diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 8051ceb4..4b416af7 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -10,14 +10,14 @@ "plugins": [ { "name": "taskflow", - "description": "Multi-phase subagent orchestration for Codex: 19 taskflow_* MCP tools (run/runs/resume/version/list/show/verify/compile/lint/plan/analytics/peek/trace/replay/why_stale/recompute/reconcile_workspace/save/search) plus a routing skill. Background runs support status/wait/cancel; compile returns SVG + text outline. The server runs via npx (codex-taskflow).", + "description": "Multi-phase subagent orchestration for Codex: 20 taskflow_* MCP tools (run/runs/resume/version/list/show/verify/compile/lint/plan/analytics/peek/trace/replay/why_stale/why_effect/recompute/reconcile_workspace/save/search) plus a routing skill. Background runs support status/wait/cancel; compile returns SVG + text outline. The server runs via npx (codex-taskflow).", "source": "./packages/codex-taskflow/plugin", "category": "developer-tools", "homepage": "https://github.com/heggria/taskflow" }, { "name": "claude-taskflow", - "description": "Multi-phase subagent orchestration for Claude Code: 19 taskflow_* MCP tools (run/runs/resume/version/list/show/verify/compile/lint/plan/analytics/peek/trace/replay/why_stale/recompute/reconcile_workspace/save/search) plus a routing skill. Background runs support status/wait/cancel; compile returns SVG + text outline. The server runs via npx (claude-taskflow).", + "description": "Multi-phase subagent orchestration for Claude Code: 20 taskflow_* MCP tools (run/runs/resume/version/list/show/verify/compile/lint/plan/analytics/peek/trace/replay/why_stale/why_effect/recompute/reconcile_workspace/save/search) plus a routing skill. Background runs support status/wait/cancel; compile returns SVG + text outline. The server runs via npx (claude-taskflow).", "source": "./packages/claude-taskflow/plugin", "category": "developer-tools", "homepage": "https://github.com/heggria/taskflow" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c11de508..162eb9da 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,6 +34,8 @@ jobs: run: pnpm install --frozen-lockfile --registry https://registry.npmjs.org/ - name: Shared completion and process-tree tests run: node --conditions=development --experimental-strip-types --test packages/taskflow-core/test/runner-process.test.ts + - name: Project storage boundary tests + run: node --conditions=development --experimental-strip-types --test packages/taskflow-core/test/store.test.ts test: name: test (node ${{ matrix.node }}) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index b3e5fbeb..c175fd54 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -3,8 +3,9 @@ name: Publish & Release # Publishes all ten packages (taskflow-core, taskflow-mcp-core, taskflow-hosts, # taskflow-dsl, pi-taskflow, codex-taskflow, claude-taskflow, opencode-taskflow, # grok-taskflow, hermes-taskflow) to npmjs.com and creates a GitHub Release when a v* tag is -# pushed (e.g. `git tag v0.2.0 && git push origin v0.2.0`). All ten workspace -# versions must equal the tag. +# pushed (e.g. `git tag v0.3.0-beta.1 && git push origin v0.3.0-beta.1`). All ten +# publishable workspace versions must equal the tag. Stable tags use npm's +# `latest` channel; prerelease tags such as `v0.3.0-beta.1` use `beta`. on: push: @@ -87,6 +88,10 @@ jobs: exit 1 fi VERSION="${TAG#v}" + if [[ "$VERSION" == *-* && "$VERSION" != 0.3.0-beta.* ]]; then + echo "::error::Only 0.3.0-beta.* prereleases are supported by this beta workflow (got $VERSION)" + exit 1 + fi if grep -Fqx "## [$VERSION] — Unreleased" CHANGELOG.md; then echo "::error::CHANGELOG.md still marks $VERSION as Unreleased" exit 1 @@ -145,7 +150,7 @@ jobs: HERMES_PINNED="v$(node -e ' const fs = require("fs"); const text = fs.readFileSync("packages/hermes-taskflow/plugin/hermes.config.snippet.yaml", "utf8"); - const match = text.match(/hermes-taskflow@(\d+\.\d+\.\d+)/); + const match = text.match(/hermes-taskflow@(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)/); if (!match) process.exit(2); process.stdout.write(match[1]); ')" @@ -162,6 +167,19 @@ jobs: # pre-publishing name@version must never be mistaken for our release. run: | set -euo pipefail + VERSION="${GITHUB_REF_NAME#v}" + if [[ "$VERSION" == *-* ]]; then + NPM_TAG="${VERSION#*-}" + NPM_TAG="${NPM_TAG%%.*}" + RELEASE_PRERELEASE=true + if [ "$NPM_TAG" != "beta" ]; then + echo "::error::Prerelease $VERSION must publish with npm dist-tag beta, not $NPM_TAG" + exit 1 + fi + else + NPM_TAG="latest" + RELEASE_PRERELEASE=false + fi tarball_for() { node -e ' const fs = require("fs"); @@ -183,7 +201,7 @@ jobs: echo "::notice::$name@$version already published and verified — skipping" else echo "Publishing $name@$version" - npm publish "$tarball" --provenance --access public + npm publish "$tarball" --provenance --access public --tag "$NPM_TAG" fi } publish_one taskflow-core @@ -280,8 +298,10 @@ jobs: exit 1 ;; esac - if [ "$RELEASE_DRAFT" != "false" ] || [ "$RELEASE_PRERELEASE" != "false" ]; then - echo "::error::Existing release must be published and non-prerelease (draft=$RELEASE_DRAFT, prerelease=$RELEASE_PRERELEASE)" + EXPECTED_PRERELEASE="false" + if [[ "$VERSION" == *-* ]]; then EXPECTED_PRERELEASE="true"; fi + if [ "$RELEASE_DRAFT" != "false" ] || [ "$RELEASE_PRERELEASE" != "$EXPECTED_PRERELEASE" ]; then + echo "::error::Existing release has wrong draft/prerelease state (draft=$RELEASE_DRAFT, prerelease=$RELEASE_PRERELEASE, expected prerelease=$EXPECTED_PRERELEASE)" exit 1 fi echo "::notice::Release $GITHUB_REF_NAME already exists with a verified target; skipping creation" @@ -294,10 +314,14 @@ jobs: exit 1 fi - gh release create "$GITHUB_REF_NAME" \ - --notes-file /tmp/release-notes.md \ - --title "v$VERSION" \ - --target "$TAG_COMMIT" \ + RELEASE_ARGS=( + "$GITHUB_REF_NAME" + --notes-file /tmp/release-notes.md + --title "v$VERSION" + --target "$TAG_COMMIT" --verify-tag + ) + if [[ "$VERSION" == *-* ]]; then RELEASE_ARGS+=(--prerelease); fi + gh release create "${RELEASE_ARGS[@]}" env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.grok-plugin/marketplace.json b/.grok-plugin/marketplace.json index e1b91743..1d9386e8 100644 --- a/.grok-plugin/marketplace.json +++ b/.grok-plugin/marketplace.json @@ -8,7 +8,7 @@ "plugins": [ { "name": "taskflow", - "description": "Multi-phase subagent orchestration for Grok Build: 19 taskflow_* MCP tools (run/runs/resume/version/list/show/verify/compile/lint/plan/analytics/peek/trace/replay/why_stale/recompute/reconcile_workspace/save/search) plus a routing skill. Background runs support status/wait/cancel; compile returns SVG + text outline. The server runs via npx (grok-taskflow).", + "description": "Multi-phase subagent orchestration for Grok Build: 20 taskflow_* MCP tools (run/runs/resume/version/list/show/verify/compile/lint/plan/analytics/peek/trace/replay/why_stale/why_effect/recompute/reconcile_workspace/save/search) plus a routing skill. Background runs support status/wait/cancel; compile returns SVG + text outline. The server runs via npx (grok-taskflow).", "source": { "type": "local", "path": "./packages/grok-taskflow/plugin" diff --git a/CHANGELOG.md b/CHANGELOG.md index f9f1a155..7e78704f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,38 @@ All notable changes to taskflow are documented here. This project follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) format. -## [Unreleased] +## [0.3.0-beta.1] — 2026-08-13 + +> **Pre-release candidate:** `0.3.0-beta.1` is prepared for npm's `beta` dist-tag. It is **not GA**. The 0.3-C Control Plane remains a follow-on candidate track, not part of this beta's shipped product definition. + +### Added + +- **Trusted Effects MVP** (`packages/taskflow-core/src/effects/`): + - EffectIR (`EFFECT_KINDS`), PathRef reuse, SecretRef/ServiceRef (type-only fail-closed) + - closed TypeBox EffectIR + confidentiality/integrity source-to-sink validation + - resource-controlled FS transaction: durable snapshot → persistent lease → journal intent/permit → stage → Commit or Restore+Reject + - declaration-only bridge in `effects/runtime-apply.ts`; no second changeset/gateway authority + - ledger-backed `whyAuthorized` / `whyContext` / `whyEffect` +- Optional phase `effects[]`; FlowIR translate/compile/hash include effects +- Built-in `detectEffectsIssues` (category `effects`) + `effectsLintVerifier` +- Every imperative phase fast path finalizes declared `fs.write` through the resource transaction; event-kernel-enabled runs use the same safe imperative path +- Honest host baseline: `conformance/workspace/host-support-baseline.json` +- Docs: `docs/internal/0.3.0-trusted-effects-mvp.md`, `0.3.0-agent-goal.md`, `0.3.0-ga-scoreboard.md` +- Example: `examples/trusted-effects-write.json` +- Tests: `test/effects*.test.ts`, `test/verify-effects.test.ts` + +### Fixed + +- Resource-bearing inline/saved/expanded/`ctx_spawn` children can no longer be skipped by parent cache or resume reuse. +- Information-flow labels compose across nested flow boundaries; unresolved dynamic definitions remain tainted, malformed non-array `effects` fail admission/compile, and `why-effect` follows DAG dependencies. +- Durable commit/abort results survive staging/lease cleanup faults, activation double faults release leases, and clean-terminal/aged-orphan before-images are garbage-collected. + +### Notes + +- SecretRef/ServiceRef have **no** vault/network backends in this cut. +- Resolve-only is not an OS sandbox. Direct writes to declared targets are detected and restored; writes outside declared targets remain host-policy dependent. +- Historical Control Plane (`feat/0.3.0`) is **not** this release definition. +- **Beta release; not GA.** ## [0.2.10] — 2026-08-12 diff --git a/README.md b/README.md index 4b29f516..c3f8b195 100644 --- a/README.md +++ b/README.md @@ -1,460 +1,222 @@
-taskflow: compile, verify, and run multi-agent DAGs across six coding-agent hosts +taskflow 0.3: trusted effects for coding-agent workflows
-[![npm](https://img.shields.io/npm/v/pi-taskflow?style=flat-square&color=7775FF&label=npm)](https://www.npmjs.com/package/pi-taskflow) [![CI](https://img.shields.io/github/actions/workflow/status/heggria/taskflow/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/heggria/taskflow/actions/workflows/ci.yml) [![Node](https://img.shields.io/badge/node-%E2%89%A522.19-35C99A?style=flat-square)](https://nodejs.org) [![License](https://img.shields.io/badge/license-MIT-35C99A?style=flat-square)](./LICENSE) -[![Hosts](https://img.shields.io/badge/hosts-6-7775FF?style=flat-square)](#install-on-your-host) -[![Tests](https://img.shields.io/badge/tests-1%2C500%2B-7775FF?style=flat-square)](#built-to-survive-real-work) +[![Hosts](https://img.shields.io/badge/hosts-6-7775FF?style=flat-square)](#host-adapters) **English** · [简体中文](./README.zh-CN.md) -[Install](#install-on-your-host) · [Quickstart](#60-second-start) · [What's new in 0.2.10](#0210-organized-portable-saved-flows) · [0.2 compiler turn](#02-is-the-compiler-turn) · [Docs](https://heggria.github.io/taskflow/en/docs) · [Examples](./examples) +[0.3 overview](#taskflow-03-trusted-effects) · [Quickstart](#quickstart) · [Docs](https://heggria.github.io/taskflow/en/docs) · [Examples](./examples) · [Changelog](./CHANGELOG.md)
--- -# Build multi-agent systems you can inspect before they run. +# taskflow 0.3: make agent side effects inspectable -**taskflow turns agent plans into compiled task graphs**: declared once, verified before model spend, executed in isolated subagents, resumed across sessions, replayed without tokens, and recomputed from the smallest stale frontier. +**taskflow is a declarative runtime for coding-agent workflows.** It turns a graph into a verifiable execution contract, runs phases in isolation, and keeps intermediate work out of the host conversation. In the 0.3 candidate, the contract also describes the effects a phase is allowed to propose. -It runs on the coding agent you already use: +> **Status: 0.3.0-beta.1 Trusted Effects beta — beta channel, not GA.** This release candidate is prepared for npm's `beta` channel; the beta ships the Trusted Effects MVP described below. The 0.3-C Control Plane remains a follow-on candidate track; it is not a shipped beta surface. -**Pi · Codex · Claude Code · OpenCode · Grok Build · Hermes Agent** +## The 0.3 idea + +An agent can propose content. It should not become the mutation authority merely because it can run a command. + +For admitted, declared filesystem-write targets, taskflow 0.3 makes the path explicit and routes the final mutation through the resources transaction: ```text -JSON or .tf.ts - │ - ▼ - validate ──► Taskflow JSON ──► FlowIR + content hash +flow / .tf.ts + │ + ▼ + validate + verify ──► EffectIR + FlowIR hash + │ │ + │ ▼ + │ admit declared targets + │ │ + ▼ ▼ + isolated phase ───────► stage → commit | restore + reject │ ▼ - isolated DAG runtime - │ - ┌────────────┼────────────┐ - ▼ ▼ ▼ - resume replay recompute + ledger-backed why-effect ``` -> Your host receives the final result. Intermediate transcripts stay inside the runtime unless you explicitly inspect them. +This is **not** an OS sandbox. Resolve-only hosts cannot prevent every write to an undeclared path. Secret and service references are typed and fail closed in this cut; they do not imply a vault or network backend. -## Why taskflow? +## What is in the candidate -Built-in subagent tools are excellent for one turn. The moment the work branches, retries, crosses sessions, or needs a quality gate, the plan becomes infrastructure. - -| | Ad-hoc agents / scripts | **taskflow** | +| Layer | What it does | Candidate status | |---|---|---| -| **Plan** | Re-derived from prose or hidden in a script | **An explicit, versionable DAG** | -| **Before execution** | Discover mistakes while spending | **Verify structure at zero model calls** | -| **Intermediate output** | Floods the host context | **Stays isolated in the runtime** | -| **Failure** | Start over or reconstruct state | **Resume from persisted phase state** | -| **Changed input** | Re-run broadly | **Explain staleness and re-run the affected frontier** | -| **Portability** | Coupled to one agent | **One JSON contract across six hosts** | +| **Taskflow runtime** | Declarative DAGs, 12 phase types, budgets, retries, approvals, isolation, resume, replay, trace, and recompute | Existing 0.2 foundation | +| **Trusted Effects** | Closed `EffectIR`, `PathRef` / `SecretRef` / `ServiceRef`, confidentiality/integrity labels, effect validation, overlap checks, and ledger-backed `why-*` explainers | 0.3 MVP implementation | +| **Resource transaction** | Snapshot → lease → durable intent/permit → stage → commit, or restore and reject | 0.3 MVP implementation | +| **Host adapters** | Pi, Codex, Claude Code, OpenCode, Grok Build, and Hermes Agent use the same flow contract | Existing host surface; support remains host-specific | +| **Control Plane** | ControlHost scaffold, proposed wire contracts, singleton/fencing, and hello negotiation; future stores, approvals, receipts, and coordination | Active 0.3-C track; not shipped and not the 0.3 MVP GA claim | +| **WebUI** | Runs, approvals, receipts, and evidence browsing | Planned in the 0.3-C sequence; not shipped in this candidate | -The trade is deliberate: less arbitrary orchestration code, more **verifiability, observability, recovery, and reuse**. +The normative MVP definition is [`docs/internal/0.3.0-trusted-effects-mvp.md`](./docs/internal/0.3.0-trusted-effects-mvp.md). The 0.3-C Control Plane plan is [`docs/internal/0.3-c-control-plane-plan.md`](./docs/internal/0.3-c-control-plane-plan.md). -## 60-second start +## Quickstart -Install taskflow on [Pi](https://pi.dev): +The 0.3 beta can be installed from npm, or exercised from a clean source checkout. Use Node.js **≥ 22.19.0**: ```bash -pi install npm:pi-taskflow +git clone https://github.com/heggria/taskflow.git +cd taskflow +git checkout rc/0.3.0-trusted-effects +pnpm install +pnpm run typecheck +pnpm test ``` -Then ask naturally: - -> Use taskflow to audit `src/api` in parallel and return one prioritized report. - -The routing skill uses the same familiar `task` / `tasks` / `chain` shape: - -```json -{ - "chain": [ - { "agent": "scout", "task": "Map the public API under src/api." }, - { - "agent": "security-reviewer", - "task": "Audit this surface for missing auth and unsafe input boundaries:\n{previous.output}" - }, - { - "agent": "reviewer", - "task": "Turn these findings into one prioritized report:\n{previous.output}" - } - ] -} +The beta commands below become usable after the tag workflow completes; until then they are release-target examples, not proof of registry availability. +```bash +npm install --global pi-taskflow@beta +npm install --global codex-taskflow@beta ``` -That already gives you an isolated, tracked run. When the job needs real topology, declare the graph: +The host-specific plugin and MCP commands remain in the [host guides](https://heggria.github.io/taskflow/en/docs/guides/). Stable 0.2.x installs remain available through exact stable pins. -```json -{ - "name": "audit-api", - "args": { "dir": { "default": "src/api" } }, - "concurrency": 4, - "phases": [ - { - "id": "discover", - "type": "agent", - "agent": "scout", - "task": "List source files under {args.dir}. Output ONLY a JSON array of {\"path\":\"...\"} objects.", - "output": "json" - }, - { - "id": "audit-each", - "type": "map", - "over": "{steps.discover.json}", - "as": "file", - "agent": "security-reviewer", - "task": "Audit {file.path}. Cite evidence and assign severity.", - "dependsOn": ["discover"] - }, - { - "id": "report", - "type": "reduce", - "from": ["audit-each"], - "agent": "reviewer", - "task": "Synthesize one prioritized report:\n{steps.audit-each.output}", - "dependsOn": ["audit-each"], - "final": true - } - ] -} -``` - -Save it as `.pi/taskflows/audit-api.json`, then run: +Run the no-LLM Trusted Effects vertical-slice fixture: -```text -/tf:audit-api dir=src/api +```bash +pnpm exec node --conditions=development --experimental-strip-types --test \ + packages/taskflow-core/test/effects-e2e-fixture.test.ts ``` -On Codex, Claude Code, OpenCode, Grok Build, and Hermes Agent, run the same saved definition by name through `taskflow_run`. For long DAGs, use `mode: "background"`, then manage the durable run with `taskflow_runs` (`list` / `status` / `wait` / `cancel`); list output reports active concurrency and can filter `running` or `terminal` runs. +This exercises the checked-in `examples/trusted-effects-write.json` path without a live LLM. For an interactive run, use the host guide for the adapter you already run. The stable 0.2 installation path remains documented separately in the [host guides](https://heggria.github.io/taskflow/en/docs/guides/). -Large projects may organize saved definitions recursively below `.pi/taskflows/flows/`, for example `.pi/taskflows/flows/release/audit-api.json`. Legacy `.pi/taskflows/*.json` files remain discoverable and win same-scope name collisions; nested duplicates use locale-independent Unicode-scalar path order. Saving an already-discovered nested flow updates that file and its adjacent metadata in place; new flows still use the legacy top-level location. Discovery uses one shared user/project budget and fails closed if it exceeds 1,000 flows, 10,000 visited entries, 512 directories, 8 MiB of definition data, 1 MiB per definition, or 16 levels. It rejects symlinks below the trusted storage boundary through definition leaves and skips dot paths, metadata (`*.meta.json`), and compiled IR (`*.flowir.json`). The configured agent-directory boundary itself may be a symlink for compatible home-directory relocation. New-flow saves enforce the same storage-boundary policy and revalidate the physical target directory inside the write lock. +## Declare an effect -A file-backed flow can opt script phases into definition-relative execution: +Effects are part of the flow contract, not a free-form prompt promise: ```json { - "name": "release", - "scriptCwd": "flow", + "name": "trusted-effects-write", "phases": [ - { "id": "prepare", "type": "script", "run": ["./scripts/prepare.sh"], "final": true } + { + "id": "write-report", + "type": "script", + "run": ["node", "scripts/render-report.mjs"], + "effects": [ + { + "id": "report", + "kind": "fs.write", + "purpose": "write final report", + "target": { + "kind": "path", + "path": { + "workspace": "project", + "subpath": { "literalPath": "out/report.md" }, + "intent": "create-file" + } + }, + "confidentiality": "internal", + "integrity": "project" + } + ], + "final": true + } ] } ``` -Here `./scripts/prepare.sh` resolves from the directory containing the saved flow or `defineFile`. The default remains `"invocation"`, and an explicit phase `cwd` still takes precedence. Inline definitions have no trusted file source and therefore fail closed if they request `scriptCwd: "flow"`. If execution inherits a cwd-bridge boundary, the resolved flow source directory must remain inside that boundary. - -[Follow the full quickstart →](https://heggria.github.io/taskflow/en/docs/getting-started) +The declaration is not authorization by itself. The runtime resolves the `PathRef`, checks labels and overlaps, records the resource intent, and only then permits the transaction to stage and finalize the declared target. `taskflow_why_effect` explains the resulting authorization and ledger state without model calls. -## See the graph run +## The runtime contract -This is real output from a Pi run—not a mock dashboard: +The 0.2 runtime remains the foundation. A flow can be authored as portable JSON or compiled from TypeScript DSL to FlowIR: ```text -⊗ taskflow self-improve 6/7 · blocked · $0.095 - ✓ discover agent deepseek-v4-flash 10t ↑38k ↓6.7k $0.011 - ┌ ✓ write-runner-tests agent claude-sonnet-4-6 10t ↑13 ↓6.6k $0.020 - ├ ✓ write-store-tests agent claude-sonnet-4-6 10t ↑11 ↓10k $0.018 - ├ ✓ write-agents-tests agent claude-sonnet-4-6 10t ↑28 ↓13k $0.030 - └ ✓ fix-stability agent claude-sonnet-4-6 10t ↑13 ↓3.9k $0.012 - ✓ verify gate BLOCK 3 type errors in test files - ⊘ report reduce skipped · Gate blocked ↳ fix-stability -``` - -The layout **is** the DAG. Parallel rails expose concurrency; long edges expose dependencies; the gate explains why downstream work stopped. No separate control plane is required to understand the run. - -## 0.2.10: organized, portable saved flows - -Saved flows can now be organized below the bounded `.pi/taskflows/flows/**` convention while legacy top-level flows keep their existing precedence and behavior. A file-backed flow may opt into `scriptCwd: "flow"`, making adjacent scripts, templates, and fixtures portable as one reviewable directory bundle. - -Discovery, provenance, and persistence remain fail-closed: recursion has shared file/entry/directory/byte/depth budgets, symlinked descendants are excluded, source identity survives foreground/background/resume/subflow paths, and nested definition/sidecar writes revalidate the physical parent through atomic promotion. [Full 0.2.10 notes →](./CHANGELOG.md#0210--2026-08-12) - -## 0.2.9: Hermes Agent + verify parity - -Taskflow now ships on **Hermes Agent** as `hermes-taskflow`, bringing the same MCP control plane to a sixth host. Hermes children run with an ephemeral home, explicit toolsets, cwd-confined local reads, provider-only credential material, and an explicit opt-in for mutating `--yolo` phases. - -Pi's advertised `/tf verify ` command now matches the tool surface, including saved flow names containing spaces. Project discovery also stops at canonical home/temp boundaries, so ambient `/tmp/.pi` state cannot become a project by accident. [Full 0.2.9 notes →](./CHANGELOG.md#029--2026-08-11) - -## 0.2.8: review, then confirm - -Pi approvals now separate **selection** from **commit**. Choose Reject, Edit guidance, or Approve with `R` / `E` / `A`, arrows, or Tab; press Enter to confirm. The safe default is Reject, and Escape or Ctrl-C still rejects immediately. - -Long proposals start collapsed. Press `V` to open an inline scrollable preview while the decision footer stays visible; short proposals remain open by default. Full notes: [CHANGELOG 0.2.8](./CHANGELOG.md#028--2026-08-10). - -## 0.2.7: plan before spend · close the loop - -The 0.2 line made graphs **compiled and inspectable**. **0.2.7** makes the day-to-day loop feel finished: you can see the plan *before* any model call, and you can hear about the run *after* it finishes — without stuffing transcripts into the host. - -| Before spend | After spend | -|---|---| -| **`taskflow_plan` / `/tf plan`** — bind typed args, project phase order, mark dynamic refs, worst-case agent-call bound | **`hooks.onComplete` / `onFail` / `onBlocked`** — webhook, file, or argv-only command; summary payload only (`taskflow.hook.v1`) | -| **`verify` / `lint`** still free | **`approval.timeoutMs` + `onExpire`** — HITL no longer waits forever | -| **`recompute` savings line** — `reused N · rerun M · cutoff K · saved ~P%` | **`taskflow_analytics`** — last-N status, duration, fail/cache rates (read-only) | - -```bash -# Zero tokens: see what would run and how expensive the worst case looks -# MCP: taskflow_plan · Pi: /tf plan my-flow '{"dir":"src"}' -``` - -```jsonc -// Optional: fire-and-forget when a background run finishes -{ - "hooks": { - "onComplete": [{ "type": "file", "path": ".taskflow/hooks/last-complete.json" }] - } -} -``` - -MCP hosts now expose **19 tools** (added `taskflow_plan` and `taskflow_analytics`). Starter templates: [`examples/templates/`](./examples/templates/). Full notes: [CHANGELOG 0.2.7](./CHANGELOG.md#027--2026-08-06). - -## 0.2 is the compiler turn - -Before 0.2, taskflow executed declarative graphs. Now the graph also has a compile-time frontend, a canonical intermediate representation, an append-only decision trace, offline replay, and incremental recompute. - -### Author in JSON or TypeScript - -JSON remains the portable runtime contract. For larger flows, `taskflow-dsl` adds a compile-time TypeScript authoring layer: - -```ts -import { agent, flow, json, map, reduce } from "taskflow-dsl"; - -export default flow("audit", (ctx) => { - ctx.budget({ maxUSD: 2 }); - - const files = agent("List files under {args.dir}", { - agent: "scout", - output: json<{ path: string }[]>(), - }); - - const audits = map(files, (file) => - agent(`Audit ${file.path}`, { agent: "security-reviewer" }), - ); - - return reduce( - [audits], - (parts) => agent(`Write one report:\n${parts.audits.output}`), - { final: true }, - ); -}); -``` - -```bash -pnpm add -D taskflow-dsl -taskflow-dsl check audit.tf.ts -taskflow-dsl build audit.tf.ts --emit both -# → audit.taskflow.json + audit.flowir.json +JSON / .tf.ts + │ + ▼ +validate → Taskflow JSON → FlowIR + content hash + │ + ▼ + isolated DAG runtime + │ + resume · replay · recompute · trace + │ + ▼ + finalOutput to the host ``` -`.tf.ts` is **compile-time only**. Hosts execute the emitted Taskflow JSON; they never interpret TypeScript. - -### Compile to a contract you can reason about - -FlowIR canonicalizes the graph and gives it a content hash. That compiled identity makes provenance and stale analysis inspectable, while the runtime adds content-addressed caching and deterministic tools: - -| Operation | What it answers | Model calls | -|---|---|---:| -| **`plan`** | What will run, which args bind, worst-case agent calls? | **0** | -| `verify` / `compile` / `lint` | Is the graph structurally safe / lint-clean? | **0** | -| `ir` | What is the canonical graph and content hash? | **0** | -| `resume` | What unfinished work remains? (forks a new run; original untouched) | Only unfinished phases | -| `trace` | What calls and runtime decisions actually happened? | **0** to inspect | -| `replay` | What if thresholds or budgets had been different? | **0** | -| `why-stale` | What changed, and what depends on it? | **0** | -| `recompute` | What is the smallest observable affected frontier? (+ savings line) | Only affected phases | -| `analytics` | How have recent runs of this flow behaved? | **0** | - -[Explore the compiler and runtime →](https://heggria.github.io/taskflow/en/docs/compiler-runtime/) - ## One runtime, 12 phase types | Family | Phases | Use them for | |---|---|---| -| **Work** | `agent` · `parallel` · `map` · `reduce` · `script` | Single tasks, static fan-out, dynamic fan-out, aggregation, zero-token shell steps | -| **Control** | `gate` · `approval` · `flow` · `loop` | Quality decisions, human checkpoints, composition, iterative refinement | +| **Work** | `agent` · `parallel` · `map` · `reduce` · `script` | Single tasks, static concurrency, dynamic fan-out, aggregation, and zero-token shell steps | +| **Control** | `gate` · `approval` · `flow` · `loop` | Quality decisions, human checkpoints, composition, and iterative refinement | | **Selection** | `tournament` · `race` | Best-of-N quality or first-success latency | -| **Dynamic graph** | `expand` | Validate and execute a runtime-produced fragment, nested or grafted | - -Across those phase types, the DSL provides dependencies, conditions, retries, timeouts, output contracts, budgets, workspace isolation, and explicit final-output selection. Each kind accepts only the fields that are safe and meaningful for it; freshness-sensitive phases are excluded from cross-run caching. - -[Read the phase reference →](https://heggria.github.io/taskflow/en/docs/syntax/phase-types) - -## Runtime guarantees, not prompt conventions - -### Verify before spend - -Cycles, dangling dependencies, invalid references, impossible joins, unsafe dynamic fragments, and configuration hazards are rejected or surfaced before the expensive work starts. - -### Keep intermediate work out of the host context - -Agent-running phases execute in isolated subagent processes; control and script phases stay inside the runtime. Upstream outputs are wired into downstream inputs internally. Only `finalOutput` returns to the host unless you explicitly use `peek` or `trace`. - -### Survive sessions and failures - -Phase state is persisted atomically. Resume skips unchanged completed work; detached Pi runs can outlive the initiating session; an idle watchdog terminates stalled subagents. - -### Reuse work honestly - -Within-run resume is content-addressed. Cross-run caching is opt-in and can fingerprint Git commits, files, globs, environment variables, and TTLs. Change one declared input and only its dependents become stale. - -### Bound the blast radius - -Budgets, concurrency caps, retries, timeouts, nesting limits, dynamic-graph breadth caps, path containment, non-idempotent phase classification, and fail-closed approval behavior are runtime semantics—not suggestions in a prompt. - -### 0.2.1: safe dynamic cwd and Pi terminal reaping - -An invocation argument declared as `type: "relative-path"` may select a phase -working directory with the exact form `cwd: "{args.package}"`. The bridge is -default-off, requires host `resolve-only` authorization, and confines the -canonical directory to the invocation root. Absolute paths, concatenation, and -`{steps.*}` remain rejected; this compatibility bridge is not an OS sandbox. -Resolve-only writer phases within one invocation are serialized before durable -lease acquisition, so fan-out cannot self-timeout while separate processes -remain protected by cross-process leases. +| **Dynamic graph** | `expand` | Validate and execute a runtime-produced nested or grafted fragment | -Pi child agents no longer inherit ambient extensions by default. Trusted host -settings can use an explicit extension allowlist or opt back into legacy -inheritance. If a Pi child produces a validated final answer and terminal event -but an extension keeps the process alive, Taskflow waits a bounded grace window, -reaps the process group, and records `completionSource: "terminal-reap"` instead -of reporting a false timeout. +Across those phase types, the runtime provides shared behavior: dependencies, conditions, retries, timeouts, output contracts, budgets, workspace isolation, explicit final-output selection, and persistence for resume. Each phase kind accepts only the fields that are safe and meaningful for it. -```json -{ - "taskflow": { - "piChild": { - "resourceProfile": "isolated", - "extensions": [], - "terminalGraceMs": 1500 - } - } -} -``` - -`allowlist` accepts explicit trusted extension files; `inherit` restores ambient -Pi extension discovery as a compatibility mode. Flows cannot widen this host -authority. - -[Read the core concepts →](https://heggria.github.io/taskflow/en/docs/concepts/) - -## Install on your host - -All packages require **Node.js ≥ 22.19.0**. - -### Pi - -```bash -pi install npm:pi-taskflow -``` - -Pi provides the richest local experience: the `taskflow` tool, `/tf` commands, live DAG rendering, interactive approvals, background runs, and model-role setup. - -[Pi guide →](https://heggria.github.io/taskflow/en/docs/guides/pi) - -### OpenAI Codex - -```bash -codex plugin marketplace add heggria/taskflow -codex plugin add taskflow@taskflow -``` +Useful zero-token operations include: -[Codex guide →](https://heggria.github.io/taskflow/en/docs/guides/codex) - -### Claude Code - -```bash -claude plugin marketplace add heggria/taskflow -claude plugin install claude-taskflow@taskflow -``` - -[Claude Code guide →](https://heggria.github.io/taskflow/en/docs/guides/claude-code) - -### OpenCode - -```bash -opencode mcp add taskflow -- \ - npx -y -p opencode-taskflow@0.2.10 opencode-taskflow-mcp -``` - -[OpenCode guide →](https://heggria.github.io/taskflow/en/docs/guides/opencode) - -### Grok Build - -```bash -grok mcp add taskflow -- \ - npx -y -p grok-taskflow@0.2.10 grok-taskflow-mcp -``` - -Grok Build support is new in 0.2. Its CLI stream does not report token/cost usage, so budget-declaring flows are rejected rather than silently running without enforcement. - -[Grok Build guide →](https://heggria.github.io/taskflow/en/docs/guides/grok-build) - -### Hermes Agent - -```bash -hermes mcp add taskflow --command npx --args -y -p hermes-taskflow@0.2.10 hermes-taskflow-mcp -# Prefer env in config.yaml (not CLI --env after args — can be stuffed into argv): -# mcp_servers.taskflow.env.PI_TASKFLOW_HERMES_UNSAFE_YOLO: "1" # mutating only -``` - -Hermes quiet mode does not report token/cost usage, so budget-declaring flows are rejected rather than silently running without enforcement. Child agents use an ephemeral HERMES_HOME with only non-secret model/fallback routing, a routed-provider-only inference `auth.json`, and provider-allowlisted dotenv keys; parent MCP, skills, memory, sessions, and rules are not inherited. RO local-read → `taskflow_readonly_files`; else `taskflow_model_only` (never omit `-t`). - -[Hermes guide →](./docs/hermes-mcp.md) - - -## Built to survive real work - -
- -**10 packages** · **6 hosts** · **12 phase types** · **18 built-in agents** · **1,500+ tests** · **MIT** +| Operation | Question it answers | +|---|---| +| `taskflow_plan` | What will run, what arguments bind, and what is the worst-case agent-call bound? | +| `taskflow_verify` / `taskflow_compile` | Is the graph structurally valid and what is its canonical form? | +| `taskflow_trace` / `taskflow_replay` | What happened, or what would a zero-token what-if replay decide? | +| `taskflow_why_stale` / `taskflow_recompute` | What changed and what is the smallest affected frontier? | +| `taskflow_why_effect` | Why was a declared effect allowed, staged, committed, rejected, or left unknown? | +| `taskflow_analytics` | How have recent runs behaved? | -
+The MCP surface currently exposes **20 tools**. Intermediate transcripts remain inside the runtime unless you explicitly inspect them with `peek` or `trace`; the host normally receives only `finalOutput`. -```text - taskflow-core - ┌──────────────┼───────────────┐ - │ │ │ - taskflow-dsl pi-taskflow taskflow-mcp-core ─┐ - taskflow-hosts ─────┼─ codex-taskflow - ├─ claude-taskflow - ├─ opencode-taskflow - └─ grok-taskflow / hermes-taskflow -``` +## Host adapters -`taskflow-core` is host-neutral and imports no host SDK. `taskflow-mcp-core` implements stdio JSON-RPC without an MCP SDK dependency; `taskflow-hosts` owns the shared host process runners. The five MCP delivery packages bind both layers (and core), while Pi keeps its native adapter. +The same flow contract can be delivered through six coding-agent hosts: -The test suite covers orchestration semantics, persistence and file-lock races, cache freshness, path traversal, dynamic graph hardening, cancellation, budgets, all 12 phase kinds, FlowIR/replay/recompute, TypeScript DSL erasure, host argv contracts, MCP servers, and packed consumer imports. +- **Pi** — native extension, `/tf` commands, live run views, and interactive approvals. +- **Codex** — plugin and stdio MCP server. +- **Claude Code** — plugin and stdio MCP server. +- **OpenCode** — MCP configuration and generated skill. +- **Grok Build** — MCP configuration and generated skill. +- **Hermes Agent** — MCP delivery with explicit child toolsets and isolation policy. -## Documentation +Host support is not a blanket security guarantee. Read the [host support baseline](./conformance/workspace/host-support-baseline.json) and the [Trusted Effects documentation](./docs/internal/0.3.0-trusted-effects-mvp.md) before enabling mutating phases. -| Start here | When you need | -|---|---| -| [Getting Started](https://heggria.github.io/taskflow/en/docs/getting-started) | Your first successful run | -| [Concepts](https://heggria.github.io/taskflow/en/docs/concepts/) | DAGs, isolation, verification, resume, shared context | -| [Syntax](https://heggria.github.io/taskflow/en/docs/syntax/) | Phase fields, control flow, budgets, caching, scorers | -| [Compiler & Runtime](https://heggria.github.io/taskflow/en/docs/compiler-runtime/) | TypeScript DSL, FlowIR, replay, recompute, background runs | -| [Host Guides](https://heggria.github.io/taskflow/en/docs/guides/) | Pi, Codex, Claude Code, OpenCode, Grok, and Hermes setup | -| [Reference](https://heggria.github.io/taskflow/en/docs/reference/) | Commands, shorthand, and exact tool surfaces | -| [Showcase](https://heggria.github.io/taskflow/en/docs/showcase/) | Real flows and case studies | -| [0.2.0 Frontier Assessment](./docs/taskflow-0.2.0-frontier-assessment.zh-CN.md) | Independent, evidence-based technical assessment (Chinese) | +## Security boundaries we state plainly -Also see [`examples/`](./examples), the [changelog](./CHANGELOG.md), and the [release guide](./RELEASE.md). +- `effects[]` is a declaration and validation surface; it is not ambient authority. +- The resources layer is the only finalizer for admitted declared filesystem effects. +- Direct writes to declared targets are detected and restored by the MVP path. +- Writes to undeclared paths remain host-policy dependent under resolve-only execution. +- `SecretRef` and `ServiceRef` are typed handles only; no vault or live service adapter ships in this cut. +- There is no FileBroker or full OS sandbox claim in 0.3 MVP. +- Control Plane stores, approvals, receipts, and WebUI are future 0.3-C stages, not proof that 0.3 is released or GA. -## Contributing +## Development ```bash pnpm install pnpm run typecheck pnpm test pnpm run build +pnpm run build:website pnpm run test:pack ``` -Contributions are welcome. Start with [`CONTRIBUTING.md`](./CONTRIBUTING.md) for the workflow and [`AGENTS.md`](./AGENTS.md) for architecture and coding conventions. +The monorepo contains the host-neutral `taskflow-core`, Trusted Effects and resources code, the `taskflow-control` 0.3-C contract package, the TypeScript DSL, MCP/host adapters, examples, and the website. See [`AGENTS.md`](./AGENTS.md) for architecture and coding conventions. + +## Documentation + +| Start here | Use it for | +|---|---| +| [0.3 overview](https://heggria.github.io/taskflow/en/docs) | Candidate scope, status, and the honest security boundary | +| [Getting Started](https://heggria.github.io/taskflow/en/docs/getting-started) | First flow and host setup | +| [Core Concepts](https://heggria.github.io/taskflow/en/docs/concepts/) | DAGs, isolation, verification, resume, and evidence | +| [Compiler & Runtime](https://heggria.github.io/taskflow/en/docs/compiler-runtime/) | JSON, TypeScript DSL, FlowIR, replay, and recompute | +| [Host Guides](https://heggria.github.io/taskflow/en/docs/guides/) | Pi, Codex, Claude Code, OpenCode, Grok, and Hermes | +| [Examples](./examples) | Runnable flow definitions, including Trusted Effects | +| [Changelog](./CHANGELOG.md) | Release history and candidate notes | ## License @@ -462,8 +224,8 @@ Contributions are welcome. Start with [`CONTRIBUTING.md`](./CONTRIBUTING.md) for
-**Declare once. Verify first. Recompute only what changed.** +**Declare the effect. Verify the path. Commit through one authority.** -[Read the docs](https://heggria.github.io/taskflow/en/docs) · [Try an example](./examples) · [View releases](https://github.com/heggria/taskflow/releases) +[Read the docs](https://heggria.github.io/taskflow/en/docs) · [Try the candidate](#quickstart) · [View releases](https://github.com/heggria/taskflow/releases)
diff --git a/README.zh-CN.md b/README.zh-CN.md index 4213088f..a5fb5d2f 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -1,464 +1,232 @@
-taskflow:跨六个编程智能体宿主编译、验证并运行多智能体 DAG +taskflow 0.3:让 coding-agent 工作的副作用可检查
-[![npm](https://img.shields.io/npm/v/pi-taskflow?style=flat-square&color=7775FF&label=npm)](https://www.npmjs.com/package/pi-taskflow) [![CI](https://img.shields.io/github/actions/workflow/status/heggria/taskflow/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/heggria/taskflow/actions/workflows/ci.yml) [![Node](https://img.shields.io/badge/node-%E2%89%A522.19-35C99A?style=flat-square)](https://nodejs.org) [![License](https://img.shields.io/badge/license-MIT-35C99A?style=flat-square)](./LICENSE) -[![Hosts](https://img.shields.io/badge/hosts-6-7775FF?style=flat-square)](#安装到你的宿主) -[![Tests](https://img.shields.io/badge/tests-1%2C500%2B-7775FF?style=flat-square)](#为真实工作而生) +[![Hosts](https://img.shields.io/badge/hosts-6-7775FF?style=flat-square)](#宿主适配器) [English](./README.md) · **简体中文** -[安装](#安装到你的宿主) · [快速开始](#60-秒开始) · [0.2.10 新能力](#0210可组织可携带的-saved-flow) · [0.2 编译器转身](#02-是编译器转身) · [文档](https://heggria.github.io/taskflow/zh-cn/docs) · [示例](./examples) +[0.3 总览](#taskflow-03-trusted-effects) · [快速开始](#快速开始) · [文档](https://heggria.github.io/taskflow/zh-cn/docs) · [示例](./examples) · [变更记录](./CHANGELOG.md)
--- -# 构建那些在运行前就能看清楚的多智能体系统。 +# taskflow 0.3:让智能体副作用可检查 -**taskflow 把智能体计划变成可编译的任务图**:只声明一次,在模型花费前验证,通过隔离子智能体执行,跨会话续跑,零 token 重放,并从最小陈旧前沿开始重算。 +**taskflow 是面向 coding-agent 工作流的声明式运行时。** 它把任务图变成可验证的执行合同,让阶段隔离运行,并把中间过程留在宿主对话之外。在 0.3 candidate 中,这份合同还可以描述每个阶段被允许提出的副作用。 -它运行在你已经使用的编程智能体上: +> **状态:0.3.0-beta.1 Trusted Effects beta——beta channel,尚未 GA。** 当前 release candidate 已准备发布到 npm 的 `beta` channel;beta 包含下文所述的 Trusted Effects MVP。0.3-C Control Plane 仍是后续 candidate 轨道,不是 beta 已交付的产品表面。 -**Pi · Codex · Claude Code · OpenCode · Grok Build · Hermes Agent** +## 0.3 的核心想法 + +智能体可以提出内容,但不应该因为能执行命令,就自动成为文件修改的最终权威。 + +对于已准入、已声明的文件写入目标,taskflow 0.3 把路径写进合同,并让最终修改经过 resources transaction: ```text -JSON 或 .tf.ts - │ - ▼ - 验证 ──► Taskflow JSON ──► FlowIR + 内容哈希 +flow / .tf.ts + │ + ▼ + validate + verify ──► EffectIR + FlowIR hash + │ │ + │ ▼ + │ 准入已声明目标 + │ │ + ▼ ▼ + 隔离阶段 ─────────────► stage → commit | restore + reject │ ▼ - 隔离 DAG 运行时 - │ - ┌────────────┼────────────┐ - ▼ ▼ ▼ - 续跑 重放 重算 + ledger-backed why-effect ``` -> 宿主收到的是最终结果。中间转录留在运行时里,除非你明确要求查看。 +这**不是 OS sandbox**。在 resolve-only 宿主上,taskflow 不能阻止所有对未声明路径的写入。Secret 和 service reference 在这一版只是类型化、失败关闭的句柄,并不代表已经有 vault 或网络后端。 -## 为什么是 taskflow? +## candidate 里有什么 -内置 subagent 工具非常适合单轮委派。但一旦工作开始分支、重试、跨会话,或需要质量门控,计划本身就成了基础设施。 - -| | 即席 agent / 脚本 | **taskflow** | +| 层 | 作用 | candidate 状态 | |---|---|---| -| **计划** | 每次从 prose 重推,或藏在脚本里 | **显式、可版本化的 DAG** | -| **执行前** | 边花钱边发现错误 | **零模型调用验证结构** | -| **中间输出** | 涌入宿主上下文 | **隔离在运行时里** | -| **失败后** | 从头开始或手工恢复状态 | **从持久化阶段状态续跑** | -| **输入变化** | 大范围重跑 | **解释过期原因,只重跑受影响前沿** | -| **可移植性** | 绑定单一智能体 | **同一份 JSON 合同跨六个宿主** | +| **Taskflow runtime** | 声明式 DAG、12 种阶段、预算、重试、审批、隔离、续跑、replay、trace 与 recompute | 已有的 0.2 基础 | +| **Trusted Effects** | 封闭的 `EffectIR`、`PathRef` / `SecretRef` / `ServiceRef`、机密性/完整性标签、effect 校验、重叠检查与 ledger-backed `why-*` | 0.3 MVP 实现 | +| **Resource transaction** | snapshot → lease → durable intent/permit → stage → commit,或 restore and reject | 0.3 MVP 实现 | +| **宿主适配器** | Pi、Codex、Claude Code、OpenCode、Grok Build、Hermes Agent 共用同一 flow 合同 | 已有宿主表面;能力仍按宿主区分 | +| **Control Plane** | ControlHost 脚手架、拟议中的 wire contract、singleton/fencing 与 hello 协商;后续再实现 store、审批、receipt 与协调 | 活跃的 0.3-C 轨道;尚未交付,也不是 0.3 MVP 的 GA 声明 | +| **WebUI** | runs、审批、receipts 与 evidence 浏览 | 0.3-C 计划中的后续阶段;当前 candidate 未交付 | -这是一项有意的取舍:少一点任意编排代码,换来更多的**可验证性、可观测性、恢复能力与复用**。 +规范性的 MVP 定义见 [`docs/internal/0.3.0-trusted-effects-mvp.md`](./docs/internal/0.3.0-trusted-effects-mvp.md)。0.3-C Control Plane 计划见 [`docs/internal/0.3-c-control-plane-plan.md`](./docs/internal/0.3-c-control-plane-plan.md)。 -## 60 秒开始 +## 快速开始 -在 [Pi](https://pi.dev) 上安装 taskflow: +0.3 beta 可以从 npm 安装,也可以从源码 checkout 运行。准备 Node.js **≥ 22.19.0**: ```bash -pi install npm:pi-taskflow +git clone https://github.com/heggria/taskflow.git +cd taskflow +git checkout rc/0.3.0-trusted-effects +pnpm install +pnpm run typecheck +pnpm test ``` -然后自然地提出需求: - -> 用 taskflow 并行审计 `src/api`,最后只返回一份按优先级排列的报告。 +以下 beta 命令在 tag workflow 完成后可用;在此之前它们只是发版目标示例,不代表 registry 已可获取。 -路由 skill 使用你已经熟悉的 `task` / `tasks` / `chain` 形式: - -```json -{ - "chain": [ - { "agent": "scout", "task": "Map the public API under src/api." }, - { - "agent": "security-reviewer", - "task": "Audit this surface for missing auth and unsafe input boundaries:\n{previous.output}" - }, - { - "agent": "reviewer", - "task": "Turn these findings into one prioritized report:\n{previous.output}" - } - ] -} +```bash +npm install --global pi-taskflow@beta +npm install --global codex-taskflow@beta ``` -这样就已经得到一次隔离、可追踪的运行。当任务需要真正的拓扑结构时,声明整张图: +各宿主的 plugin 与 MCP 命令见[宿主指南](https://heggria.github.io/taskflow/zh-cn/docs/guides/)。稳定的 0.2.x 安装仍可使用精确 stable pin。 -```json -{ - "name": "audit-api", - "args": { "dir": { "default": "src/api" } }, - "concurrency": 4, - "phases": [ - { - "id": "discover", - "type": "agent", - "agent": "scout", - "task": "List source files under {args.dir}. Output ONLY a JSON array of {\"path\":\"...\"} objects.", - "output": "json" - }, - { - "id": "audit-each", - "type": "map", - "over": "{steps.discover.json}", - "as": "file", - "agent": "security-reviewer", - "task": "Audit {file.path}. Cite evidence and assign severity.", - "dependsOn": ["discover"] - }, - { - "id": "report", - "type": "reduce", - "from": ["audit-each"], - "agent": "reviewer", - "task": "Synthesize one prioritized report:\n{steps.audit-each.output}", - "dependsOn": ["audit-each"], - "final": true - } - ] -} -``` - -保存为 `.pi/taskflows/audit-api.json`,然后运行: +运行不需要 LLM 的 Trusted Effects vertical-slice fixture: -```text -/tf:audit-api dir=src/api +```bash +pnpm exec node --conditions=development --experimental-strip-types --test \ + packages/taskflow-core/test/effects-e2e-fixture.test.ts ``` -在 Codex、Claude Code、OpenCode、Grok Build 和 Hermes Agent 上,通过 `taskflow_run` 按名称运行同一份保存定义。长任务可使用 `mode: "background"`,再用 `taskflow_runs` 执行 `list` / `status` / `wait` / `cancel`,无需担心单次 MCP 调用超时;列表会显示当前并发数,并可筛选 `running` 或 `terminal` 运行。 +它会在没有 live LLM 的情况下执行仓库内的 `examples/trusted-effects-write.json` 路径。要交互式运行,请按当前使用的宿主查看对应指南。稳定的 0.2 安装路径仍在[宿主指南](https://heggria.github.io/taskflow/zh-cn/docs/guides/)中单独说明。 -大型项目可以把保存的定义递归组织在 `.pi/taskflows/flows/` 下,例如 `.pi/taskflows/flows/release/audit-api.json`。旧的 `.pi/taskflows/*.json` 仍可发现,并在同一作用域的重名冲突中优先;嵌套定义按与区域设置无关的 Unicode 标量路径顺序确定优先级。重新保存一个已发现的嵌套 flow 会原地更新该定义及相邻元数据;新 flow 仍写入旧版顶层位置。发现过程由用户与项目共享一套预算,超过 1,000 个 flow、10,000 个已访问目录项、512 个目录、8 MiB 定义总量、单文件 1 MiB 或 16 层深度时会安全失败。它拒绝可信存储边界以下直到定义叶子的符号链接,并跳过点路径、元数据(`*.meta.json`)和已编译 IR(`*.flowir.json`);为兼容 home 目录迁移,配置的 agent 目录边界本身可以是符号链接。保存新 flow 时会执行相同的存储边界校验,并在写锁内重新验证目标目录的物理身份。 +## 声明一个 effect -文件支持的 flow 可以显式选择从定义文件目录运行脚本阶段: +Effect 是 flow 合同的一部分,不是 prompt 里的自由文本承诺: ```json { - "name": "release", - "scriptCwd": "flow", + "name": "trusted-effects-write", "phases": [ - { "id": "prepare", "type": "script", "run": ["./scripts/prepare.sh"], "final": true } + { + "id": "write-report", + "type": "script", + "run": ["node", "scripts/render-report.mjs"], + "effects": [ + { + "id": "report", + "kind": "fs.write", + "purpose": "write final report", + "target": { + "kind": "path", + "path": { + "workspace": "project", + "subpath": { "literalPath": "out/report.md" }, + "intent": "create-file" + } + }, + "confidentiality": "internal", + "integrity": "project" + } + ], + "final": true + } ] } ``` -此时 `./scripts/prepare.sh` 从保存 flow 或 `defineFile` 所在目录解析。默认值仍是 `"invocation"`,显式 phase `cwd` 仍然优先;inline 定义没有可信文件来源,因此请求 `scriptCwd: "flow"` 时会安全失败。如果执行继承了 cwd bridge 边界,解析出的 flow 来源目录也必须位于该边界内。 +声明本身并不等于授权。运行时会解析 `PathRef`、检查标签与路径重叠、记录 resource intent,然后才允许 transaction 对已声明目标执行 stage 与最终提交。`taskflow_why_effect` 可以在不调用模型的情况下解释授权结果与 ledger 状态。 -[查看完整快速开始 →](https://heggria.github.io/taskflow/zh-cn/docs/getting-started) +## 运行时合同 -## 看见整张图运行 - -下面是真实的 Pi 运行输出,不是模拟的 dashboard: +0.2 运行时仍是基础层。Flow 可以用可移植 JSON 编写,也可以从 TypeScript DSL 编译到 FlowIR: ```text -⊗ taskflow self-improve 6/7 · blocked · $0.095 - ✓ discover agent deepseek-v4-flash 10t ↑38k ↓6.7k $0.011 - ┌ ✓ write-runner-tests agent claude-sonnet-4-6 10t ↑13 ↓6.6k $0.020 - ├ ✓ write-store-tests agent claude-sonnet-4-6 10t ↑11 ↓10k $0.018 - ├ ✓ write-agents-tests agent claude-sonnet-4-6 10t ↑28 ↓13k $0.030 - └ ✓ fix-stability agent claude-sonnet-4-6 10t ↑13 ↓3.9k $0.012 - ✓ verify gate BLOCK 3 type errors in test files - ⊘ report reduce skipped · Gate blocked ↳ fix-stability -``` - -布局**本身就是 DAG**。并行轨道暴露并发,长边暴露依赖,gate 解释下游为什么停止。你不需要另一套控制平面才能看懂运行状态。 - -## 0.2.10:可组织、可携带的 saved flow - -saved flow 现在可以按受限约定放在 `.pi/taskflows/flows/**` 下分目录管理,同时旧顶层 flow 的优先级和行为保持不变。文件来源可信的 flow 可显式设置 `scriptCwd: "flow"`,让相邻的脚本、模板和 fixtures 作为一个目录整体复制、审阅和版本控制。 - -发现、来源和持久化继续 fail closed:递归扫描共享文件数、entry、目录数、字节和深度预算;排除边界下的 symlink;来源身份贯穿前台、后台、resume 与 subflow;嵌套 definition/sidecar 在 atomic promotion 各阶段重验物理父目录。[完整 0.2.10 说明 →](./CHANGELOG.md#0210--2026-08-12) - -## 0.2.9:Hermes Agent + verify 对齐 - -Taskflow 现在通过 `hermes-taskflow` 支持第六个宿主 **Hermes Agent**。Hermes 子代理使用临时 home、显式工具集、cwd 内只读路径边界、仅 provider 凭据,以及对 mutating `--yolo` phase 的明确 opt-in。 - -Pi 已公开的 `/tf verify ` 现在与 tool 接口一致,也能正确处理含空格的 flow 名。项目发现同时在规范化后的 home/temp 边界停止,不再把环境中的 `/tmp/.pi` 误认成项目状态。[完整 0.2.9 说明 →](./CHANGELOG.md#029--2026-08-11) - -## 0.2.8:先审阅,再确认 - -Pi 审批现在把**选择**和**提交**分开:用 `R` / `E` / `A`、方向键或 Tab 选择拒绝、编辑意见或批准,再按 Enter 确认。默认停在拒绝;Escape 和 Ctrl-C 仍会立即拒绝。 - -长提案默认折叠,按 `V` 可在原位展开并滚动审阅,决策栏始终可见;短提案默认展开。完整说明:[CHANGELOG 0.2.8](./CHANGELOG.md#028--2026-08-10)。 - -## 0.2.7:花 token 前先计划 · 跑完闭环 - -0.2 线把图做成了**可编译、可检查**的合同。**0.2.7** 补上日常闭环:跑之前看清计划,跑之后有人(或文件/webhook)知道结果——且从不把 transcript 塞回宿主。 - -| 花 token 之前 | 花 token 之后 | -|---|---| -| **`taskflow_plan` / `/tf plan`** — 绑定 typed args、投影 phase 序、标出动态引用、给出 worst-case agent 调用上界 | **`hooks.onComplete` / `onFail` / `onBlocked`** — webhook / 文件 / 纯 argv 命令;仅摘要 payload(`taskflow.hook.v1`) | -| **`verify` / `lint`** 仍是 0 花费 | **`approval.timeoutMs` + `onExpire`** — HITL 不再无限挂起 | -| **`recompute` 省钱一行** — `reused N · rerun M · cutoff K · saved ~P%` | **`taskflow_analytics`** — 最近 N 次状态/耗时/失败与缓存命中率(只读) | - -```bash -# 零 token:看清会跑谁、参数绑没绑上、最坏会打多少 agent 调用 -# MCP: taskflow_plan · Pi: /tf plan my-flow '{"dir":"src"}' -``` - -```jsonc -// 可选:background 跑完后 fire-and-forget 通知 -{ - "hooks": { - "onComplete": [{ "type": "file", "path": ".taskflow/hooks/last-complete.json" }] - } -} -``` - -MCP 宿主现为 **19 个工具**(新增 `taskflow_plan`、`taskflow_analytics`)。入门模板见 [`examples/templates/`](./examples/templates/)。完整说明:[CHANGELOG 0.2.7](./CHANGELOG.md#027--2026-08-06)。 - -## 0.2 是编译器转身 - -0.2 之前,taskflow 负责执行声明式图。现在,这张图还拥有编译期前端、规范化中间表示、append-only 决策 trace、离线重放,以及增量重算。 - -### 用 JSON 或 TypeScript 编写 - -JSON 仍是可移植的运行时合同。面对更大的 flow,`taskflow-dsl` 提供编译期 TypeScript 编写层: - -```ts -import { agent, flow, json, map, reduce } from "taskflow-dsl"; - -export default flow("audit", (ctx) => { - ctx.budget({ maxUSD: 2 }); - - const files = agent("List files under {args.dir}", { - agent: "scout", - output: json<{ path: string }[]>(), - }); - - const audits = map(files, (file) => - agent(`Audit ${file.path}`, { agent: "security-reviewer" }), - ); - - return reduce( - [audits], - (parts) => agent(`Write one report:\n${parts.audits.output}`), - { final: true }, - ); -}); -``` - -```bash -pnpm add -D taskflow-dsl -taskflow-dsl check audit.tf.ts -taskflow-dsl build audit.tf.ts --emit both -# → audit.taskflow.json + audit.flowir.json +JSON / .tf.ts + │ + ▼ +validate → Taskflow JSON → FlowIR + content hash + │ + ▼ + 隔离 DAG 运行时 + │ + resume · replay · recompute · trace + │ + ▼ + finalOutput 回到宿主 ``` -`.tf.ts` **只存在于编译期**。宿主执行生成的 Taskflow JSON,绝不会解释执行 TypeScript。 - -### 编译成一份可以推理的合同 - -FlowIR 规范化整张图,并赋予它内容哈希。这个编译身份让 provenance 与过期分析变得可检查,而运行时在其上提供内容寻址缓存与确定性工具: - -| 操作 | 它回答什么 | 模型调用 | -|---|---|---:| -| **`plan`** | 会跑谁、参数是否绑定、worst-case agent 调用上界? | **0** | -| `verify` / `compile` / `lint` | 结构是否安全 / lint 是否干净? | **0** | -| `ir` | 规范化图和内容哈希是什么? | **0** | -| `resume` | 还有哪些未完成工作?(派生新运行,原运行不变) | 仅未完成阶段 | -| `trace` | 实际发生了哪些调用和运行时决策? | 查看时 **0** | -| `replay` | 如果阈值或预算不同,结果会怎样? | **0** | -| `why-stale` | 什么变了,哪些节点依赖它? | **0** | -| `recompute` | 最小可观测受影响前沿是什么?(含省钱一行) | 仅受影响阶段 | -| `analytics` | 这个 flow 最近 N 次跑得怎么样? | **0** | - -[探索编译器与运行时 →](https://heggria.github.io/taskflow/zh-cn/docs/compiler-runtime/) - ## 一套运行时,12 种阶段 | 家族 | 阶段 | 用途 | |---|---|---| -| **工作** | `agent` · `parallel` · `map` · `reduce` · `script` | 单任务、静态并发、动态 fan-out、聚合、零 token shell 步骤 | -| **控制** | `gate` · `approval` · `flow` · `loop` | 质量决策、人工检查点、组合、迭代改进 | +| **工作** | `agent` · `parallel` · `map` · `reduce` · `script` | 单任务、静态并发、动态 fan-out、聚合与零 token shell 步骤 | +| **控制** | `gate` · `approval` · `flow` · `loop` | 质量决策、人工检查点、组合与迭代改进 | | **选择** | `tournament` · `race` | best-of-N 质量或 first-success 延迟 | -| **动态图** | `expand` | 校验并执行运行时产出的片段,可嵌套或提升 | - -在这些阶段类型之上,DSL 提供依赖、条件、重试、超时、输出合同、预算、工作区隔离和明确的最终输出选择。每种类型只接受对它安全且有意义的字段;对新鲜度敏感的阶段不会进入跨运行缓存。 - -[阅读阶段参考 →](https://heggria.github.io/taskflow/zh-cn/docs/syntax/phase-types) - -## 运行时保证,而不是 prompt 约定 - -### 花费前先验证 - -环路、悬空依赖、无效引用、不可能的 join、不安全的动态片段与配置风险,会在昂贵工作开始前被拒绝或明确暴露。 - -### 中间工作不进入宿主上下文 - -负责 agent 工作的阶段运行在隔离的 subagent 进程中;控制阶段和 script 阶段留在运行时内部。上游输出由运行时在内部接入下游输入。除非明确使用 `peek` 或 `trace`,否则只有 `finalOutput` 返回宿主。 - -### 穿越会话与失败 - -阶段状态以原子方式持久化。续跑会跳过未变化的已完成工作;Pi 的 detached 运行可以活过发起会话;idle watchdog 会终止卡死的 subagent。 - -### 诚实地复用工作 - -运行内续跑基于内容寻址。跨运行缓存需显式开启,并可把 Git commit、文件、glob、环境变量和 TTL 纳入指纹。改变一个已声明输入,只有其依赖项会变为陈旧。 - -### 限制爆炸半径 - -预算、并发上限、重试、超时、嵌套深度、动态图宽度、路径包含检查、非幂等阶段分类,以及审批 fail-closed 都是运行时语义,不是写在 prompt 里的建议。 - -### 0.2.1:安全动态 cwd 与 Pi 终态回收 - -声明为 `type: "relative-path"` 的调用参数,可以通过严格完整的 -`cwd: "{args.package}"` 选择 phase 工作目录。该桥默认关闭,需要 Host 显式 -授权 `resolve-only`,并把 canonical 目录限制在 invocation root 内。绝对路径、 -字符串拼接和 `{steps.*}` 仍会被拒绝;这个兼容桥不是 OS sandbox。 -同一次 invocation 内的 resolve-only 写阶段会在获取持久 lease 前串行化,避免 -fan-out 自己等待自己超时,同时仍以跨进程 lease 保护其他 Taskflow 进程。 - -Pi 子 agent 默认不再继承 ambient extensions。可信 Host 可以配置明确的扩展 -白名单,或显式恢复旧版继承行为。如果 Pi 子进程已经产出经过验证的最终答案和 -终态事件,却因扩展遗留 handle 而不退出,Taskflow 会等待有限 grace 窗口、回收 -整个进程组,并记录 `completionSource: "terminal-reap"`,而不是误报 timeout。 - -```json -{ - "taskflow": { - "piChild": { - "resourceProfile": "isolated", - "extensions": [], - "terminalGraceMs": 1500 - } - } -} -``` - -`allowlist` 接受显式可信扩展文件;`inherit` 仅作为兼容模式恢复 Pi ambient -extension discovery。Flow 无权扩大这项 Host 权限。 - -[阅读核心概念 →](https://heggria.github.io/taskflow/zh-cn/docs/concepts/) - -## 安装到你的宿主 - -所有包都要求 **Node.js ≥ 22.19.0**。 - -### Pi - -```bash -pi install npm:pi-taskflow -``` - -Pi 提供最完整的本地体验:`taskflow` 工具、`/tf` 命令、实时 DAG 渲染、交互审批、后台运行与模型角色配置。 - -[Pi 指南 →](https://heggria.github.io/taskflow/zh-cn/docs/guides/pi) - -### OpenAI Codex +| **动态图** | `expand` | 校验并执行运行时产出的嵌套或提升片段 | -```bash -codex plugin marketplace add heggria/taskflow -codex plugin add taskflow@taskflow -``` - -[Codex 指南 →](https://heggria.github.io/taskflow/zh-cn/docs/guides/codex) - -### Claude Code - -```bash -claude plugin marketplace add heggria/taskflow -claude plugin install claude-taskflow@taskflow -``` - -[Claude Code 指南 →](https://heggria.github.io/taskflow/zh-cn/docs/guides/claude-code) - -### OpenCode - -```bash -opencode mcp add taskflow -- \ - npx -y -p opencode-taskflow@0.2.10 opencode-taskflow-mcp -``` - -[OpenCode 指南 →](https://heggria.github.io/taskflow/zh-cn/docs/guides/opencode) - -### Grok Build - -```bash -grok mcp add taskflow -- \ - npx -y -p grok-taskflow@0.2.10 grok-taskflow-mcp -``` - -Grok Build 支持在 0.2 首次加入。其 CLI stream 不返回 token/cost 用量,因此声明了预算的 flow 会被拒绝,而不是在无法执行预算约束时静默运行。 - -[Grok Build 指南 →](https://heggria.github.io/taskflow/zh-cn/docs/guides/grok-build) +在这些阶段类型之上,运行时提供依赖、条件、重试、超时、输出合同、预算、工作区隔离、明确的最终输出选择,以及支持 resume 的持久化。每种阶段只接受对它安全且有意义的字段。 -### Hermes Agent +常用的零 token 操作: -```bash -hermes mcp add taskflow --command npx --args -y -p hermes-taskflow@0.2.10 hermes-taskflow-mcp -# 优先在 config.yaml 写 env(不要用 CLI --env 塞进 node argv): -# mcp_servers.taskflow.env.PI_TASKFLOW_HERMES_UNSAFE_YOLO: "1" # 仅 mutating -``` - -Hermes quiet 模式不返回 token/cost,声明了预算的 flow 会被拒绝。子代理使用临时 HERMES_HOME,只继承非 secret 的 model/fallback 路由、仅含已路由 inference provider 的 `auth.json` 与 provider allowlist dotenv;不继承父级 MCP、skills、memory、sessions 或 rules。只读 phase 默认无工具(零网络);READONLY_WEB=1 → web,search——Hermes 只读本地读用插件 toolset `taskflow_readonly_files`。 - -[Hermes 指南 →](./docs/hermes-mcp.md) - -## 为真实工作而生 - -
- -**10 个包** · **6 个宿主** · **12 种阶段** · **18 个内置 agent** · **1,500+ 测试** · **MIT** +| 操作 | 它回答什么 | +|---|---| +| `taskflow_plan` | 会运行什么、参数如何绑定、最坏会调用多少 agent? | +| `taskflow_verify` / `taskflow_compile` | 图在结构上是否有效,规范化形式是什么? | +| `taskflow_trace` / `taskflow_replay` | 实际发生了什么,或零 token 的 what-if replay 会怎样? | +| `taskflow_why_stale` / `taskflow_recompute` | 什么变了,最小受影响前沿是什么? | +| `taskflow_why_effect` | 为什么一个声明的 effect 被允许、stage、commit、reject,或仍是 unknown? | +| `taskflow_analytics` | 最近的运行表现如何? | -
+当前 MCP 表面暴露 **20 个工具**。除非明确使用 `peek` 或 `trace`,中间 transcript 会留在运行时,宿主通常只收到 `finalOutput`。 -```text - taskflow-core - ┌──────────────┼───────────────┐ - │ │ │ - taskflow-dsl pi-taskflow taskflow-mcp-core ─┐ - taskflow-hosts ─────┼─ codex-taskflow - ├─ claude-taskflow - ├─ opencode-taskflow - ├─ grok-taskflow - └─ hermes-taskflow -``` +## 宿主适配器 -`taskflow-core` 保持宿主无关,不导入任何宿主 SDK。`taskflow-mcp-core` 在不依赖 MCP SDK 的情况下实现 stdio JSON-RPC;`taskflow-hosts` 负责共享宿主进程 runner。五个 MCP 交付包绑定这两层(以及 core),而 Pi 保留原生适配器。 +同一份 flow 合同可以通过六个 coding-agent 宿主交付: -测试套件覆盖编排语义、持久化与文件锁竞态、缓存新鲜度、路径穿越、动态图加固、取消、预算、全部 12 种阶段、FlowIR/replay/recompute、TypeScript DSL 擦除、宿主 argv 合同、MCP server,以及打包后的 consumer imports。 +- **Pi**:原生扩展、`/tf` 命令、实时运行视图与交互式审批。 +- **Codex**:plugin 与 stdio MCP server。 +- **Claude Code**:plugin 与 stdio MCP server。 +- **OpenCode**:MCP 配置与生成的 skill。 +- **Grok Build**:MCP 配置与生成的 skill。 +- **Hermes Agent**:带显式子代理 toolset 与隔离策略的 MCP 交付。 -## 文档 +宿主支持不是一揽子安全保证。启用 mutating phase 前,请阅读[宿主支持基线](./conformance/workspace/host-support-baseline.json)与 [Trusted Effects 定义](./docs/internal/0.3.0-trusted-effects-mvp.md)。 -| 从这里开始 | 当你需要 | -|---|---| -| [快速开始](https://heggria.github.io/taskflow/zh-cn/docs/getting-started) | 第一次成功运行 | -| [核心概念](https://heggria.github.io/taskflow/zh-cn/docs/concepts/) | DAG、隔离、验证、续跑、共享上下文 | -| [语法](https://heggria.github.io/taskflow/zh-cn/docs/syntax/) | 阶段字段、控制流、预算、缓存、scorer | -| [编译器与运行时](https://heggria.github.io/taskflow/zh-cn/docs/compiler-runtime/) | TypeScript DSL、FlowIR、重放、重算、后台运行 | -| [宿主指南](https://heggria.github.io/taskflow/zh-cn/docs/guides/) | Pi、Codex、Claude Code、OpenCode、Grok、Hermes 配置 | -| [参考](https://heggria.github.io/taskflow/zh-cn/docs/reference/) | 命令、简写与精确工具接口 | -| [Showcase](https://heggria.github.io/taskflow/zh-cn/docs/showcase/) | 真实 flow 与案例研究 | -| [0.2.0 前沿性评估](./docs/taskflow-0.2.0-frontier-assessment.zh-CN.md) | 基于源码、竞品与采用数据的独立技术报告 | +## 我们明确声明的安全边界 -另见 [`examples/`](./examples)、[变更日志](./CHANGELOG.md)和[发版指南](./RELEASE.md)。 +- `effects[]` 是声明与校验表面,不是 ambient authority。 +- resources 层是已准入、已声明文件 effect 的唯一最终提交者。 +- MVP 路径会检测并恢复对已声明目标的直接写入。 +- 在 resolve-only 执行下,未声明路径的写入仍取决于宿主策略。 +- `SecretRef` 与 `ServiceRef` 只是类型化句柄;这一版没有 vault 或 live service adapter。 +- 0.3 MVP 不声称提供 FileBroker 或完整 OS sandbox。 +- Control Plane store、审批、receipt 与 WebUI 属于未来的 0.3-C 阶段,不证明 0.3 已发布或 GA。 -## 贡献 +## 开发 ```bash pnpm install pnpm run typecheck pnpm test pnpm run build +pnpm run build:website pnpm run test:pack ``` -欢迎贡献。请先阅读 [`CONTRIBUTING.md`](./CONTRIBUTING.md) 了解工作流,以及 [`AGENTS.md`](./AGENTS.md) 了解架构与编码规范。 +这个 monorepo 包含 host-neutral 的 `taskflow-core`、Trusted Effects 与 resources 代码、0.3-C 合同 package `taskflow-control`、TypeScript DSL、MCP/宿主适配器、示例与网站。架构和编码约定见 [`AGENTS.md`](./AGENTS.md)。 + +## 文档 + +| 从这里开始 | 适用场景 | +|---|---| +| [0.3 总览](https://heggria.github.io/taskflow/zh-cn/docs) | candidate 范围、状态与诚实的安全边界 | +| [快速开始](https://heggria.github.io/taskflow/zh-cn/docs/getting-started) | 第一个 flow 与宿主配置 | +| [核心概念](https://heggria.github.io/taskflow/zh-cn/docs/concepts/) | DAG、隔离、验证、续跑与 evidence | +| [编译器与运行时](https://heggria.github.io/taskflow/zh-cn/docs/compiler-runtime/) | JSON、TypeScript DSL、FlowIR、replay 与 recompute | +| [宿主指南](https://heggria.github.io/taskflow/zh-cn/docs/guides/) | Pi、Codex、Claude Code、OpenCode、Grok 与 Hermes | +| [示例](./examples) | 可运行的 flow 定义,包括 Trusted Effects | +| [变更记录](./CHANGELOG.md) | 发布历史与 candidate 说明 | -## 许可 +## 许可证 [MIT](./LICENSE) © [heggria](https://github.com/heggria)
-**只声明一次。花费前验证。只重算变化部分。** +**声明 effect。验证路径。让一个 authority 负责提交。** -[阅读文档](https://heggria.github.io/taskflow/zh-cn/docs) · [运行示例](./examples) · [查看版本](https://github.com/heggria/taskflow/releases) +[阅读文档](https://heggria.github.io/taskflow/zh-cn/docs) · [试用 candidate](#快速开始) · [查看 releases](https://github.com/heggria/taskflow/releases)
diff --git a/RELEASE.md b/RELEASE.md index adc8f8d6..f7e2f60c 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -19,12 +19,7 @@ Dependency order: `taskflow-mcp-core`, `taskflow-hosts`, `taskflow-dsl`, `pi-tas ## One-time repository setup -The canonical release path is the tag-triggered GitHub Actions workflow. A -repository administrator must configure `NPM_TOKEN` for an npm account allowed -to publish all ten package names. The workflow itself uses least-privilege -`contents: read` plus `id-token: write` and publishes with npm provenance. Do -not publish a release from a developer workstation: a manual publish cannot -provide the workflow identity and source/tag guarantees enforced on reruns. +The beta release path is `v0.3.0-beta.1`: merge the reviewed release commit to `main`, then push the tag. `.github/workflows/publish.yml` validates the `0.3.0-beta.*` prerelease family, publishes the same ten packages to npm's `beta` dist-tag with provenance, and creates a prerelease GitHub Release. Do not publish from a workstation. ## Pre-flight (always) @@ -82,8 +77,8 @@ the matching annotated tag: ```sh git switch main git pull --ff-only origin main -git tag -a v0.2.10 -m "Release v0.2.10" -git push origin v0.2.10 +git tag -a v0.3.0-beta.1 -m "Release v0.3.0-beta.1" +git push origin v0.3.0-beta.1 ``` `.github/workflows/publish.yml` then performs the complete release transaction: @@ -119,7 +114,7 @@ pnpm view hermes-taskflow version --registry=https://registry.npmjs.org/ ``` Also verify the `Publish & Release` workflow completed successfully and that -the non-draft, non-prerelease GitHub Release targets the tagged commit. A +the non-draft prerelease GitHub Release targets the tagged commit. A partially published ten-package set is not a completed release; fix the cause and rerun the same tag workflow rather than creating a replacement tag or publishing missing packages manually. @@ -139,24 +134,27 @@ publishing missing packages manually. ## Install (end users) +The 0.3 beta is prepared for npm's `beta` channel; after the tag workflow publishes it, use `@beta` explicitly. The stable examples below remain pinned to `0.2.10`. + ```sh -# Pi users (unchanged): -pi install npm:pi-taskflow +# Pi users: +pi install npm:pi-taskflow@beta -# Codex users (plugin): +# Codex users (plugin source; npm MCP package uses @beta): codex plugin marketplace add heggria/taskflow codex plugin add taskflow@taskflow -# Claude Code users (plugin): +# Claude Code users (plugin source; npm MCP package uses @beta): claude plugin marketplace add heggria/taskflow claude plugin install claude-taskflow@taskflow -# OpenCode users (MCP server): -opencode mcp add taskflow -- npx -y -p opencode-taskflow opencode-taskflow-mcp +# OpenCode users (beta MCP server): +opencode mcp add taskflow -- npx -y -p opencode-taskflow@beta opencode-taskflow-mcp -# Grok Build (published MCP package) -grok mcp add taskflow -- npx -y -p grok-taskflow@0.2.10 grok-taskflow-mcp -# or: grok mcp add taskflow -- npx -y -p grok-taskflow grok-taskflow-mcp +# Grok Build (beta MCP package) +grok mcp add taskflow -- npx -y -p grok-taskflow@beta grok-taskflow-mcp -hermes mcp add taskflow --command npx --args -y -p hermes-taskflow@0.2.10 hermes-taskflow-mcp +# Hermes Agent (beta MCP package) +# 0.3 beta candidate (after publication, select @beta) +hermes mcp add taskflow --command npx --args -y -p hermes-taskflow@beta hermes-taskflow-mcp ``` diff --git a/SECURITY.md b/SECURITY.md index 12f8d154..7b9f48f2 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -27,7 +27,8 @@ The runtime has intentional hardening: `realpath`-based path containment, runId | Version | Support | |---------|---------| -| Latest npm release (`v0.2.10` for this release) | ✅ Active | +| Latest stable npm release (`v0.2.10`) | ✅ Active | +| `0.3.0-beta.1` beta channel | ⚠️ Pre-release; test-only support | | Earlier versions | ❌ Unsupported — upgrade to the latest npm release | ## Disclosure diff --git a/conformance/workspace/host-support-baseline.json b/conformance/workspace/host-support-baseline.json index 2df1eba6..904d2643 100644 --- a/conformance/workspace/host-support-baseline.json +++ b/conformance/workspace/host-support-baseline.json @@ -1,5 +1,79 @@ { "schemaVersion": 1, - "baselineId": "taskflow-workspace-proposed-v1", - "cells": [] + "baselineId": "taskflow-workspace-trusted-effects-v0.3.0-mvp", + "notes": "Honest cells for 0.3 Trusted Effects MVP. resolve-only is not a FileBroker sandbox. Empty claims removed.", + "cells": [ + { + "host": "pi", + "resourceDomain": "filesystem", + "capability": "pathref-resolve", + "status": "supported", + "guarantee": "resolve-only", + "evidence": "packages/taskflow-core/src/resources/execution.ts" + }, + { + "host": "codex", + "resourceDomain": "filesystem", + "capability": "pathref-resolve", + "status": "supported", + "guarantee": "resolve-only", + "evidence": "packages/taskflow-core/src/resources/execution.ts" + }, + { + "host": "claude", + "resourceDomain": "filesystem", + "capability": "pathref-resolve", + "status": "supported", + "guarantee": "resolve-only", + "evidence": "packages/taskflow-core/src/resources/execution.ts" + }, + { + "host": "opencode", + "resourceDomain": "filesystem", + "capability": "pathref-resolve", + "status": "supported", + "guarantee": "resolve-only", + "evidence": "packages/taskflow-core/src/resources/execution.ts" + }, + { + "host": "grok", + "resourceDomain": "filesystem", + "capability": "pathref-resolve", + "status": "supported", + "guarantee": "resolve-only", + "evidence": "packages/taskflow-core/src/resources/execution.ts" + }, + { + "host": "*", + "resourceDomain": "filesystem", + "capability": "file-broker", + "status": "unsupported", + "guarantee": "none", + "evidence": "No host has passed FileBroker adversarial baseline; do not claim sandbox." + }, + { + "host": "node-local", + "resourceDomain": "filesystem", + "capability": "trusted-effects-resource-transaction", + "status": "supported", + "guarantee": "pathref-lease-journal-permit-commit-or-restore", + "evidence": "packages/taskflow-core/src/resources/file-transaction.ts + test/resource-file-transaction.test.ts" + }, + { + "host": "*", + "resourceDomain": "secret", + "capability": "secretref-backend", + "status": "unsupported", + "guarantee": "type-only-fail-closed", + "evidence": "SecretRef validated; no vault adapter in 0.3 MVP" + }, + { + "host": "*", + "resourceDomain": "service", + "capability": "serviceref-adapter", + "status": "unsupported", + "guarantee": "type-only-fail-closed", + "evidence": "ServiceRef validated; no live adapter in 0.3 MVP" + } + ] } diff --git a/docs/claude-mcp.md b/docs/claude-mcp.md index 9d807e6a..0ed0aa64 100644 --- a/docs/claude-mcp.md +++ b/docs/claude-mcp.md @@ -35,7 +35,7 @@ Verify: ```sh claude plugin list # → claude-taskflow@taskflow installed, enabled -claude mcp list # → taskflow … (npx -y -p claude-taskflow@0.2.10 claude-taskflow-mcp) +claude mcp list # → taskflow … (npx -y -p claude-taskflow@beta claude-taskflow-mcp) ``` The bundled skill tells Claude Code *when* to reach for the tools (multi-phase diff --git a/docs/codex-mcp.md b/docs/codex-mcp.md index a6ad894c..72833e69 100644 --- a/docs/codex-mcp.md +++ b/docs/codex-mcp.md @@ -31,7 +31,7 @@ globally, and the plugin version binds the exact code that runs. Verify: ```sh codex plugin list # → taskflow@taskflow installed, enabled -codex mcp list # → taskflow … enabled (npx -y -p codex-taskflow@0.2.10 codex-taskflow-mcp) +codex mcp list # → taskflow … enabled (npx -y -p codex-taskflow@beta codex-taskflow-mcp) ``` The bundled skill tells Codex *when* to reach for the tools (multi-phase or @@ -70,7 +70,7 @@ To stop large flows from being cut off, the plugin's `.mcp.json` ships a "mcpServers": { "taskflow": { "command": "npx", - "args": ["-y", "-p", "codex-taskflow@0.2.10", "codex-taskflow-mcp"], + "args": ["-y", "-p", "codex-taskflow@beta", "codex-taskflow-mcp"], "tool_timeout_sec": 1800 } } diff --git a/docs/grok-mcp.md b/docs/grok-mcp.md index 019287b5..5b4be4f3 100644 --- a/docs/grok-mcp.md +++ b/docs/grok-mcp.md @@ -27,7 +27,7 @@ Official Grok docs used for this integration: ## Install (recommended): register the published MCP server ```sh -grok mcp add taskflow -- npx -y -p grok-taskflow@0.2.10 grok-taskflow-mcp +grok mcp add taskflow -- npx -y -p grok-taskflow@beta grok-taskflow-mcp ``` A public Grok plugin marketplace/source is not published yet. Do not substitute @@ -179,7 +179,7 @@ grok mcp add taskflow -- grok-taskflow-mcp Or with npx (no global install): ```sh -grok mcp add taskflow -- npx -y -p grok-taskflow@0.2.10 grok-taskflow-mcp +grok mcp add taskflow -- npx -y -p grok-taskflow@beta grok-taskflow-mcp ``` Verify: diff --git a/docs/hermes-mcp.md b/docs/hermes-mcp.md index 5dda53e0..0d88baa1 100644 --- a/docs/hermes-mcp.md +++ b/docs/hermes-mcp.md @@ -15,7 +15,7 @@ Or paste into `~/.hermes/config.yaml` (see `packages/hermes-taskflow/plugin/herm mcp_servers: taskflow: command: "npx" - args: ["-y", "-p", "hermes-taskflow@0.2.10", "hermes-taskflow-mcp"] + args: ["-y", "-p", "hermes-taskflow@beta", "hermes-taskflow-mcp"] env: # PI_TASKFLOW_HERMES_UNSAFE_YOLO: "1" # required for mutating agent phases timeout: 600 diff --git a/docs/i18n/README.ar.md b/docs/i18n/README.ar.md index 6f28c132..21af71ad 100644 --- a/docs/i18n/README.ar.md +++ b/docs/i18n/README.ar.md @@ -22,7 +22,7 @@ claude plugin install claude-taskflow@taskflow opencode mcp add taskflow -- npx -y -p opencode-taskflow opencode-taskflow-mcp # Grok Build (from monorepo checkout pre-publish, or published source) -grok mcp add taskflow -- npx -y -p grok-taskflow@0.2.10 grok-taskflow-mcp +grok mcp add taskflow -- npx -y -p grok-taskflow@beta grok-taskflow-mcp # or: grok mcp add taskflow -- npx -y -p grok-taskflow grok-taskflow-mcp ``` diff --git a/docs/i18n/README.bn.md b/docs/i18n/README.bn.md index eefaa438..429c4699 100644 --- a/docs/i18n/README.bn.md +++ b/docs/i18n/README.bn.md @@ -22,7 +22,7 @@ claude plugin install claude-taskflow@taskflow opencode mcp add taskflow -- npx -y -p opencode-taskflow opencode-taskflow-mcp # Grok Build (from monorepo checkout pre-publish, or published source) -grok mcp add taskflow -- npx -y -p grok-taskflow@0.2.10 grok-taskflow-mcp +grok mcp add taskflow -- npx -y -p grok-taskflow@beta grok-taskflow-mcp # or: grok mcp add taskflow -- npx -y -p grok-taskflow grok-taskflow-mcp ``` diff --git a/docs/i18n/README.es.md b/docs/i18n/README.es.md index 1775d299..57e33581 100644 --- a/docs/i18n/README.es.md +++ b/docs/i18n/README.es.md @@ -22,7 +22,7 @@ claude plugin install claude-taskflow@taskflow opencode mcp add taskflow -- npx -y -p opencode-taskflow opencode-taskflow-mcp # Grok Build (from monorepo checkout pre-publish, or published source) -grok mcp add taskflow -- npx -y -p grok-taskflow@0.2.10 grok-taskflow-mcp +grok mcp add taskflow -- npx -y -p grok-taskflow@beta grok-taskflow-mcp # or: grok mcp add taskflow -- npx -y -p grok-taskflow grok-taskflow-mcp ``` diff --git a/docs/i18n/README.hi.md b/docs/i18n/README.hi.md index 0411c9c2..217dec14 100644 --- a/docs/i18n/README.hi.md +++ b/docs/i18n/README.hi.md @@ -22,7 +22,7 @@ claude plugin install claude-taskflow@taskflow opencode mcp add taskflow -- npx -y -p opencode-taskflow opencode-taskflow-mcp # Grok Build (from monorepo checkout pre-publish, or published source) -grok mcp add taskflow -- npx -y -p grok-taskflow@0.2.10 grok-taskflow-mcp +grok mcp add taskflow -- npx -y -p grok-taskflow@beta grok-taskflow-mcp # or: grok mcp add taskflow -- npx -y -p grok-taskflow grok-taskflow-mcp ``` diff --git a/docs/i18n/README.pt.md b/docs/i18n/README.pt.md index e264c280..fd0a1549 100644 --- a/docs/i18n/README.pt.md +++ b/docs/i18n/README.pt.md @@ -22,7 +22,7 @@ claude plugin install claude-taskflow@taskflow opencode mcp add taskflow -- npx -y -p opencode-taskflow opencode-taskflow-mcp # Grok Build (from monorepo checkout pre-publish, or published source) -grok mcp add taskflow -- npx -y -p grok-taskflow@0.2.10 grok-taskflow-mcp +grok mcp add taskflow -- npx -y -p grok-taskflow@beta grok-taskflow-mcp # or: grok mcp add taskflow -- npx -y -p grok-taskflow grok-taskflow-mcp ``` diff --git a/docs/i18n/README.ru.md b/docs/i18n/README.ru.md index dc4999ed..4f89c339 100644 --- a/docs/i18n/README.ru.md +++ b/docs/i18n/README.ru.md @@ -22,7 +22,7 @@ claude plugin install claude-taskflow@taskflow opencode mcp add taskflow -- npx -y -p opencode-taskflow opencode-taskflow-mcp # Grok Build (from monorepo checkout pre-publish, or published source) -grok mcp add taskflow -- npx -y -p grok-taskflow@0.2.10 grok-taskflow-mcp +grok mcp add taskflow -- npx -y -p grok-taskflow@beta grok-taskflow-mcp # or: grok mcp add taskflow -- npx -y -p grok-taskflow grok-taskflow-mcp ``` diff --git a/docs/internal/0.3-c-control-plane-plan.md b/docs/internal/0.3-c-control-plane-plan.md new file mode 100644 index 00000000..cb68475b --- /dev/null +++ b/docs/internal/0.3-c-control-plane-plan.md @@ -0,0 +1,51 @@ +# 0.3-C: Trusted Effects + Control Plane + WebUI — 作战计划(入板版) + +> **Status:** APPROVED — 已入 Kanban(tenant `taskflow-0.3-converge`) +> **Date:** 2026-08-12 +> **Base:** `rc/0.3.0-trusted-effects` @ sync 0.2.10 + TE 闭环 +> **Design source:** Control Plane RFC v7.6(archive `backup/0.3-archive/mac-feat-0.3.0-control-plane`),**概念重写,不搬代码** + +## 战略 + +- 不 cherry-pick 旧 Control Plane 代码(628KB/29 文件基于 0.2.4,冲突爆炸) +- RFC v7.6 = 设计蓝本;TE = 执行地基(唯一 ExecutionProvider);控制面重新实现 +- §22 实现顺序 + P1–P16 ADR 门槛直接复用 + +## 阶段 + +| 阶段 | 内容 | 依赖 | 预估 | +|---|---|---|---| +| S0 Green trunk | ✅ rc sync 0.2.10 + TE 闭环 + 2302 测试绿 | — | done | +| S1 Wire freeze | P1–P16 ADR 落地(草稿在 archive)+ TypeBox wire 冻结 | S0 | 2–3d | +| S2 ControlHost | daemon/supervisor/standalone 三模式;TE 作为 ExecutionProvider | S1 | 3–4d | +| S3 Store 层 | Project ControlStore + Registry + UserCoordinatorStore(files-only, P14) | S2 | 3–4d | +| S4 运行控制 | Linker + BoundPlan + admission + 并发预留 + reconcile unknown | S3 | 3d | +| S5 审批协议 | P15 三模式 + park/expired + CAS | S4 | 2–3d | +| S6 表面层 | Thin MCP + CLI + 结构化状态 | S5 | 2d | +| S7 WebUI | Web Console(P17 重建)+ evidence 浏览 | S6 | 3–4d | +| S8 GA 验收 | §23 全清单 + 打包/发布链 | S7 | 2d | + +## 验收(§23 最小集) + +- 新鲜安装、默认 auto、单 run 无需手工 daemon 配置 +- 并发 client → 单写者/singleton attach;stale socket 恢复 +- per-project store;registry 多 mount;standalone+daemon 同一 store +- maxActiveRuns slots≡1;N+1 竞争;release 仅经 normalRelease/forceRelease +- reconcile 穷尽 → unknown + needs-operator + 无最终 Receipt +- Approval 三模式 + park/expire/CAS + 双客户端/重启 +- Receipt 幸存 compaction;无 DomainTransfer/联邦 +- WebUI 可看可操作(runs、审批、Receipt、why-*) +- TE 8 deliverables 不回退(2302+ 测试全绿) + +## 禁区 + +- ❌ DomainTransfer / 跨项目联邦 ControlStore(RFC out of scope) +- ❌ 不直接搬 archive 代码,概念重写 +- ❌ 不依赖 live Codex CLI 之外的 real-host +- ❌ WebUI 不做多用户/权限(单机 operator 控制台) +- ❌ 不声称 FileBroker/OS sandbox(保持 resolve-only + TE 声明写入) + +## Kanban + +- board=taskflow, tenant=taskflow-0.3-converge +- parent ← children(S1..S8 各为 child) diff --git a/docs/internal/0.3.0-agent-goal.md b/docs/internal/0.3.0-agent-goal.md new file mode 100644 index 00000000..3f5acccf --- /dev/null +++ b/docs/internal/0.3.0-agent-goal.md @@ -0,0 +1,119 @@ +# Agent Goal — Taskflow 0.3 Trusted Effects → Closed-Loop GA + +> **Owner:** coding agent (this session and successors) +> **Branch:** `rc/0.3.0-trusted-effects` (RC candidate; earlier working branches: `feat/0.3.0-trusted-effects` → `codex/0.3.0-trusted-effects-candidate` → `rc/0.3.0-trusted-effects`) +> **Base:** `main` @ v0.2.7 +> **Normative product freeze:** [`0.3.0-trusted-effects-mvp.md`](./0.3.0-trusted-effects-mvp.md) +> **Scoreboard:** [`0.3.0-ga-scoreboard.md`](./0.3.0-ga-scoreboard.md) +> **Convergence workflow:** `tf-03-ga-converge` (`~/.grok/workflows/tf-03-ga-converge.rhai`) + +--- + +## 1. Mission (one sentence) + +> **Drive Trusted Effects MVP from code-on-branch to honest closed-loop GA: every declared FS write effect is validated and finalized through the existing resources authority (PathRef, lease, journal, permit); eight deliverables are contract-tested; real host evidence is green; release candidate is cut — without claiming Control Plane / Adaptive / Retain as 0.3.** + +## 2. Success = L6 GA only when all below hold + +| ID | Requirement | Evidence | +|----|-------------|----------| +| G1 | Eight MVP deliverables each `pass` on scoreboard | scoreboard table | +| G2 | `effects-trusted` + related unit suite green | test command exit 0 | +| G3 | `pnpm run typecheck` (or package-filtered equivalent) green on branch | log | +| G4 | Real host/fixture e2e applies a declared write through the resources ledger | terminal host evidence | +| G5 | Runtime path: declared `fs.write` cannot bypass PathRef/lease/journal/permit authority; bypass restores exact pre-state | code + adversarial test | +| G6 | Host baseline honest (no FileBroker claim without proof) | conformance JSON | +| G7 | CHANGELOG Unreleased → versioned; root package version plan for 0.3.0 | CHANGELOG + package.json | +| G8 | Branch pushed; CI green on clean candidate; tag only after G1–G7 | remote | + +**Forbidden claims until G1–G8:** + +- “0.3 GA” / “shipped” / “ready for everyone” +- Equating historical `feat/0.3.0` Control Plane with this GA +- Auto-tune / Retain / Adaptive as 0.3 scope + +## 3. Operating loop (how I work) + +```text +forever until L6: + 1. AUDIT scoreboard + git + tests → list gaps (ordered) + 2. FAN-OUT parallel implementers on non-overlapping files + 3. VERIFY effects tests + typecheck subset + e2e fixture + 4. RECORD update scoreboard (PASS/PARTIAL/FAIL + evidence) + 5. DECIDE if L6 ready → stop and ask human for release authority + else → next wave (workflow re-run) +``` + +**Parallel policy** + +- Prefer **file ownership** per agent (effects / verify / runtime / docs / e2e). +- Max parallel implementers per wave: **5** (plus 1 auditor + 1 verifier). +- Do **not** merge Control Plane monolith into this branch as a blocker. +- Prefer small commits when human asks; default keep working tree until wave stable. + +**Workflows** + +| Name | Role | +|------|------| +| `tf-03-trusted-effects` | Initial wire / inventory (may still be running) | +| `tf-03-ga-converge` | **Primary loop:** audit → parallel fill gaps → verify → scoreboard | + +Re-run: `/workflow` launch `tf-03-ga-converge` with high `agent_budget` (up to 1024). +Same-process resume if paused for human release authority only. + +## 4. Wave plan (ordered, not optional) + +| Wave | Goal | Exit criteria | +|------|------|----------------| +| **W0** Freeze | Definition + branch | doc + branch exist ✅ | +| **W1** Kernel | types, closed validate, resources transaction, ledger why | unit vertical slice | +| **W2** Contract wire | schema, FlowIR, hash, effects verifier | verify + hash tests | +| **W3** Runtime force | every phase kind routes declared writes through resources | bypass/fast-path tests fail closed | +| **W4** Fixture e2e | no-LLM end-to-end under `test/` or `examples/` | L4 for MVP story | +| **W5** Package honesty | versions, CHANGELOG, skills note, baseline | release-ready docs | +| **W6** Candidate | full typecheck/test, push, CI | L5 evidence | +| **W7** GA | human tag + publish decision | L6 | + +Current wave target after W1–W4 closure: **W5 package honesty + W6 clean candidate / remote CI**. + +## 5. Acceptance Gate (live) + +Update [`0.3.0-ga-scoreboard.md`](./0.3.0-ga-scoreboard.md) every wave. Template: + +| Level | Meaning for 0.3 TE | +|-------|---------------------| +| L1 local | effects code + tests on branch | +| L2 contract | 8 deliverables + schema/verify/hash | +| L3 N/A | no browser product for this MVP | +| L4 real-environment | actual host invocation with durable ledger readback | +| L5 released | tag + npm (or explicit monorepo release) | +| L6 ga | L5 + scoreboard all pass + no dual-path lies | + +## 6. Standing orders + +1. **Truth over speed** — never promote PARTIAL to PASS without evidence path. +2. **MVP over Control Plane** — ignore daemon/P13–P16 for this GA definition. +3. **Fail closed** — unknown effect kinds, secret material, path overlap = error. +4. **One claim band** — user-facing language ≤ Highest proven level. +5. **Human for L5/L6** — agent prepares candidate; human authorizes tag/publish. + +## 7. Commands (agent cheat sheet) + +```bash +# Focused suite +PI_TASKFLOW_BUILTIN_AGENTS_DIR= node --conditions=development \ + --experimental-strip-types --test 'packages/taskflow-core/test/effects*.test.ts' + +# Broader core (when wave allows) +pnpm --filter taskflow-core test # if script exists +pnpm run typecheck + +# Scoreboard +# edit docs/internal/0.3.0-ga-scoreboard.md after each wave +``` + +## 8. Stop condition + +- **Hard stop success:** L6 checklist complete and human accepted release. +- **Hard stop blocked:** product redefines 0.3 away from Trusted Effects — rewrite freeze first. +- **Soft continue:** any PARTIAL/FAIL on G1–G6 → another `tf-03-ga-converge` wave. diff --git a/docs/internal/0.3.0-ga-scoreboard.md b/docs/internal/0.3.0-ga-scoreboard.md new file mode 100644 index 00000000..5d397102 --- /dev/null +++ b/docs/internal/0.3.0-ga-scoreboard.md @@ -0,0 +1,99 @@ +# 0.3 Trusted Effects — GA Scoreboard + +> Living ledger. **Do not claim GA** unless L6 is PASS with human tag evidence. + +**Last updated:** 2026-08-11 (tip `0551f62`; residual recheck + S-H2 closed) +**Branch:** `rc/0.3.0-trusted-effects` @ `0551f62` +**Code/product tip:** includes TE MVP + store harden `96a32e8` + ADV High harden `cbb4131` + S-H2 static composition `580daa0` (+ docs) +**Current evidence:** exact-SHA remote CI **GREEN** — GitHub Actions run [31466355338](https://github.com/heggria/taskflow/actions/runs/31466355338) on Draft PR #122 head `0551f62` (full matrix PASS incl. windows process-supervisor). Prior green milestones: `cbb4131` run 31463823993; Windows harness `e7c5e31` era. Local residual focused suites **110/110** (adv-high-fix, resource-file-transaction, store, verifier-discover, verify-effects, effects-composition-cache). RC pipeline logs at `/tmp/taskflow-03-rc-final/` (evidence SHA `6071fb2`) still back audit --prod clean + built Codex MCP 16/16 fixture. Live Codex CLI E2E **PASS** on tip `4524d2d` (worker-mac, 2026-08-11) — evidence `docs/internal/evidence/e2e-codex-live-4524d2d.md`. L5/L6 remain FAIL (no tag/publish). + +--- + +## Acceptance Gate + +| Level | Status | Evidence | Notes | +|-------|--------|----------|-------| +| L1 local | **PASS** | tip `0551f62`: CI node 22/24 + residual focused 110/110; audit --prod clean at RC log `6071fb2` | typecheck/suite via CI on tip | +| L2 contract | **PASS** | tip `0551f62`: CI full unit + build + pack + MCP e2e; residual focused green | exact-SHA CI GREEN run 31466355338 | +| L3 browser/electron | **N/A** | — | | +| L4 real-environment | **PASS (built-MCP fixture + live Codex CLI @ 4524d2d)** | CI built-MCP e2e on tip; live `test:e2e-codex` PASS on worker-mac @ `4524d2d` (A→B→C + TE fs.write + ledger why) — see `docs/internal/evidence/e2e-codex-live-4524d2d.md` | both fixture and live legs evidenced on tip family | +| L5 released | **FAIL** | no `v0.3.0-beta.1` tag/publish | human gate | +| L6 ga | **FAIL** | L5 missing | **NOT GA** | + +**Highest proven for the current code candidate:** **L4 real-environment (built-MCP fixture)** with exact-SHA remote CI green on tip `f5284da` — live-Codex-CLI leg closed on tip `4524d2d`. ADV High + S-H2 closed; beta release and GA remain human gates. +**User-facing claim allowed:** *Trusted Effects beta.1 tip `f5284da` is green on exact-SHA CI (run 31613909075) with TE + security hardenings; beta release and GA remain open.* +**G5 status:** **contract pass for admitted declared targets** — PathRef escape, direct-write restore, crash recovery, multi-file rollback, terminal cleanup failure, activation/journal double-fault lease release, before-image GC, gate fast path, cross-run/dynamic-spawn cache bypass, and event-kernel fallback are adversarially covered. Resolve-only execution does not prevent or restore writes to undeclared paths; that requires a future FileBroker/native sandbox and is not a 0.3 MVP claim. + +--- + +## Eight deliverables + +| # | Deliverable | Status | Evidence | +|---|-------------|--------|----------| +| 1 | EffectIR | **pass** | closed TypeBox schema; translate/compile diagnostics; invalid IR receives no canonical hash | +| 2 | PathRef / SecretRef / ServiceRef | **pass** | typed refs; unsupported secret/service backends fail before body execution | +| 3 | labels | **pass** | compositional source/sink summaries cover `flow.def`, saved `use`, `expand`, and unresolved dynamic definitions in schema, verifier, compiler, and runtime | +| 4 | resource transaction | **pass** | durable snapshot/intent/permit + Commit-or-Restore + non-throwing post-terminal cleanup + process-crash recovery/GC | +| 5 | single mutation authority | **pass** | legacy changeset/gateway authority removed; effects bridge delegates to resources | +| 6 | mutating-path overlap | **pass** | phase-local static overlap + persistent cross-session/run lease admission | +| 7 | host matrix | **pass (honest)** | five hosts explicitly resolve-only; FileBroker/secret/service cells explicitly unsupported | +| 8 | why-* | **pass** | DAG-consistent explanation avoids invented independent-phase dependencies; ledger readback derives principal/capability/intent/generation | + +--- + +## Wave log + +| Wave | Result | +|------|--------| +| Resource convergence | single resources authority; focused + full contract PASS | +| Information flow | transitive DAG labels wired through validate/verify/compile/runtime; false-edge regression covered | +| Real host | live Codex three-agent run persisted final output through resource transaction; ledger authorization PASS | +| Remote candidate | Draft PR #117; run 31167592775 passed all CI jobs on `1478510f` | +| Post-review hardening | composition/cache and dynamic-spawn taint; nested information flow; malformed `effects`; terminal cleanup/lease safety; terminal/orphan before-image GC; local contract PASS on `60126954` | +| 0.3 RC pipeline | full RC pipeline green (logs `/tmp/taskflow-03-rc-final/`, built at evidence SHA `6071fb2`): typecheck, 2202-test suite 2198/0/4, build, test:pack 9 pkgs, built Codex MCP 16/16, audit --prod clean; store .pi discovery hardened and re-verified at exact SHA `96a32e8`; exact-SHA remote CI open | +| Exact-SHA CI green | PR #122 head `e7c5e31`: GitHub Actions run 31460133496 full matrix PASS (test node 22/24, e2e codex MCP network-free incl. built-dist full, build dist, packed consumer 9 pkgs + CharterArc, website export, process supervisor ubuntu/macos/windows, CodeQL); Windows test-harness portability fixed (`pathToFileURL`/`fileURLToPath`); local `e7c5e31` typecheck + 2198/0/4 + store 69/69 + detached 8/8 + store-extended 23/23 (t_6e11e70b) | + +## Explicitly deferred non-blocker + +The append-only resource journal still lacks a sharded/compacted projection designed for thousands of intents, and long-lived control-file scale has not been benchmarked. That is a performance/capacity limitation, not a Commit-or-Reject exception in the current bounded MVP. Full before-image bodies are now removed after durable clean terminal states, and aged pre-intent orphans are collected at startup; no high-scale journal claim is made for 0.3. + +--- + +## Commands last green + +```text +# Exact-SHA remote CI (GitHub Actions run 31460133496, PR #122 head e7c5e31): +# https://github.com/heggria/taskflow/actions/runs/31460133496 +# → full matrix PASS: test node 22/24 (typecheck + pnpm test), e2e codex MCP network-free +# (build + test:e2e-codex-mcp + test:e2e-codex-mcp-full + claude/opencode/grok MCP stdio), +# build dist, packed consumer 9 pkgs + CharterArc, website export, process supervisor +# ubuntu/macos/windows (runner-process.test.ts + store.test.ts), CodeQL JS/TS. + +pnpm run typecheck +# → PASS (local at exact SHA e7c5e31; t_6e11e70b) + CI test job node 22/24 + +pnpm test +# → 2198 pass / 0 fail / 4 skipped (local at exact SHA e7c5e31; t_6e11e70b) + CI test job node 22/24 + +pnpm run build +# → PASS — CI build job at exact SHA e7c5e31 (RC pipeline log at 6071fb2 also green) + +pnpm run test:pack +# → PASS 9 packages + CharterArc consumer — CI packed-consumer job at exact SHA e7c5e31 + +pnpm run test:e2e-codex-mcp-full +# → PASS — comprehensive e2e 16/16 against the built dist bin, incl. TE fixture: +# fs.write committed through resources + ledger-backed why-effect (RC pipeline log at +# evidence SHA 6071fb2; CI e2e job re-runs it at exact SHA e7c5e31) + +pnpm audit --prod +# → no known vulnerabilities (RC pipeline log at 6071fb2; CI does not run audit) + +# Focused suites at exact SHA e7c5e31 (t_6e11e70b): store 69/69, detached 8/8, +# store-extended 23/23 (store.test.ts also runs in CI process-supervisor on 3 OSes) + +# LIVE RERUN on 4524d2d (worker-mac): +# pnpm run test:e2e-codex → PASS A→B→C + TE fs.write + ledger (docs/internal/evidence/e2e-codex-live-4524d2d.md) +# pnpm audit --prod at exact SHA — RC pipeline log at 6071fb2 (no prod-code change since) +# Release/GA: no tag, no publish — L5/L6 FAIL until human action. +``` diff --git a/docs/internal/0.3.0-release-plan.md b/docs/internal/0.3.0-release-plan.md new file mode 100644 index 00000000..1931db2c --- /dev/null +++ b/docs/internal/0.3.0-release-plan.md @@ -0,0 +1,36 @@ +# 0.3.0 Trusted Effects — beta.1 release plan + +## Current state + +| Item | Value | +|------|--------| +| Branch | `rc/0.3.0-trusted-effects` | +| Code candidate | `f5284da` (docs: clarify control plane candidate boundaries; product and test candidate already green) | +| Highest proven | **L4 real-environment (built-MCP fixture)** with **exact-SHA remote CI green** (run 31613909075 on PR #122 head `f5284da`: Node 22/24, e2e, build, packed consumer, website export, process supervisor 3 OSes, CodeQL) | +| Remote candidate | **GREEN exact-SHA CI** — Draft PR #122 head `f5284da`, run https://github.com/heggria/taskflow/actions/runs/31613909075 conclusion success, full matrix incl. Windows/macOS/Ubuntu process-supervisor PASS | +| L4 real-environment | **PASS (built-MCP fixture)** — built dist Codex MCP and the checked-in no-LLM Trusted Effects fixture are exercised by CI at the current candidate family; live Codex evidence remains historical to the current tip | +| Package versions on npm | **0.2.10 stable**; `0.3.0-beta.1` not yet published | +| Tag | **`v0.3.0-beta.1` not yet created** | + +## Beta release checklist + +1. Review scoreboard: `docs/internal/0.3.0-ga-scoreboard.md` (all 8 deliverables **pass**, L1/L2 **pass**, L4 **PASS** for the built-MCP fixture with the live-Codex-CLI leg still open on current SHA). +2. Run locally (or confirm the RC logs at `/tmp/taskflow-03-rc-final/`): + ```bash + pnpm run typecheck + pnpm test + pnpm run build + pnpm run test:pack + pnpm run test:e2e-codex-mcp-full + pnpm audit --prod + ``` +3. **Exact-SHA remote CI: DONE** — Draft PR #122 is open (head `rc/0.3.0-trusted-effects` @ `f5284da`) and run 31613909075 is green across the full matrix incl. Windows/macOS/Ubuntu process-supervisor. If the rc tip moves again, re-verify the PR head before relying on this. +4. Bump root + ten publishable packages and pins to `0.3.0-beta.1`. +5. Freeze `CHANGELOG.md` as `## [0.3.0-beta.1] — 2026-08-13`. +6. Merge the reviewed release commit to `main`, then tag `v0.3.0-beta.1`. +7. Publish only through `publish.yml`; the workflow selects npm `beta` and a prerelease GitHub Release. +8. Verify all ten npm packages, provenance, tarballs, fresh consumer, and the beta dist-tag. + +## Agent stop line + +Agents must not tag or npm publish without explicit human authority. Beta publication does not make L6 GA PASS. diff --git a/docs/internal/0.3.0-trusted-effects-mvp.md b/docs/internal/0.3.0-trusted-effects-mvp.md new file mode 100644 index 00000000..4ae0f489 --- /dev/null +++ b/docs/internal/0.3.0-trusted-effects-mvp.md @@ -0,0 +1,132 @@ +# 0.3.0 — Trusted Effects MVP(冻结定义) + +> **Status:** ACTIVE implementation track +> **Branch:** `feat/0.3.0-trusted-effects` +> **Base:** `main` @ `v0.2.7` (`44b45025`) +> **Date frozen:** 2026-08-07 + +## 1. What 0.3 *is* (and is not) + +| 0.3 IS | 0.3 is NOT | +|--------|------------| +| **Trusted Effects** minimal vertical slice | Historical Control Plane (`feat/0.3.0`) GA | +| Agent execution substrate: effects, resource transactions, ledger-backed why-* | Adaptive Planning / Goal / Retain GA | +| Built on published 0.2.7 orchestration | Replacing 0.2 phase DAG | + +**Product sentence** + +> Taskflow 0.3 lets a run **declare**, **stage**, **verify**, and **commit** filesystem (and later secret/service) effects under typed refs and labels — so, for admitted declared FS-write targets, the model proposes content and the resources runtime is the only commit authority. + +Historical Control Plane (`feat/0.3.0`: ControlHost/daemon/Receipt) remains an **experimental parallel track**. It is **not** the GA definition of 0.3.0 under this freeze. + +Adaptive / CharterArc Retain stay **shadow** until this MVP is contract-green. + +## 2. Eight core deliverables (MVP scope) + +| # | Deliverable | MVP bar (must be true) | +|---|-------------|------------------------| +| 1 | **EffectIR** | FlowIR nodes may carry a closed `effects[]` set; compile/validate rejects unknown kinds; hash includes effects | +| 2 | **PathRef / SecretRef / ServiceRef** | PathRef reuses `resources/*`; SecretRef + ServiceRef types exist; unresolved/unsupported refs fail closed (no bare string authority) | +| 3 | **confidentiality + integrity labels** | Labels on effects + optional on refs; schema/verify/compile/runtime reject illegal phase-local and transitive DAG flows (e.g. secret→public write) in a fixed rule set | +| 4 | **Resource-controlled file transaction** | For **declared FS write effects**: durable snapshot → lease → intent/permit → stage → `Commit \| Restore+Reject` | +| 5 | **Single mutation authority** | Existing `resources/*` PathRef resolver, persistent leases, durable journal, and mutation permits are the only authority; `effects/*` is declaration/bridge code only | +| 6 | **mutating-path overlap** | Static + admit-time check: two concurrent mutating PathRefs that overlap → deny or serialize (documented) | +| 7 | **host guarantee matrix** | `conformance/workspace/host-support-baseline.json` has real cells; resolve-only hosts explicit; FileBroker not claimed without proof | +| 8 | **why-authorized / why-context / why-effect** | Pure explainers from ledger/decision records; MCP or core API returns structured reasons | + +### Explicit non-goals for 0.3.0 MVP + +- Full OS sandbox / all-host FileBroker green +- Prevention or rollback of writes to **undeclared** paths under resolve-only execution +- Secret vault backends / live ServiceRef adapters +- ControlHost daemon multi-mount GA +- Auto-tune / Retain / Adaptive promotion +- Event-kernel default-ON (may remain OFF if imperative path enforces effects) + +## 3. Vertical slice demo (acceptance story) + +A flow with one `script` or `agent` phase that declares (see also `examples/trusted-effects-write.json`): + +```jsonc +"effects": [ + { + "id": "report", + "kind": "fs.write", + "purpose": "write final report", + "target": { + "kind": "path", + "path": { + "workspace": "project", + "subpath": { "literalPath": "out/report.md" }, + "intent": "create-file" // PathIntent: create-file | create-directory | existing-file | existing-directory | executable + } + }, + "integrity": "project", + "confidentiality": "internal" + } +] +``` + +**Must:** + +1. `verify` / compile lists the effect and path overlap (if any). +2. Runtime admits every declared target through PathRef resolution, an atomic multi-scope lease, durable intent, and active mutation permit before the phase body. +3. The resource transaction stages payloads and promotes each file with atomic rename; a later failure restores every admitted file to its durable pre-state. +4. Reject proves `beforeContentId === afterContentId` and records `aborted-restored`; unknown restoration blocks later writes. +5. `whyEffect(runId, effectId)` derives principal, capability binding, lifecycle, and generation from the durable resource ledger; declaration alone is not authorization. +6. Unit tests cover happy path, reject, overlap deny, multi-write, and secret label illegal flow — **no live LLM required**. +7. **Agents/scripts must not write declared final paths** — bypass is detected against the durable pre-state and restored; only the resource transaction may finalize declared content. + +## 4. Layered DoD (acceptance-gate) + +| Layer | Required for “0.3 MVP closed” | +|-------|-------------------------------| +| **local** | typecheck + focused unit suite green on branch | +| **contract** | 8 deliverables each have tests + public types exported from `taskflow-core` | +| **real-fixture-e2e** | Node fixture run of vertical slice (script phase, no LLM) | +| **released** | version bump plan + CHANGELOG; not required for “MVP complete” label | +| **GA** | released + host matrix honesty + no dual-path lies; **separate gate after MVP** | + +**GA is not claimed until** local + contract + real-fixture-e2e are green **and** a release candidate is cut. This document’s “MVP complete” ≠ npm publish. + +## 5. Package layout + +```text +packages/taskflow-core/src/ + effects/ + types.ts # EffectIR, labels, refs (Secret/Service) + schema.ts # closed TypeBox EffectIR contract + validate.ts # static effect + label + overlap checks + runtime-apply.ts # declaration-to-resources bridge; no filesystem authority + why.ts # why-authorized / why-context / why-effect + index.ts + flowir/schema.ts # optional effects on nodes (additive) + resources/ + file-transaction.ts # resource-owned snapshot/stage/commit/restore + execution.ts # host authority + PathRef binding + leases.ts # persistent overlap admission + journal.ts # durable intents, permits, terminal evidence +conformance/workspace/host-support-baseline.json # honest cells +``` + +## 6. Relationship to historical Control Plane + +| Track | Branch | Role | +|-------|--------|------| +| Trusted Effects 0.3 | `feat/0.3.0-trusted-effects` | **GA-bound product definition** | +| Control Plane prototype | `feat/0.3.0` | Experimental; may later consume Trusted Effects as exec substrate | +| Adaptive kernel | `codex/hierarchical-adaptive-kernel` | Post-0.3 shadow | + +Do **not** merge Control Plane into this branch as a prerequisite for MVP. + +## 7. Implementation waves + +1. **Types + validate + hash** (EffectIR, labels, SecretRef/ServiceRef, overlap) +2. **Resource transaction** (FS write path only; no second authority) +3. **ledger-backed why-*** + wire into run/tool surfaces +4. **Host baseline honesty** + vertical-slice e2e fixture +5. **Docs / CHANGELOG / skills note** + +## 8. Freeze authority + +This file is the **normative product freeze** for 0.3.0 MVP. Conflicting “Control Plane GA” narratives are superseded for release planning. diff --git a/docs/internal/control-plane-future-notes.md b/docs/internal/control-plane-future-notes.md new file mode 100644 index 00000000..cd571db9 --- /dev/null +++ b/docs/internal/control-plane-future-notes.md @@ -0,0 +1,105 @@ +# Control Plane (feat/0.3.0) — Future Notes / 概念留档 + +> **Status:** ARCHIVED — historical direction, superseded by Trusted Effects. +> **Date:** 2026-08-12 +> **Source branch:** `backup/mac-feat-0.3.0-control-plane` (archived under `backup/0.3-archive/`) +> **Not a release definition.** PR #122 明确: *"Historical Control Plane (feat/0.3.0) is not this release definition."* + +本文档是 0.3 整合归档时的概念留档。Control Plane 与 Trusted Effects 是**互斥架构**(同一执行权威层的两个竞争实现),代码不可合并;但其中若干**概念**对 TE 路线的未来演进(0.4+)有参考价值,故冻结于此供未来重新设计时借鉴。**抄概念,不抄代码。** + +--- + +## 1. 为什么与 TE 互斥(不可 git 合并) + +| 维度 | Trusted Effects (0.3, rc) | Control Plane (feat/0.3.0) | +|---|---|---| +| 机制 | 声明式 `effects[]` + 编译期静态验证 | 命令式 daemon + UDS RPC + ControlStore | +| 授权 | PathRef 白名单 + 资源事务(snapshot→lease→journal→permit→stage→Commit/Restore) | capability tokens + 审批 CAS + 跨进程锁 | +| 执行 | 复用现有 runtime,事务性提交/回滚 | Program→BoundPlan→Run→Receipt 独立闭环 | +| 核心文件 | `schema.ts`/`verify.ts`/`runtime.ts`/`exec/*` | **同一批文件**的深度改写 | + +- 两者都解决"谁有权让 flow 写文件/执行",在同一层级竞争,不是互补模块。 +- TE deliverable #5 要求 **single mutation authority**(移除 legacy changeset/gateway authority);Control Plane 恰恰建立独立的 daemon 级授权权威 → 并存即双权威。 +- control-plane 分支不含 `packages/taskflow-core/src/effects/`(0 文件),即两套代码面根本没有交集点可"融合"。 +- 合并后果:数百处冲突 + 运行时无法定义"谁授权写文件"。 + +## 2. Control Plane 要解决什么(历史目标) + +RFC 文档: `docs/internal/rfc-0.3.0-control-plane.md` (v7.6, 2026-07-22) +产品句: *"Taskflow links programs under policy and capabilities into immutable BoundPlans/BoundFragments, executes them with one semantic kernel on heterogeneous providers, and records a durable **per-project** journal from which runs, receipts, and replays are derived."* + +核心闭环: `Program → BoundPlan → Run → Receipt` +灵魂: single execution semantics · immutable BoundPlan/BoundFragment · durable per-project journal + +**Scoped authority (D6):** +- Project ControlStore = Run/Command/Approval/Receipt 权威(官方项目账本) +- UserCoordinatorStore = singleton lease + 全局并发预留 + coordinator 命令(窄权威) +- ControlRegistry = 非权威发现/投影(目录) +- One ControlDomain per project; daemon multi-mount; 0.3 不做 DomainTransfer/合并用户账本 + +## 3. 值得借鉴的概念(未来 0.4+ 参考) + +### 3.1 并发/容量的运行时控制 +- `maxActiveRuns` 全局并发预留(UserCoordinatorStore CoordinatorLease / ConcurrencyReservation) +- `controlMode: auto` 默认;coordinated fail-closed;standalone 显式 +- `unknown` 状态可 reconcile 且非终态;超时**不得**编造 provider 终态、释放已提交槽位或签发最终 Receipt(§8.4) +- 协调器命令:set maxActiveRuns / force-release(forceRelease 需 risk-ack) + +### 3.2 审批协议(P15 / P16) +- Durability modes (D34): `compat-auto-reject`(默认) / `durable-optional` / `durable-required` +- Wire status: pending | approved | rejected | edited | **expired** | cancelled +- 超时 → request `expired` 且 Run → **blocked**(永不 permanent paused / 永不默认 approve) +- Park (D38): durable pending + provider quiescent → RunStatus paused + RunStage parked; approve → queued + re-reserve +- **Durable reservation-release outbox**(host-local): 先持久化 `ReservationReleaseIntent` 再调用 `normalRelease`,evidence-bound `releaseReservationWithProof`,宿主打开时 drain —— 这个模式对任何"释放外部资源"的持久化场景都有价值 +- 审批 CAS: atomic first-commit-wins approve under exclusive store lock + +### 3.3 Capability / enforcement 模型(P8 / §15) +正交能力维度: +| Capability | Values | +|---|---| +| resolution | contained \| unbound | +| mutationMediation | none \| brokered (per mutation) | +| processIsolation | none \| sandboxed (sealed plan) | +| revocation | admission-only \| per-mutation \| `{mode:"bounded-latency", maxLatencyMs}` | + +- 不支持 sandbox → **fail closed** (D11) +- capability tokens: grant refs + revalidation; plan ≠ bearer (D19) +- Policy: deny | substitute | attenuate (D10) + +### 3.4 持久化/恢复模式(P14) +- files-only ControlStore(非 node:sqlite): temp + fsync + rename 原子提交批;journal/ projections/ commands/ receipts/ 分段;commit-seq 单调计数器 +- 恢复: 重读 journal 段重建投影 +- **P14 可信边界反例(重要教训)**: 若 opener 只读项目根内文件,restorer 可用一个更老的完整快照替换全部本地工件(`.taskflow/` + anchor 都在内),opener 无法区分"合法旧状态"与"回滚伪造状态"——**任何 local-only 算法都无法消除这个不可分辨性**。→ 真正的回滚新鲜度需要项目外锚点。TE 的资源事务应保持"before-image 在干净终态后移除"的现状,但未来若要做整根回滚,必须考虑外部锚点(如用户级可信存储/远程证明)。 + +### 3.5 进程/daemon 监督 +- taskflowd / embedded supervisor / standalone: "Swap clerks; do not swap project ledgers"(换执行进程,不换账本) +- UDS hello-before-RPC 握手; singleton fencing epoch; cross-process locks for store header/coordinator state +- wait for exitSettled before dead-pid fail-closed; poll fail-closed when pid dead under running status + +## 4. 归档时保留的原始文档(在 backup/0.3-archive 分支内,未复制到 rc) + +- `docs/internal/rfc-0.3.0-control-plane.md` — master RFC v7.6(§0-§25 完整) +- `docs/internal/rfc-workspace-capabilities.md` +- `docs/internal/p-adrs/P8-enforcement-capabilities.md` +- `docs/internal/p-adrs/P14-controlstore-engine.md` +- `docs/internal/p-adrs/P15-approval-protocol.md` +- `docs/internal/0.3.0-ga-closure-goal.md` / `0.3.0-ga-traceability-matrix.md` +- `docs/internal/design-org-supervision.md` / `design-dynamic-dag-expansion.md` +- `docs/internal/overstory-convergence-roadmap.md` +- `docs/internal/brainstorm-2026-07-02-feature-roadmap.md` + +需要完整细节时 `git show backup/0.3-archive/mac-feat-0.3.0-control-plane:`。 + +## 5. 未来融合路径(若要做 daemon/审批层) + +1. 0.3 按 TE 发(当前定义,L1-L4 绿) +2. Control Plane 保持归档冻结 +3. 若未来做操作控制面(daemon/审批/并发预留): **以 0.3 TE 为基底重新实现**,借鉴 §3 的概念,不 cherry-pick 旧代码;放进 0.4+ 路线图,作为 TE 的"操作层"(TE = 声明式写入授权地基,Control Plane 概念 = 运行时控制面楼)。 + +## 6. 其他归档分支内容摘要 + +- `backup/mac-0.3.0-beta.2` — beta.2 时代 web/control 证据链(更早路线,含 Safari/浏览器权威证据) +- `backup/mac-te-tip-3e3d2ed4` — TE 早期 tip(= rc 里 e6efcd4 的前身/变体,内容已并入 rc) +- `backup/mac-dirty-budget-soft-hard` — budget soft/hard reserve WIP(`deterministic.ts`/`runtime.ts`/`schema.ts`/`verify.ts` 增量 + 测试;commit 自注 *"Not for main/rc merge"*,若未来要 budget 功能可单独评估 cherry-pick) +- `backup/mac-stash-*` — Mac 抢救的 stash 片段(老 main 历史 + WIP) +- `codex/0.3.0-trusted-effects-candidate` — 旧 TE 候选(PR #117),内容已被 rc 覆盖 diff --git a/docs/internal/evidence/e2e-codex-live-4524d2d.log b/docs/internal/evidence/e2e-codex-live-4524d2d.log new file mode 100644 index 00000000..f4f41572 --- /dev/null +++ b/docs/internal/evidence/e2e-codex-live-4524d2d.log @@ -0,0 +1,11 @@ +$ node --conditions=development --experimental-strip-types packages/codex-taskflow/test/e2e-codex.mts +▶ running 3-phase taskflow on codex (real subagents + Trusted Effects)… + + [progress] [progress] pick:running [progress] pick:running [progress] pick:running [progress] pick:running [progress] pick:done [progress] pick:done use:running [progress] pick:done use:running [progress] pick:done use:running [progress] pick:done use:running [progress] pick:done use:done [progress] pick:done use:done persist:running [progress] pick:done use:done persist:running [progress] pick:done use:done persist:running [progress] pick:done use:done persist:running [progress] pick:done use:done persist:done [progress] pick:done use:done persist:done + +✓ run finished in 27.6s — ok=true + phase pick.output: "Mango" + final output : "MANGO" + total usage : {"input":56411,"output":18,"cacheRead":29952,"cacheWrite":0,"cost":0,"contextTokens":0,"turns":3} + +✅ E2E PASS — live Codex data flowed A→B→C; fs.write committed with ledger-backed authority. diff --git a/docs/internal/evidence/e2e-codex-live-4524d2d.md b/docs/internal/evidence/e2e-codex-live-4524d2d.md new file mode 100644 index 00000000..61bfec3e --- /dev/null +++ b/docs/internal/evidence/e2e-codex-live-4524d2d.md @@ -0,0 +1,17 @@ +# Live Codex E2E @ tip 4524d2d + +**Date:** 2026-08-11 +**Host:** worker-mac (Codex CLI 0.144.1, authenticated) +**Tree:** clean checkout `4524d2d3be1cf670f7e0300ccd2903acc37fc9b6` (`rc/0.3.0-trusted-effects`) +**Command:** `PI_TASKFLOW_CODEX_BIN= pnpm run test:e2e-codex` +**Result:** **PASS** (exit 0) in ~27.6s + +## Proof +- Real `codex exec` subagents for phases pick → use → persist +- Data flow A→B→C: pick `"Mango"` → final `"MANGO"` +- Trusted Effects `fs.write` committed with ledger-backed `whyEffect` authority (`authorized.allowed=true`, principal `local-host-invocation`) +- Log: `docs/internal/evidence/e2e-codex-live-4524d2d.log` + +## Notes +- Brain has no local `codex` binary; dogfood ran on worker-mac with existing `~/.codex` auth. +- Built-MCP fixture L4 remains valid; this closes the **live Codex CLI** leg on tip `4524d2d`. diff --git a/docs/internal/p-adrs/P1-policy-overlay.md b/docs/internal/p-adrs/P1-policy-overlay.md new file mode 100644 index 00000000..567a787e --- /dev/null +++ b/docs/internal/p-adrs/P1-policy-overlay.md @@ -0,0 +1,44 @@ +# P1: Policy overlay(策略叠加) + +> Status: **Accepted** (0.3-C wire-freeze gate) +> Normative parent: [RFC v7.6 §14](../rfc-0.3.0-control-plane-v7.6.md)(D10) +> TE 基底: `resources/authority.ts` InvocationAuthority + `resources/registry.ts` RootRegistry/RootGrant + `resources/types.ts` ScopedContentEvidence.capabilityBindingId + `effects/validate.ts` 静态检查。 + +## Decision + +有效权威是各层策略的**交集**: + +```text +effectiveAuthority = host ∩ user ∩ project ∩ invocation +``` + +- **host**:主机基线策略(`resources/baseline.ts` HostProbe 分类:sandboxed-single-root / sandboxed-multi-root / resolve-only / unsupported)。 +- **user**:用户级策略(0.3-C 在 ControlRegistry/UserCoordinatorStore 层持有,S3 落地)。 +- **project**:项目级策略(随 ControlDomain 的 ControlStore 持久化)。 +- **invocation**:单次调用/声明级策略(TE 的 EffectIR 声明 + PathRef 绑定 + capabilityBindingId)。 + +### 操作(Ops) + +| Ops | 语义 | +|-----|------| +| **deny** | 移除一项 capability | +| **substitute** | 替换为允许的替代(冲突 → deny) | +| **attenuate** | 收缩 scope(永不放大) | + +### 规则 + +- Security-unknown 字段 **fail closed**(未知能力请求 → deny,见 P2)。 +- 目录/标签 ≠ 权威(catalog labels ≠ authority)。 +- Project 策略不能放大 user/host 天花板。 +- 单一 canonical hash 库(见 P6)。 +- 0.3-C 的 TE 执行权威不可被策略层绕过:策略只决定"绑定哪些 capability",实际写入仍必须经过 `resources/*` 的资源事务(snapshot → lease → intent/permit → stage → commit/restore)。 + +## Wire impact (TypeBox) + +- 策略决策记录在 `CommandRecord.authorizationContextHash`(审计元数据,accept 时记录)。 +- disclosure 时用 **live authz** 重查(见 P12)。 +- BoundPlan 携带 capabilitySetHash / policyHash(见 P7 wire-freeze)。 + +## Status + +Accepted for 0.3-C wire freeze(决策内容 = RFC v7.6 §14 批准内容的钉住;TE 映射层新增,无架构分歧)。 diff --git a/docs/internal/p-adrs/P10-rollback-tiers.md b/docs/internal/p-adrs/P10-rollback-tiers.md new file mode 100644 index 00000000..112b4d34 --- /dev/null +++ b/docs/internal/p-adrs/P10-rollback-tiers.md @@ -0,0 +1,31 @@ +# P10: Rollback tiers(回滚分层) + +> Status: **Accepted** (0.3-C wire-freeze gate) +> Normative parent: [RFC v7.6 §20](../rfc-0.3.0-control-plane-v7.6.md)(D12) +> TE 基底: P14 的 trusted-local-disk 信任模型(archive 2026-07-27 scope 决策保留);`resources/persistence.ts` 原子写原语。 + +## Decision + +1. **Full rollback**:任何 0.3 ControlStore 写入之前 → 可完整回滚到 0.2(无迁移成本)。 +2. **首次 0.3 写入之后**: + - read-only export / lossy export **可用**; + - **execute promise 不存在**(导出的历史不得承诺可继续执行)。 +3. **无 DomainTransfer 作为回滚机制**(回滚 ≠ 跨域搬账本)。 + +### 分层表 + +| Tier | 前提 | 能力 | +|------|------|------| +| T0 完整回滚 | 无 0.3 写 | 完整回到 0.2 行为 | +| T1 只读导出 | 有 0.3 写 | 只读/有损导出,无执行承诺 | +| T2 前向运行 | 0.3 GA | 0.3 持续执行(回滚仅 T1) | + +- 0.3-C 的 store 信任模型 = trusted-local-disk(P14):不声称外部单调见证/反回滚;整根一致回滚在 0.3.0 威胁模型之外。 + +## Wire impact (TypeBox) + +- 导出格式复用 Receipt/eventManifest 的 wire types(无新类型);导出文件带 `exportKind: "readonly" | "lossy"` 与 `executePromise: false` 字段。 + +## Status + +Accepted for 0.3-C wire freeze。 diff --git a/docs/internal/p-adrs/P11-compaction-cursor.md b/docs/internal/p-adrs/P11-compaction-cursor.md new file mode 100644 index 00000000..38760615 --- /dev/null +++ b/docs/internal/p-adrs/P11-compaction-cursor.md @@ -0,0 +1,25 @@ +# P11: Compaction + cursor + minAvailableCommitSeq + +> Status: **Accepted** (0.3-C wire-freeze gate — 契约部分;实施在 S3/S4) +> Normative parent: [RFC v7.6 §13/§18](../rfc-0.3.0-control-plane-v7.6.md) +> TE 基底: `resources/journal.ts` 追加式意图日志 + `resources/persistence.ts` 持久化原语;0.3-C ControlStore journal 沿用"追加 + checkpoint"形态。 +> 说明: archive 草稿为 PARTIAL(实施切片证据);0.3-C 在本 ADR 只冻结 **wire/契约**,物理保留/删除策略交给 P14 + S3/S4 实施门。 + +## Decision + +- `commitSeq` **永不**被 compaction 重编号。 +- Receipt 内嵌 issue-time 的 `eventManifest[]` / merkle|hash-chain root → compaction 后仍有效;compaction 不得要求修改旧 Receipt。 +- **Cursor floor 的唯一权威形态**:hash-linked project journal 中经校验的 `CompactionCheckpoint { throughCommitSeq }` 事件 → 派生 `minAvailableCommitSeq = throughCommitSeq + 1`。**无** loose `compaction.json` 缓存可作权威。 +- 过期 cursor → `TF_CURSOR_EXPIRED` → checkpoint resync(P4)。 +- 保留期后缺 blob → `artifactIntegrity: unknown`(**不是**静默 verify)。 +- checkpoint 可推进**逻辑** resync floor,但**不**单独授权删除 journal 段;物理删除需 P14 retention handoff 证明(保留前缀、hash-chain 边界、Receipt 可达性、crash recovery、trust root)后才启用。 + +## Wire impact (TypeBox) + +- `CompactionCheckpointEvent`(journal 内事件,含 throughCommitSeq)。 +- `CursorState { minAvailableCommitSeq, cursorId, leaseExpiresAt }`。 +- Receipt wire 已有 eventManifest/root 字段(P13/Receipt 类型,见 wire-freeze)。 + +## Status + +Accepted(契约)for 0.3-C wire freeze。实施 PARTIAL 状态不阻塞 wire 冻结;P14 重写版在 S3 落地 engine 时承接 retention handoff。 diff --git a/docs/internal/p-adrs/P12-command-batch-reauth.md b/docs/internal/p-adrs/P12-command-batch-reauth.md new file mode 100644 index 00000000..c1021270 --- /dev/null +++ b/docs/internal/p-adrs/P12-command-batch-reauth.md @@ -0,0 +1,51 @@ +# P12: Command batch + re-auth disclosure + +> Status: **Accepted** (0.3-C wire-freeze gate) +> Normative parent: [RFC v7.6 §9](../rfc-0.3.0-control-plane-v7.6.md)(D24) +> TE 基底: `resources/journal.ts` WriteIntentJournal(intent + 原子批)+ `resources/permits.ts` MutationPermitRegistry(一次性 permit)+ `resources/authority.ts` InvocationAuthority。 + +## Decision + +`CommandRecord` 是**不可变权威记录**,与其 ControlEvents 在**同一原子提交批次**内落盘(log-structured 或等价)。唯一索引 `(controlDomainId, commandId)` 可从 journal 重建——**不是**可与日志漂移的可变侧表。 + +### 字段 + +```text +commandId, requestHash +callerPrincipal, authorizationContextHash +projectId, controlDomainId +status, firstCommitSeq, lastCommitSeq +responseArtifactRef? +recordedAt +``` + +### 原子批(9.3) + +1. 先 durable 写响应 Artifact(rename/fsync,如有); +2. 原子提交: CommandRecord + 全部 events,分配连续 commitSeq; +3. 然后 RPC 才算 accepted。 +孤儿 blob GC;**绝不**接受带悬空引用的 commit。 + +### Idempotent 执行 vs 披露(9.4) + +| 情形 | 行为 | +|------|------| +| 同 commandId + requestHash | **不重放**副作用;返回先前响应体,但**先 live 重查**当前 principal 对该 project/command class 的授权(revocation → deny,即使命令已执行过) | +| 同 id 不同 hash | `TF_IDEMPOTENCY_CONFLICT` | +| 不同 principal 同 id | `TF_CROSS_PRINCIPAL_COMMAND` | + +- `authorizationContextHash` 是 accept 时记录的**审计元数据**;披露仍用 live authz。 +- TE 对应物: WriteIntentRecord 的 `authorizationPrincipalId` + `authorizationScopeRoot` 已是 intent 级审计字段;CommandRecord 将其提升为 control-plane wire 字段。 + +### Artifact 访问(9.5) + +`ArtifactRef.digest` 非 bearer;读取需 当前 principal + project scope + **ledger reachability**(artifact 被已授权 run/command 引用)。 + +## Wire impact (TypeBox) + +- `CommandRecordSchema`(新增)+ `ControlEventSchema`(新增,含 commandId FK、commandEventIndex、commitSeq、causationId/correlationId)。 +- `TF_IDEMPOTENCY_CONFLICT` / `TF_CROSS_PRINCIPAL_COMMAND` 入错误信封(P4)。 + +## Status + +Accepted for 0.3-C wire freeze。 diff --git a/docs/internal/p-adrs/P13-bootstrap-singleton.md b/docs/internal/p-adrs/P13-bootstrap-singleton.md new file mode 100644 index 00000000..e72604ff --- /dev/null +++ b/docs/internal/p-adrs/P13-bootstrap-singleton.md @@ -0,0 +1,56 @@ +# P13: Bootstrap / fresh-install / singleton lock / platforms + +> Status: **Accepted** (0.3-C wire-freeze gate — 契约部分;实施在 S2/S3) +> Normative parent: [RFC v7.6 §5](../rfc-0.3.0-control-plane-v7.6.md)(D5/D32) +> TE 基底: `resources/persistence.ts` PersistentFileMutex / 原子写 / fsync 原语;archive P13 的锁语义作为**概念输入**(identity-bound reclaim),不作为已批准实现。 +> 说明: archive P13 为 PARTIAL(锁回收硬化证据);0.3-C 冻结契约与平台决策,锁协议细节在 S2 重新实现并出证据。 + +## Decision + +### controlMode + +| Mode | 行为 | +|------|------| +| **auto**(默认) | 确保 registry + project ControlStore;启动/附着 **user singleton multi-mount control**(taskflowd **或** embedded supervisor 竞争**同一** lock/endpoint — D32);control 起不来 → **fail closed** | +| **coordinated** | 外部 control 必需;down → fail closed | +| **standalone** | **显式**;in-process ControlHost 打开**同一** project ControlStore;单 owner lease;不跨项目声明全局并发 | + +**禁止** `auto → standalone` 静默回退。只有显式 `controlMode: standalone`。 + +### Singleton(D32) + +- Embedded multi-mount 必须与独立 `taskflowd` 竞争**同一** user singleton lock + **同一** coordination endpoint(UDS path / pipe name)。 +- 输者**作为 client 附着**赢者——不得各自成为独立 multi-mount authority。 +- Wire protocol / fencing epoch / UserCoordinatorStore path 与外部 daemon **一致**。 +- 拿不到锁然后"本地 multi-mount 单干" → 禁止(silent fork)。 + +### 排他锁语义(0.3-C 契约) + +- Owner 发布用 hard-link create(**绝不** rename-overwrite);malformed singleton 元数据 fail closed。 +- 死 PID 自动接管保持 fail-closed(POSIX/Node 无 compare-and-unlink 绑定 observed inode → 需未来 OS-backed holder 协议,0.3 不做)。 +- 协作竞争者用 identity-bound reclaim(archive 概念:fixed claim file + O_EXCL + generation 校验 + rename-to-discard + 可恢复 claim cleanup)。 +- Release = 对 acquire 时 device/inode + owner token 的 compare-and-delete(rename-to-discard)。 +- `maxAttempts` 是**硬 pass 预算**(非 maxAttempts×K spins);progress wait 用 generation/claim-aware 等待,不烧预算。 + +### Fresh-install / upgrade 契约(GA 必须过) + +1. Bundled control binary 路径文档化。 +2. 首次 `taskflow_run`(或 CLI 等价)默认: 缺 registry 条目/project ControlStore 则创建;启动/附着 singleton control;**无需手工 daemon 配置**完成一次 run。 +3. 并发 client 启动: 单实例锁/socket acquire;输者附着赢者(无双写者)。 +4. Stale socket: 检测死 peer(pid/lock)→ 移除 socket → 重启。 +5. Version skew: 握手拒绝不兼容 client/daemon;升级路径文档化。 +6. 平台: **Unix UDS required for 0.3 GA**;Windows named pipe **non-GA**(release notes 明示;lock-file 协调仍适用)。 +7. 停机不得损坏 journal;control 恢复前新 admit 失败。 + +### 布局 + +- User: `~/.taskflow/control/`(`TASKFLOW_HOME` 可覆写) +- Project: `/.taskflow/control/`(TE 已有 `defaultWorkspaceControlDirectory` 同源惯例) + +## Wire impact (TypeBox) + +- 握手携带 `protocolMajor` + controlMode(P4);`BootstrapManifest { controlBinaryPath, controlHome, singletonEndpoint, fencingEpoch }` 新增。 + +## Status + +Accepted(契约)for 0.3-C wire freeze。锁/接管实现与证据在 S2,不阻塞 wire 冻结。 diff --git a/docs/internal/p-adrs/P14-controlstore-engine.md b/docs/internal/p-adrs/P14-controlstore-engine.md new file mode 100644 index 00000000..538c6a24 --- /dev/null +++ b/docs/internal/p-adrs/P14-controlstore-engine.md @@ -0,0 +1,58 @@ +# P14: ControlStore engine(TE 基底重写) + +> Status: **Proposed** (0.3-C wire-freeze gate — TE 基底重写,需随 wire freeze 批准) +> Normative parent: [RFC v7.6 §4.2/§22](../rfc-0.3.0-control-plane-v7.6.md) +> TE 基底: `resources/persistence.ts`(`writeJsonAtomicDurable`、`appendJsonLinesDurable`、`PersistentFileMutex`、`fsyncDirectory`)+ `resources/journal.ts`(追加式 intent 日志 + 原子批形态)+ `resources/file-transaction.ts`(snapshot → stage → atomic rename)。 +> 重写说明: archive 草稿 = files-only 引擎 + trusted-local-disk 信任模型 + FAIL 状态;0.3-C 保留其**存储形态与信任边界决策**,但把引擎原语绑定到 TE 已有的持久化层,并明确 S3 的实施门。 + +## Decision + +0.3 GA engine 是 **files-only**(仍是一个完整引擎——fsync、atomic batch、locks、recovery、compaction 都是 engine 职责): + +- **Atomic commit batch**: temp + fsync + rename(TE `writeJsonAtomicDurable` / `appendJsonLinesDurable` 直接复用)。 +- Journal segments 在 `journal/`;projections 在 `projections/`;commands 在 `commands/`;receipts 在 `receipts/`。 +- **Header**: `{ projectId, controlDomainId, schemaVersion, directoryBinding }`。 +- `commit-seq.json` 单调计数器;`commitSeq` 永不重编号(P11)。 +- **No `node:sqlite`**,除非未来 ADR 替换本 ADR。 + +### 恢复 + +重读 journal segments;projection 缺失时从 events 重建(幂等)。 + +### 信任边界(trusted-local-disk,沿用 archive 2026-07-27 scope 决策) + +- 假设项目根、`.taskflow/`、`.taskflow-control.anchor.json` **不会被整体一致恢复到某个旧快照**。 +- 能把所有本地咨询字节一致回滚的 actor(备份/VM 快照/FS 管理员)在 0.3.0 威胁模型**之外**:无 anti-rollback / external freshness 承诺。 +- 不要求外部单调见证或外部认证修复服务。 +- 该排除**很窄**:malformed/torn 文件、缺根/缺锚、journal 不连续、crash-at-write-boundary、并发写者、fencing 违规、symlink/路径替换、compaction/retention 正确性、recovery 与 mutation 竞态——**全部仍在 P13/P14/P16 GA 范围内**,必须 fail closed。 +- 本地 `repair` 记录不能自称 freshness 根(可被整根回滚);未来 anti-rollback 必须绑定 restorer 无法一起回滚的外部因素(OS/hardware 单调计数、enterprise 审计服务等)。 +- 信任检查失败/不可用 → 允许清晰标注的 forensic read/export 路径,但**禁止** ControlStore mutation、provider dispatch、capacity change、approval settlement、Receipt issuance。 + +### 0.3-C 决策记录(在 archive 决策表上补充) + +| 字段 | Frozen 0.3-C 决策 | +|---|---| +| 引擎 | files-only;原语 = TE `resources/persistence.ts` / `resources/journal.ts`;布局 = journal/projections/commands/receipts + header + commit-seq.json | +| 信任 | trusted-local-disk;整根一致回滚排除(archive 2026-07-27 决策保留,release notes 必须保留该限制声明) | +| 原子性 | CommandRecord + events 同批原子提交(P12);permit/intent 顺序沿用 TE 资源事务 | +| 锁 | 单写者/恢复由 P13 singleton + PersistentFileMutex 承接;跨进程互斥由 TE persistence 层提供 | +| 恢复 | 重读 journal → 重建 projections;`unknown` 状态按 P5 保持非终态 | +| 物理 compaction | 契约在 P11(CompactionCheckpoint 事件);物理删除需 S3 retention handoff 证明后才启用 | +| 反回滚 | 不在 0.3.0 承诺内;未来升级必须走"持久协议 + compare-and-advance 授权",不是新 JSON 文件 | + +### 实施门(S3,非 wire-freeze 门) + +- 原子批 crash/power-loss 矩阵(写前/写中/写后重开:旧完整态 | 新完整态 | fail-closed,绝无未验证混合态)。 +- 并发写者/单写者证明;fencing 与 stale owner 拒绝。 +- temporary-path symlink 硬化(archive Round 53 教训: 随机 UUID + 独占 `"wx"` 创建 + EEXIST 重试;rename 目标被并发替换为 symlink 的残余竞态必须 fail closed 或文档化为 OS 下限)。 +- recovery racing mutation 的 fail-closed 路径。 +- 每个入口(MCP / CLI / taskflowd)在首次 mutation/provider 调用前执行同一 gate。 + +## Wire impact (TypeBox) + +- `ControlStoreHeaderSchema`、`ControlStoreStatus`(store 健康/恢复状态)新增。 +- `ControlStoreDurabilityError` → `TF_DURABILITY_FAILED`(P4 信封)。 + +## Status + +Proposed(重写)for 0.3-C wire freeze — 存储形态与信任边界 = archive 批准的延续;原语绑定 TE persistence 层 + S3 实施门为本版新增内容。 diff --git a/docs/internal/p-adrs/P15-approval-protocol.md b/docs/internal/p-adrs/P15-approval-protocol.md new file mode 100644 index 00000000..e6448ee6 --- /dev/null +++ b/docs/internal/p-adrs/P15-approval-protocol.md @@ -0,0 +1,80 @@ +# P15: Approval protocol(TE 基底重写) + +> Status: **Proposed** (0.3-C wire-freeze gate — TE 基底重写,需随 wire freeze 批准) +> Normative parent: [RFC v7.6 §17](../rfc-0.3.0-control-plane-v7.6.md)(D34/D38) +> TE 基底: `schema.ts` PHASE_TYPES 已有 `"approval"` phase kind + `effects/validate.ts`(OutputContract 检查面)+ P12 CommandRecord 决策权威 + P14 journal 原子批。 +> 重写说明: archive 草稿 = V1 wire + P16-1R schema-2/3 修正提议 + B06 host notes,状态复杂(V1 路径曾 FAIL)。0.3-C 重写为**单一 wire 协议**:决策=CommandRecord、CAS first-commit-wins、park 经 D37 normalRelease、readmission 在 S4 以 P15×P16 联合测试落地(不再引用 archive 的 schema-2/3 迁移叙事)。 + +## Decision + +0.3 是 0.2.4 `ApprovalRequest`(`phaseId/message/upstream`)的**协议升级**。 + +### ApprovalRequest(wire) + +```text +ApprovalRequest { + approvalRequestId + runId, nodeInstanceId + boundPlanHash | boundFragmentHash + expectedRunVersion + allowedDecisions: approve | reject | edit + owner / audience / requiredPrincipals? + deadline, timeoutPolicy + status: pending | approved | rejected | edited | expired | cancelled + createdAt, decidedAt? + decisionCommandId? // 决策 = CommandRecord + editArtifactRef? // 当 edit +} +``` + +### 规则(normative minimum) + +- 请求 `pending` 期间 RunStatus = **`paused`**。 +- 决策是 **CommandRecord**(幂等;披露 live re-auth,P12)。 +- **CancelRequested 先到** → 后续 ApprovalDecision CAS 失败;request → `cancelled`。 +- **ApprovalDecision 先到** → 清除 pause;后续 CancelRequested 仍可取消 Run。 +- 同 `expectedRunVersion` 竞态 → first commit wins(store 锁内 CAS)。 +- **Timeout → ApprovalRequest `expired` only**;Run → **`blocked`**(永不永久 paused)。**永不默认 approve**。 +- **edit output** → OutputContract 检查;**不** re-link。 +- **edit plan** → 必须 re-Link。 +- Decider principal/audience 见本 ADR wire 字段;重启 + 双客户端测试是 GA 要求。 + +### Durability modes(D34 — 不折叠 requires/allows) + +| Mode | Link/Admit | Runtime | +|------|-----------|---------| +| **`compat-auto-reject`**(默认) | 无 durable inbox 也 OK | 立即 **blocked**(0.2.4 headless 兼容) | +| **`durable-optional`** | host/caller 缺 durable 也 OK | 优先 durable(若协商成功);否则 auto-reject → blocked | +| **`durable-required`** | host 或 caller 无法 durable → **`TF_FEATURE_REQUIRED` at Link/Admit**(不是假 human reject) | `paused` + pending 直到 decide/timeout/cancel | + +协商: flow mode + ControlHost offers + caller accepts。历史: auto-rejected 保持 blocked 除非 resume/re-run;pending 可被任何已授权 durable client 决定。 + +### Park vs maxActiveRuns(D38 — product pin) + +| 情形 | Run-slot | +|------|----------| +| durable approval **pending** + provider **quiescent**(无活/模糊副作用) | **normalRelease**(park);Run 保持 `paused` | +| Approval **approved** | Run → **`queued`**(或 re-admit 路径);必须**重新 reserve** 才能继续执行 | +| Approval **rejected/expired** → `blocked` | 若 quiescent 释放(terminal park) | +| cancel-in-flight / `unknown` / 非 quiescent | **保持** committed 或 orphan-suspect slot | +| compat-auto-reject | 永不占长期 approval slot | + +### Quiescence 判定(0.3-C 钉住) + +- `noLiveOrAmbiguousSideEffects`(D37)= provider/isolation 证明无活进程树 ∧ 无该 run 的开放模糊 provider job ∧(若 unknown/reconciling: 不是"仅 auto-reconcile 超时")。 +- 0.3-C 的 ExecutionProvider 唯一实现是 TE resources/* 执行面:quiescence = TE 资源事务无未决 intent/permit + 无活 phase 进程 + journal 无 `dirty-unknown` 意图。 +- park 释放前先持久化 release intent(archive B06 outbox 概念 → 0.3-C 采纳为 S4 的 reservation-release outbox: 先 journal `ReservationReleaseIntent` 再 `normalRelease`,evidence-bound,host 打开时 drain)。 + +### readmission(S4 落地,wire 冻结内容) + +- approve → queued → re-reserve → re-admit 是一条**独立 saga**(P15 × P16 联合测试要求);不得复用父 reservation/owner/attempt/key/provider handle/Receipt。 +- 0.3-C 不采用 archive P16-1R 的 schema-2/3 迁移叙事;版本化 readmission 在 S4 从本 wire 契约重新推导。 + +## Wire impact (TypeBox) + +- `ApprovalRequestSchema`(新增,字段见上)+ `ApprovalDecisionCommand`(= CommandRecord.kind: "approval.decide",含 decision + expectedRunVersion CAS 字段)。 +- 错误码 `TF_STALE_VERSION`(CAS 失败)入 P4 信封。 + +## Status + +Proposed(重写)for 0.3-C wire freeze — wire 协议 = RFC §17 批准内容;TE quiescence 判定 + outbox + readmission 重定义为 0.3-C 版本。S4 联合测试未过前,park 路径保持 fail-closed。 diff --git a/docs/internal/p-adrs/P16-coordinator-concurrency.md b/docs/internal/p-adrs/P16-coordinator-concurrency.md new file mode 100644 index 00000000..3f16d692 --- /dev/null +++ b/docs/internal/p-adrs/P16-coordinator-concurrency.md @@ -0,0 +1,113 @@ +# P16: UserCoordinatorStore concurrency + release + +> Status: **Accepted** (0.3-C wire-freeze gate) +> Normative parent: [RFC v7.6 §4.3.2](../rfc-0.3.0-control-plane-v7.6.md)(D30/D36/D37) +> TE 基底: `resources/types.ts` ExecutionOwner(runId/phaseId/attemptId/unitId/ancestry)→ 预留绑定身份;`resources/leases.ts` 持久租约协调器 = 跨进程互斥先例。 +> 说明: archive P16 含 D1–D4 store-local 硬化 + P16-1R 补充;0.3-C 冻结 wire/契约(slots/capacity/release/crash matrix),D1–D4 硬化语义并入 S3 实施门,P16-1R saga 留待 S4 重新推导。 + +## Decision + +### 容量(D30) + +```text +count(reservations where state ∈ {reserved, committed, orphan-suspect}) ≤ maxActiveRuns +``` + +- `slots ≡ 1` 每个 admitted Run(**不加权**;未来加权需要单独 `admissionWeight` ADR,不在 0.3)。 +- 计量: `maxActiveRuns` = 并发 **admitted Runs**;`flow.concurrency` = 单个 Run 内并发 subagents——**不混用**。 +- 0.3 全局预算: **statistics only**。 + +### 生命周期 + +```text +reserve (reserved, TTL OK) + → Run Admitted + projectAdmitCommitSeq + → committed (no TTL release) + → dispatch / park / reconciling … + → normalRelease | forceRelease +``` + +### Release predicates(D37 — product pin) + +```text +noLiveOrAmbiguousSideEffects = + provider/isolation 证明无活进程树 + AND 无该 run 的开放模糊 provider job + AND (若 unknown/reconciling: 不是"仅 auto-reconcile 超时") + +normalRelease = + noLiveOrAmbiguousSideEffects + AND ( runIsTerminal (completed|failed|blocked|cancelled) + OR runIsParkedAndFutureDispatchRequiresReadmission ) // 如 durable approval pause + provider quiescent + +forceRelease = + authorizedOperatorCommand (CoordinatorCommandRecord) + AND explicitRiskAcknowledgement + → concurrency guarantee 标记 operator-overridden +``` + +| State | TTL auto-reclaim? | 备注 | +|-------|-------------------|------| +| **reserved** | Yes | pre-admit | +| **committed** | **Never by TTL** | **仅 D37** normalRelease / forceRelease | +| **orphan-suspect** | holds capacity | crash / reconcile 自动化耗尽;仍计入容量公式 | + +### 禁止(forbidden) + +- TTL-only committed release;reconcile 超时后 fake terminal;弱化 D37 的简写(status 字段单独不足以释放);CLI 无 CoordinatorCommandRecord 直接改 reservation。 + +### Crash matrix(minimum,wire 冻结) + +| Crash window | Reservation | Run | +|-------------|-------------|-----| +| reserve 后、admit 前 | TTL 过期 → expired | none | +| commit 后、provider ack 前 | committed | unknown/reconciling | +| reconcile 耗尽 | orphan-suspect | unknown + needs-operator | +| terminal + normalRelease 后 | released | terminal + Receipt | + +### UserCoordinatorStore(scoped authority — 非项目总账本) + +```text +UserCoordinatorStore (user-private) +├── CoordinatorLease { holderId, fencingEpoch, endpoint, expiresAt } +├── maxActiveRuns +├── CoordinatorCommandRecord { # 窄命令权威(D6) +│ commandId, requestHash, callerPrincipal, +│ kind: setMaxActiveRuns | forceRelease | … +│ firstCommitSeq, lastCommitSeq, status +│ } +├── ConcurrencyReservation { +│ reservationId +│ state: reserved | committed | released | expired | orphan-suspect +│ slots: 1 +│ # state ∈ {committed, orphan-suspect} 时必填: +│ projectId, projectControlDomainId, runId +│ projectAdmitCommitSeq +│ attemptId?, providerJobHandle? +│ coordinatorEpoch +│ reservedExpiresAt? # 仅 reserved 期间 +│ renewedAt? +│ } +└── (无项目 Run 历史 / 项目 Receipts) +``` + +### Store-local 硬化语义(并入 S3 实施门,wire 冻结) + +- **D1 Clock authority**: TTL 决策只用 store 拥有的 wall clock;无可注入时钟构造。 +- **D2 Admission uniqueness**: `(projectId, projectControlDomainId, runId)` 在 committed/orphan-suspect 行唯一;重复 → `TF_ADMISSION_BINDING_CONFLICT`。 +- **D3 Legacy residue**: 旧 expired/released 行残留 `reservedExpiresAt` 必须能重开;committed/orphan-suspect 带残留 TTL 字段 → fail closed。 +- **D4 normalRelease idempotency**: 同 owner 重试返回先前 released 记录,不 churn updatedAt。 + +### 实施门(S3,非 wire-freeze 门) + +- 32 进程竞争、N+1 竞争、fencing、release 竞态、orphan-suspect 保持容量、CoordinatorCommandRecord 审计链。 +- P15×P16 联合测试(approval park → release slot → approve → re-reserve)。 + +## Wire impact (TypeBox) + +- `CoordinatorLeaseSchema`、`ConcurrencyReservationSchema`、`CoordinatorCommandRecordSchema`、`CapacitySnapshot { maxActiveRuns, active, reserved, committed, orphanSuspect }` 新增。 +- 错误码 `TF_ADMISSION_BINDING_CONFLICT`、`TF_CAPACITY_EXCEEDED` 入 P4 信封。 + +## Status + +Accepted for 0.3-C wire freeze。P16-1R(版本化 first-dispatch-owner saga)不在本 wire 冻结内;S4 运行控制阶段重新推导。 diff --git a/docs/internal/p-adrs/P2-empty-policy-exposure.md b/docs/internal/p-adrs/P2-empty-policy-exposure.md new file mode 100644 index 00000000..74f6218d --- /dev/null +++ b/docs/internal/p-adrs/P2-empty-policy-exposure.md @@ -0,0 +1,28 @@ +# P2: Empty-policy exposure(空策略暴露面) + +> Status: **Accepted** (0.3-C wire-freeze gate) +> Normative parent: [RFC v7.6 §14/§19](../rfc-0.3.0-control-plane-v7.6.md)(D21) +> TE 基底: `resources/baseline.ts` 主机默认分类(resolve-only 是 TE 当前唯一已证明分类)+ `effects/validate.ts` 默认拒绝路径。 + +## Decision + +当不存在显式 project/user 策略时,暴露面 = **host-default attenuated**: + +- 允许 link/admit 公共 0.2.4 表面(D21 兼容,`packages/taskflow-core/test/fixtures/public-surface-0.2.4.json` 为 golden)。 +- **不**授予网络、跨项目、或 DomainTransfer 能力。 +- Approval mode 默认 `compat-auto-reject`(见 P15)。 + +## Empty ≠ unrestricted + +- 缺失策略**永不**等于"允许一切"。 +- 未知 capability 请求 → deny(fail closed)。 +- TE 语义映射:未声明的 `effects[]` 或未解析的 PathRef/SecretRef/ServiceRef → 编译/准入即拒绝(`validateEffectIR` + `runtime-apply` 桥只对已绑定 capability 放行)。 + +## Wire impact (TypeBox) + +- 空策略在 wire 上仍显式存在:`PolicyBundle { hostCeiling, userCeiling?, projectCeiling?, invocationCeiling? }`,缺失层 = 继承上一层,最终 fail-closed 兜底。 +- 不存在"无 PolicyBundle"的合法 wire 形态。 + +## Status + +Accepted for 0.3-C wire freeze。 diff --git a/docs/internal/p-adrs/P3-domain-registry-identity.md b/docs/internal/p-adrs/P3-domain-registry-identity.md new file mode 100644 index 00000000..8d05ca7d --- /dev/null +++ b/docs/internal/p-adrs/P3-domain-registry-identity.md @@ -0,0 +1,32 @@ +# P3: Domain + Registry rebuild + clone/worktree identity + +> Status: **Accepted** (0.3-C wire-freeze gate) +> Normative parent: [RFC v7.6 §4.1/§4.3.1](../rfc-0.3.0-control-plane-v7.6.md)(D7/D27) +> TE 基底: 项目根锚点模式(`/.taskflow-control.anchor.json` + ControlStore header,P14 定义);`resources/persistence.ts` 原子写/fsync 原语。 + +## Decision + +- ControlStore **header** 是 `projectId` + `controlDomainId` 的权威来源: + `{ projectId, controlDomainId, schemaVersion, directoryBinding }`。 +- ControlRegistry 是**非权威**发现/投影;Registry 丢失时,下次打开项目路径 → 从 header 重新注册(**不得**从 registry 凭空发明 run 状态)。 +- 可选全盘发现仅限显式配置的 discovery roots(默认不整家爬取)。 + +### clone / copy / worktree / move 身份策略 + +| 情形 | 决策 | +|------|------| +| **move**(rebind 成功后 inode 证据一致) | 同一 projectId | +| **copy/clone** | **新 projectId**(新 domain),除非显式 "adopt identity" operator 命令 | +| **git worktree** | 新 binding;默认**新 projectId**(避免两个 worktree 共享一个活 journal 而无排他租约) | + +- 0.3-C: 每项目一个 ControlDomain;`controlDomainId` 在 daemon 重启、standalone↔daemon 切换、客户端升级时**不变**。 +- 无 DomainTransfer(RFC 明确 out of 0.3)。 + +## Wire impact (TypeBox) + +- `ControlStoreHeader`、`ControlRegistryEntry { projectId, controlDomainId, storePath, directoryBinding, mountState, summary? }` 新增 wire types(见 wire-freeze)。 +- Registry 条目可重建;header 不可重建(丢失 = fail closed)。 + +## Status + +Accepted for 0.3-C wire freeze。 diff --git a/docs/internal/p-adrs/P4-negotiation-errors.md b/docs/internal/p-adrs/P4-negotiation-errors.md new file mode 100644 index 00000000..d3c2a79b --- /dev/null +++ b/docs/internal/p-adrs/P4-negotiation-errors.md @@ -0,0 +1,44 @@ +# P4: Negotiation + errors + recoveryAction + +> Status: **Accepted** (0.3-C wire-freeze gate) +> Normative parent: [RFC v7.6 §18](../rfc-0.3.0-control-plane-v7.6.md) +> TE 基底: `resources/*` 已用 typed errors(如 `TF_DURABILITY_FAILED`、`TF_INVALID_ARGUMENT`、`ControlStoreDurabilityError`);0.3-C 将其统一为 wire 错误信封。 + +## Decision + +### 握手(negotiation) + +```text +{ protocolMajor, supportedReadSchemas[], supportedWriteSchemas[], + requiredFeatures[], offeredFeatures[], buildInfo } +``` + +- `protocolMajor` 不匹配 → `TF_PROTOCOL_INCOMPATIBLE`。 +- `requiredFeatures` 无法满足 → `TF_FEATURE_REQUIRED`(link/admit 阶段拒绝,见 P15 durable-required)。 + +### Error envelope(统一错误信封) + +```text +{ + code, message, + recoveryAction: retry-same-command | retry-new-command | refresh + | reconcile | operator | none, + sideEffects: none | possible | unknown, + commandId?, commitSeq?, controlDomainId?, projectId? +} +``` + +Codes(0.3-C wire 全集,禁止自定义裸 code):`TF_PROTOCOL_INCOMPATIBLE`、`TF_SCHEMA_*`、`TF_FEATURE_REQUIRED`、`TF_POLICY_DENIED`、`TF_AUTHORITY_REVOKED`、`TF_STALE_VERSION`、`TF_IDEMPOTENCY_CONFLICT`、`TF_CROSS_PRINCIPAL_COMMAND`、`TF_LEGACY_CONFLICT`、`TF_PROVIDER_AMBIGUOUS`、`TF_JOURNAL_UNAVAILABLE`、`TF_DURABILITY_FAILED`、`TF_CURSOR_EXPIRED`、`TF_COMMAND_FAILED`、`TF_BOOTSTRAP_FAILED`、`TF_RECONCILE_REQUIRED`、`TF_ADMISSION_BINDING_CONFLICT`、`TF_CAPACITY_EXCEEDED`。 + +### TF_RECONCILE_REQUIRED(normative pin) + +- `recoveryAction: operator`,`sideEffects: unknown`。 +- `taskflow_runs(wait)` / 状态 RPC 返回**正常快照**(status=unknown、needs-operator 标志),**不是** transport 级 RPC 失败。 + +### Cursor + +- `minAvailableCommitSeq`、cursor lease/TTL;`TF_CURSOR_EXPIRED` → checkpoint resync(见 P11)。 + +## Status + +Accepted for 0.3-C wire freeze。 diff --git a/docs/internal/p-adrs/P5-phase-feature-matrix.md b/docs/internal/p-adrs/P5-phase-feature-matrix.md new file mode 100644 index 00000000..38c566e1 --- /dev/null +++ b/docs/internal/p-adrs/P5-phase-feature-matrix.md @@ -0,0 +1,61 @@ +# P5: Phase × feature + RunStatus/RunStage 矩阵 + +> Status: **Accepted** (0.3-C wire-freeze gate) +> Normative parent: [RFC v7.6 §8/§19](../rfc-0.3.0-control-plane-v7.6.md)(D21/D31/D33) +> TE 基底: `packages/taskflow-core/src/schema.ts` 的 PHASE_TYPES(13 种 closed kinds,含 approval/race/expand)+ `public-surface-0.2.4.json` golden。 + +## Decision + +### Golden 基准 + +公共 0.2.4 表面(schema、docs/skills、examples、exports、tests、promised errors)→ golden(`packages/taskflow-core/test/fixtures/public-surface-0.2.4.json`),D21 兼容以此为准。 + +### 0.3 RunStatus(用户可见/API 生命周期) + +```text +running | completed | failed | paused | blocked | cancelled | unknown +``` + +Terminal 仅: `completed | failed | blocked | cancelled`。**`unknown` 非终态**(D33)。 + +### 0.3 RunStage(控制管线进度) + +```text +received | compiled | linked | queued | admitted | executing | parked | reconciling | terminal +``` + +### 规范配对 + +| 情形 | RunStatus | RunStage | Slot | +|------|-----------|----------|------| +| durable approval + provider quiescent | paused | **parked** | released (D37/D38) | +| cancel-in-flight + worker 仍活 | paused | executing | held | +| provider ambiguous | unknown | reconciling | held / orphan-suspect | +| 真正结束 | terminal | terminal | 仅 D37 释放 | + +### 边界(bounds) + +- Auto-reconcile 默认 `maxAttempts=3`(可覆写);只约束**自动化**,不约束事实。 +- 耗尽后: 保持 `unknown`;**无**最终 Receipt;slot → orphan-suspect(仍占 maxActiveRuns)。 +- `executing → queued` 非法,除非经 `parked`(或 P5 定义的显式重启路径)。 +- Approval 三模式(compat-auto-reject / durable-optional / durable-required,P15)。 + +### Cancellation import(0.2.4 → 0.3) + +| 0.2.4 观察 | 0.3 导入 | +|-----------|---------| +| paused + detachedCancel + worker 仍活 | paused + executing(slot held) | +| paused + detachedCancel + worker 已死/确认 | cancelled + terminal | +| failed 消息仅提及 cancel | 保持 failed(除非有 durable cancel marker) | +| 干净 cancel 无 paused 中间态 | cancelled | + +P5 必须包含 resume-after-detachedCancel goldens。 + +## Wire impact (TypeBox) + +- `RunStatus` / `RunStage` 为独立 StringEnum wire 字段;`RunSnapshot` 携带两者 + slot 状态 + needs-operator 标志。 +- Phase 类型沿用 TE PHASE_TYPES 作为 feature matrix 的 x 轴(不新增 wire 枚举)。 + +## Status + +Accepted for 0.3-C wire freeze。 diff --git a/docs/internal/p-adrs/P6-canonical-hash-refs.md b/docs/internal/p-adrs/P6-canonical-hash-refs.md new file mode 100644 index 00000000..e67a77c3 --- /dev/null +++ b/docs/internal/p-adrs/P6-canonical-hash-refs.md @@ -0,0 +1,37 @@ +# P6: Canonical hash + ArtifactRef + SecretRef + +> Status: **Accepted** (0.3-C wire-freeze gate) +> Normative parent: [RFC v7.6 §7.2/§11/§12](../rfc-0.3.0-control-plane-v7.6.md)(D25/D26) +> TE 基底: `flowir/canonical-hash.ts`(现有 canonical hash 库)+ `effects/types.ts` SecretRef `{ secretId, issuer? }` + `resources/persistence.ts` contentId/blob 惯例。 + +## Decision + +### Canonical hashes + +| Hash | 形式 | 语义 | +|------|------|------| +| `boundPlanHash` / `boundFragmentHash` | `bp:` | 稳定 JSON 的全链路审计身份(audit identity) | +| `executionSemanticHash` | `es:` | 解析后的执行描述符(复用键,RFC §11 字段集) | + +- **单一 canonical hash 库**:仅 Node `crypto.createHash("sha256")`;wire 上不允许第二种哈希算法(防折叠混淆)。TE 已有 `flowir/canonical-hash.ts`,0.3-C 不另起炉灶。 +- authority epoch 本身**不进入** executionSemanticHash。 +- Class folding 仅在已发布的等价契约下进行。 + +### ArtifactRef / SecretRef + +```text +ArtifactRef { digest, size, mediaType, storageClass, redactionClass } +SecretRef { secretId, issuer } // 无内容 digest +``` + +- `ArtifactRef.digest` **不是 bearer token**:读取需要 当前 principal + project scope + **ledger reachability**(artifact 被授权的 run/command 引用)。 +- Secrets 永不进入通用 ArtifactStore 作为 content-addressed blob。TE 的 `SecretRef` 与 RFC 完全一致 → **直接复用**,不新增 wire 类型。 + +## Wire impact (TypeBox) + +- `ArtifactRefSchema` / `SecretRefSchema`(复用 TE `effects/schema.ts` 的 SecretTargetSchema 内嵌形态,抽出为顶层类型)。 +- `BoundFragmentSchema` 携带双 hash(见 P7)。 + +## Status + +Accepted for 0.3-C wire freeze。 diff --git a/docs/internal/p-adrs/P7-dynamic-paths-dual-hashes.md b/docs/internal/p-adrs/P7-dynamic-paths-dual-hashes.md new file mode 100644 index 00000000..561b06bc --- /dev/null +++ b/docs/internal/p-adrs/P7-dynamic-paths-dual-hashes.md @@ -0,0 +1,50 @@ +# P7: Dynamic paths + dual hashes + cache + +> Status: **Accepted** (0.3-C wire-freeze gate) +> Normative parent: [RFC v7.6 §7/§11](../rfc-0.3.0-control-plane-v7.6.md)(D17/D25) +> TE 基底: `resources/schema.ts` PathRef(literalPath/argPath/segments 动态解析)+ `ScopedContentEvidence`(beforeContentId/afterContentId)+ `ExecutionOwner` 身份。 + +## Decision + +Compile+Link 后的动态 IR 产出 **BoundFragment**,携带**双 hash**: + +- `boundFragmentHash` — 全链路审计身份(audit identity) +- `executionSemanticHash` — 复用键(reuse key) + +```text +BoundFragment { + parentBoundPlanHash, parentBoundFragmentHash? + sourceEventId, sourceCommitSeq + fragmentIRHash, fragmentPolicyHash, capabilitySetHash, authorityEpoch + boundFragmentHash, executionSemanticHash +} +``` + +### 动态库存(dynamic inventory) + +| 路径 | 规则 | +|------|------| +| flow{def} / nested expand / graft / ctx_spawn subflow | BoundFragment 链 | +| saved flow use | 根部 Link 时 pin irHash/boundPlanHash;**不可变 re-resolve** | +| flat ctx_spawn | SpawnTemplate ceiling → NodeInstance;否则 fragment 或 deny | +| map/loop/tournament items | 义务绑定时确定性 nodeInstanceId | + +### Cache 复用谓词(完整,缺一不可) + +```text +authority valid ∧ lease/version valid ∧ executionSemanticHash 相等 +∧ artifact integrity ∧ output contract OK ∧ re-Link/validate 允许 +``` + +- 禁止盲目 promotedPhases 恢复。 +- 缓存存 fragment ArtifactRef、双 hash、事件区间、输出;复用前 re-Link/validate。 +- TE 语义映射:`ScopedContentEvidence.beforeContentId/afterContentId` 是 fragment 级 artifact 完整性证据;`capabilityBindingId` 是 authority 证据——cache 命中必须二者同时有效。 + +## Wire impact (TypeBox) + +- `BoundFragmentSchema`(新增,含双 hash + 链上证据字段)。 +- `SpawnTemplateSchema`(新增: allowedAgentClasses, allowedProviderClasses, tool/effect ceilings, maxChildren, maxDepth, budgetShare)。 + +## Status + +Accepted for 0.3-C wire freeze。 diff --git a/docs/internal/p-adrs/P8-enforcement-capabilities.md b/docs/internal/p-adrs/P8-enforcement-capabilities.md new file mode 100644 index 00000000..35e3bd6d --- /dev/null +++ b/docs/internal/p-adrs/P8-enforcement-capabilities.md @@ -0,0 +1,43 @@ +# P8: Enforcement capabilities(TE 基底重写) + +> Status: **Proposed** (0.3-C wire-freeze gate — TE 基底重写,需随 wire freeze 批准) +> Normative parent: [RFC v7.6 §15/§16](../rfc-0.3.0-control-plane-v7.6.md)(D23/D11) +> TE 基底: `resources/baseline.ts` HostProbeClassification + `resources/execution.ts`(resolve-only 执行协调器、单提交权威)+ `resources/schema.ts` PathRef 白名单 + `effects/schema.ts` 声明式 EffectIR。 +> 重写说明: archive 草稿仅列出 RFC 能力值表;本 ADR 把每个能力维度的取值**映射到 TE 可证明的证据面**,并钉住 0.3-C 每个维度的默认档。 + +## Decision + +正交能力维度(RFC §15,取值映射到 TE 证据): + +| Capability | 0.3-C 取值 | TE 证据面(可证明性) | +|---|---|---| +| resolution | `contained` \| `unbound` | **contained** = PathRef 白名单 + canonical prefix 解析(`normalizeCanonicalPrefix`、lease key 重叠检测);unbound = 无 PathRef 约束(0.3-C **不提供**) | +| mutationMediation | `none` \| `brokered` | **brokered** = 声明式 fs 写入必须走 `resources/file-transaction.ts` 资源事务(snapshot → lease → intent/permit → stage → Commit\|Restore+Reject),resources/* 是唯一提交权威;none = 无中介(0.3-C 对已声明写入**不提供**) | +| processIsolation | `none` \| `sandboxed` | 由 HostProbe 分类映射:`sandboxed-single-root`/`sandboxed-multi-root` → sandboxed;`resolve-only` → **none**;`unsupported` → 拒绝执行(fail closed)。**无基线证据时只能 resolve-only**(TE 当前现实) | +| revocation | `admission-only` \| `per-mutation` \| `{mode:"bounded-latency", maxLatencyMs}` | **admission-only** = capabilityBindingId 准入期绑定(`ScopedContentEvidence.capabilityBindingId`),披露时 live re-auth(P12);per-mutation / bounded-latency 0.3-C 不做(wire 保留取值,实施在 future ADR) | + +### Fail-closed 规则(D11) + +- 任何维度无法给出已证明取值 → 该维度取最弱档 + 对应能力拒绝(例如 HostProbe `unsupported` → 拒绝执行,绝不下放到"裸 shell + 无证据")。 +- `unsupported sandbox → fail closed`:archive 草稿的 D11 保留。 +- Receipt.assurance.enforcement 记录**承诺**;使用 bounded-latency 时记录 promise vs observation(`observedRevocationLatencyMs` 可选字段)。 + +### 0.3-C 默认能力包(wire 冻结值) + +```text +{ resolution: "contained", mutationMediation: "brokered", + processIsolation: , revocation: "admission-only" } +``` + +- processIsolation 由 HostProbe 证据派生:有已批准 sandbox 基线 → sandboxed;否则 resolve-only → none。 +- 任何 flow 想获得 `unbound`/`per-mutation` 能力 → `TF_FEATURE_REQUIRED`(不是静默降级)。 + +## Wire impact (TypeBox) + +- `EnforcementCapabilitiesSchema`(新增): 四维正交字段,processIsolation 用 StringEnum(`sandboxed`|`none`) + provenance 字段(baselinePolicyId / hostProbeSha256)。 +- `BoundPlan.enforcementCapabilities` 为必填(无洞)。 +- 与 `resources/baseline.ts` HostSupportCell 一一对应:`classification` → processIsolation;`backendId/backendCapabilityVersion` → 证据指纹。 + +## Status + +Proposed(重写)for 0.3-C wire freeze — archive 版 Accepted 于历史 0.3.0;本版把能力值绑定到 TE 证据面,维度取值与默认档需随 wire freeze 批准。 diff --git a/docs/internal/p-adrs/P9-legacy-conflict.md b/docs/internal/p-adrs/P9-legacy-conflict.md new file mode 100644 index 00000000..deb028e6 --- /dev/null +++ b/docs/internal/p-adrs/P9-legacy-conflict.md @@ -0,0 +1,31 @@ +# P9: legacy-conflict(0.2 写者冲突) + +> Status: **Accepted** (0.3-C wire-freeze gate) +> Normative parent: [RFC v7.6 §20](../rfc-0.3.0-control-plane-v7.6.md)(D20) +> TE 基底: TE 已并入 0.2.10 runtime(单一运行时),0.3-C 控制面与之共存于同一项目;冲突面 = 0.2 写者对 flow 存储的并发写入。 + +## Decision + +当 0.3 control 激活期间检测到同一项目 flow 存储上的 **0.2 写者**: + +- 0.3 **停止新的 Attempts**(`legacy-conflict` 状态,`TF_LEGACY_CONFLICT`)。 +- 既有 0.3 journal 对 0.3 runs 保持权威。 +- **Dual-write(D20)**: 0.3 只能停自己;**不能**杀死外来 0.2 写者。 + +### 语义 + +| 情形 | 行为 | +|------|------| +| 0.2 writer 与 0.3 control 同时活跃 | 0.3 侧新 Attempt 拒绝(TF_LEGACY_CONFLICT, recoveryAction=operator) | +| 0.2 writer 停止 | 0.3 恢复 admit(operator 确认后) | +| 0.3 journal 与 0.2 历史并存 | 0.3 runs 以 0.3 journal 为准;0.2 历史仅以 `LegacyEvidenceImported` 导入 | + +- 导入语义:0.2 历史只作为 evidence import,**不**合并进 0.3 journal 的权威叙事。 + +## Wire impact (TypeBox) + +- 错误码 `TF_LEGACY_CONFLICT` 入错误信封(P4);RunStatus 不新增值(保持 blocked/unknown + needs-operator 标志)。 + +## Status + +Accepted for 0.3-C wire freeze。 diff --git a/docs/internal/p-adrs/README.md b/docs/internal/p-adrs/README.md new file mode 100644 index 00000000..c0fd9a64 --- /dev/null +++ b/docs/internal/p-adrs/README.md @@ -0,0 +1,44 @@ +# 0.3-C Protocol ADRs (P1–P16) — wire-freeze gate + +> **Branch:** `rc/0.3.0-trusted-effects` +> **Status:** S1 交付物 — 全部 P-ADR 为 0.3-C TypeBox wire freeze 的前置门槛(RFC §22 步骤 2.5)。 +> **Normative parent:** [RFC v7.6 快照](../rfc-0.3.0-control-plane-v7.6.md)(archive `origin/backup/0.3-archive/mac-feat-0.3.0-control-plane`,只读蓝本) +> **TE 基底:** 本组 ADR 以当前 Trusted Effects 架构为执行权威层(`packages/taskflow-core/src/resources/*`),Control Plane 概念叠加其上。**抄概念,不搬代码。** + +| ID | 标题 | 文件 | Status | +|----|------|------|--------| +| P1 | Policy overlay(策略叠加) | [P1-policy-overlay.md](./P1-policy-overlay.md) | Accepted | +| P2 | Empty-policy exposure(空策略暴露面) | [P2-empty-policy-exposure.md](./P2-empty-policy-exposure.md) | Accepted | +| P3 | Domain + Registry rebuild + clone/worktree identity | [P3-domain-registry-identity.md](./P3-domain-registry-identity.md) | Accepted | +| P4 | Negotiation + errors + recoveryAction | [P4-negotiation-errors.md](./P4-negotiation-errors.md) | Accepted | +| P5 | Phase × feature + RunStatus/Stage 矩阵 | [P5-phase-feature-matrix.md](./P5-phase-feature-matrix.md) | Accepted | +| P6 | Canonical hash + ArtifactRef + SecretRef | [P6-canonical-hash-refs.md](./P6-canonical-hash-refs.md) | Accepted | +| P7 | Dynamic paths + dual hashes + cache | [P7-dynamic-paths-dual-hashes.md](./P7-dynamic-paths-dual-hashes.md) | Accepted | +| P8 | Enforcement capabilities(TE 基底重写) | [P8-enforcement-capabilities.md](./P8-enforcement-capabilities.md) | **Proposed** | +| P9 | legacy-conflict | [P9-legacy-conflict.md](./P9-legacy-conflict.md) | Accepted | +| P10 | Rollback tiers | [P10-rollback-tiers.md](./P10-rollback-tiers.md) | Accepted | +| P11 | Compaction + cursor + minAvailableCommitSeq | [P11-compaction-cursor.md](./P11-compaction-cursor.md) | Accepted | +| P12 | Command batch + re-auth disclosure | [P12-command-batch-reauth.md](./P12-command-batch-reauth.md) | Accepted | +| P13 | Bootstrap / fresh-install / singleton lock / platforms | [P13-bootstrap-singleton.md](./P13-bootstrap-singleton.md) | Accepted | +| P14 | **ControlStore engine(TE 基底重写)** | [P14-controlstore-engine.md](./P14-controlstore-engine.md) | **Proposed** | +| P15 | **Approval protocol(TE 基底重写)** | [P15-approval-protocol.md](./P15-approval-protocol.md) | **Proposed** | +| P16 | UserCoordinatorStore concurrency + release | [P16-coordinator-concurrency.md](./P16-coordinator-concurrency.md) | Accepted | + +## 门槛规则(RFC §22,统一无可选洞) + +- **P1–P16 全部 required,wire freeze 之前必须齐全。** 不允许"可选洞"式跳过。 +- 文件存储仍是 engine(P14):fsync、atomic batch、locks、recovery、compaction。 +- **P16 不可折叠进 P13。** +- Status 语义:`Accepted` = 决策直接钉住已批准的 RFC v7.6 架构决策,无重定标;`Proposed` = 以 TE 架构为基底**重定标**的决策,需随 wire freeze 一起批准。 +- P8/P14/P15 的 archive 旧草稿(v7.6 wire-freeze gate 版本)已按 TE 架构重写,见各文件 "TE 基底" 节。 + +## 与 archive 草稿的关系 + +archive `backup/0.3-archive/mac-feat-0.3.0-control-plane` 下已有全部 16 个草稿(P1–P16 + P16-1R 补充)。 +0.3-C 以 archive 草稿 + RFC v7.6 为输入,**在当前 TE 架构上概念重写**:TE 的 `resources/*` 是 execution authority(唯一提交权威),Control Plane 概念(ControlDomain/ControlStore/CommandRecord/Approval/Receipt/UserCoordinatorStore)叠加为新的控制面;archive 的实现细节(路径、锁协议、schema 版本)不作为 0.3-C 的已批准行为,除非本组 ADR 显式采纳。 + +## 实施补充(非 wire-freeze 门槛) + +| 父 | 主题 | 状态 | +|----|------|------| +| P16 | 版本化 first-dispatch-owner saga(archive P16-1R) | 0.3-C 不在 S1 采纳;S4 运行控制阶段重新推导 | diff --git a/docs/internal/pr-0.3.0-trusted-effects.md b/docs/internal/pr-0.3.0-trusted-effects.md new file mode 100644 index 00000000..8afc4187 --- /dev/null +++ b/docs/internal/pr-0.3.0-trusted-effects.md @@ -0,0 +1,78 @@ +# rc: 0.3.0 Trusted Effects beta.1 + +> Draft PR body for `0.3.0-beta.1`. The beta uses npm dist-tag `beta` and is not GA. +> This file is the source for the GitHub PR description. + +## Base / Head + +| | | +|---|---| +| Base | `main` @ `8fc6981` — current PR base | +| Head | `rc/0.3.0-trusted-effects` @ `f5284da` (23 commits ahead of main) | +| Status | **DRAFT — beta release preparation** | +| Post-ADV harden | `cbb4131` discovery/file-tx + `580daa0` S-H2; CI 31613909075 green | + +This PR supersedes the historical Draft **PR #117** (`head: codex/0.3.0-trusted-effects-candidate`). The `rc/0.3.0-trusted-effects` branch is the canonical candidate branch and its tip `f5284da` is the exact head this PR is built from (Draft PR #122 — this PR). + +## Summary + +- **Trusted Effects MVP** (`packages/taskflow-core/src/effects/`): + - EffectIR (`EFFECT_KINDS`), PathRef reuse, SecretRef/ServiceRef (type-only fail-closed) + - closed TypeBox EffectIR + confidentiality/integrity source-to-sink validation + - resource-controlled FS transaction: durable snapshot → persistent lease → journal intent/permit → stage → `Commit` or `Restore+Reject` + - declaration-only bridge in `effects/runtime-apply.ts`; no second changeset/gateway authority + - ledger-backed `whyAuthorized` / `whyContext` / `whyEffect` +- Optional phase `effects[]`; FlowIR translate/compile/hash include effects +- Built-in `detectEffectsIssues` (category `effects`) + `effectsLintVerifier` +- Every imperative phase fast path finalizes declared `fs.write` through the resource transaction; event-kernel-enabled runs use the same safe imperative path +- Honest host baseline: `conformance/workspace/host-support-baseline.json` +- Docs: `docs/internal/0.3.0-trusted-effects-mvp.md`, `0.3.0-agent-goal.md`, `0.3.0-ga-scoreboard.md` +- Example: `examples/trusted-effects-write.json` +- Tests: `test/effects*.test.ts`, `test/verify-effects.test.ts` +- **Store hardening:** project `.pi` discovery hardened against tmp and home roots (`96a32e8`) +- **Windows CI fix:** child-process store import specifiers portable via `pathToFileURL`/`fileURLToPath` (`e7c5e31`; test-harness only, no product change) — windows-latest store/process-supervisor jobs genuinely exercise saveRun/lock semantics + +## Fixed + +- Resource-bearing inline/saved/expanded/`ctx_spawn` children can no longer be skipped by parent cache or resume reuse. +- Information-flow labels compose across nested flow boundaries; unresolved dynamic definitions remain tainted, malformed non-array `effects` fail admission/compile, and `why-effect` follows DAG dependencies. +- Durable commit/abort results survive staging/lease cleanup faults, activation double faults release leases, and clean-terminal/aged-orphan before-images are garbage-collected. + +## Scope boundary + +This candidate guarantees **admitted declared filesystem write targets**. It does not claim a full FileBroker/native OS sandbox and cannot prevent or roll back writes to undeclared paths under resolve-only execution. SecretRef/ServiceRef have **no** vault/network backends in this cut (fail-closed, unsupported). Historical Control Plane (`feat/0.3.0`) is **not** this release definition. + +## Evidence (honest) + +| Gate | Status | Notes | +|---|---|---| +| L1 local | **PASS** | Current candidate has local typecheck/test/build/pack evidence; `pnpm audit --prod` remains a separate local gate | +| L2 contract | **PASS** | Exact-SHA CI run 31613909075: Node 22/24 tests, full build, packed consumer (10 packages) and MCP E2E all pass | +| L3 browser/electron | N/A | — | +| L4 real-environment | **PASS (built-MCP fixture)** | CI exercises the built MCP artifact and checked-in no-LLM Trusted Effects fixture. Live Codex evidence is historical to the current tip and is not being overclaimed. | +| L5 released | **FAIL** | no `v0.3.0-beta.1` tag/publish — human gate | +| L6 ga | **FAIL** | L5 missing — **NOT GA** | + +Exact-SHA remote CI is **GREEN**: GitHub Actions run 31613909075 on this PR's head `f5284da` passed the full matrix (Node 22/24 tests, built MCP E2E, build, packed consumer, website export, process supervisor Ubuntu/macOS/Windows, CodeQL). + +## Commits (rc/0.3.0-trusted-effects vs main, newest first) + +``` +e7c5e31 fix(test): make child-process store imports portable on Windows +0f9cf16 docs: sync 0.3 draft PR body to rc tip d532f69 +d532f69 docs: raise scoreboard to L4 built-MCP fixture for rc/96a32e8 +59fd9c3 docs(skills): teach 0.3 Trusted Effects authoring surface +7add160 docs: add draft PR body for 0.3 trusted-effects RC +0834c59 docs: refresh 0.3 scoreboard for rc/96a32e8 +96a32e8 fix(store): harden project .pi discovery against tmp and home roots +6071fb2 docs: refresh trusted effects candidate evidence +467bc28 fix(effects): close composition and transaction gaps +5529ae1 docs: record remote candidate CI evidence +f2cee92 docs: record clean 0.3 candidate evidence +e6efcd4 feat(effects): add resource-controlled trusted writes +``` + +## Release state + +- CHANGELOG: `## [0.3.0-beta.1] — 2026-08-13` — beta prerelease. +- **Beta not yet published; not GA.** Tag and npm publication remain human-authorized release gates. diff --git a/docs/internal/reviews/0.3-adv-r1-contracts.md b/docs/internal/reviews/0.3-adv-r1-contracts.md new file mode 100644 index 00000000..3c61094e --- /dev/null +++ b/docs/internal/reviews/0.3-adv-r1-contracts.md @@ -0,0 +1,36 @@ +# ADV-R1 — Trusted Effects contracts review (0.3 rc) + +Lane: contracts — runtime cache vs flow.def/expand/use/ctx_spawn+shareContext; validate.ts nested labels; why.ts lookup; malformed effects fail-closed; schema/runtime mismatch. +Tip reviewed: `d3b2878` (origin/rc/0.3.0-trusted-effects). CI green (31460495091). Draft PR #122. +Method: adversarial read of `runtime.ts`, `effects/{validate,why,runtime-apply,schema,types}.ts`, `verifiers/effects-lint.ts`, `schema.ts`, `flowir/{translate,compile,canonical-hash}.ts` + live probes (node --conditions=development --experimental-strip-types) + focused suites. + +## Findings + +| ID | Sev | Location | Scenario | Fix / Defer | +|----|-----|----------|----------|-------------| +| F1 | **High** | `schema.ts:1539`; `verifiers/effects-lint.ts:40`; `flowir/translate.ts:188`; `flowir/compile.ts:256` vs `runtime.ts:4395-4407` | Static gates call `validateComposedEffectFlow` **without a flow loader**, so every `flow{use: }` child degrades to the unknown-boundary summary (secret/untrusted source + public/verified sink). Any downstream declared sink (default `fs.write` internal/project) then fails with `confidentiality-flow-violation` + `integrity-flow-violation` — even when the saved child declares **no effects at all**. Confirmed by probe: benign `use` child (no effects) + downstream write → `validateTaskflow ok=false` and `verifyTaskflow ok=false`, while the runtime with `loadFlow` resolves the child and **executes the flow successfully** (write committed, second cross-run run re-executes, no cacheHit). pi `run` (`pi-taskflow/src/index.ts:1407`) and MCP `taskflow_run` (`taskflow-mcp-core/src/mcp/server.ts:866`) validate before execution → the flagship saved-subflow + write composition **cannot run at all** through the adapters. Real violations are still caught pre-body at runtime (`effects-agent-te.test.ts:275`, calls=0) — this is over-rejection, not under-enforcement. | Fix: thread an optional `resolveFlow` into the static gates when a loader exists; when absent, **downgrade unresolvable-`use` unknown-boundary label issues to warnings** (runtime admission with loader stays the authoritative fail-closed gate). Defer (needs API + tests). | +| F2 | Medium | `effects/why.ts:163-189` (`collectDeclaredEffects`) | `whyEffectFromFlow` scans only top-level `flow.phases[].effects`; it never recurses into `flow{def}` / `flow{use}` / `expand` children. Probe: `whyEffectFromFlow({flow: , effectId:"w", phaseId:"child"})` → `"No declared effects on run ... (flow has empty effects[])"`. The PR claim "ledger-backed why-* / why-effect follows DAG dependencies" does not hold for the most common declaration site (nested children); MCP/pi `why_effect` cannot explain a nested write from the top-level run def. | Fix: recurse `collectDeclaredEffects` into def/use children with the existing `seenUses` cycle guard; or document that why_effect takes the child def. Defer. | +| F3 | Low | `runtime.ts:827-828` (`flowTreeUsesDeclaredEffects`) | `contextSharing` / any `shareContext` (and even an **empty** `effects: []` via the `!== undefined` test at 828) marks the whole flow as effects-bearing: run-wide cache disabled, `state.cwdRootBinding` recorded (permanent invocation-root binding → resume from another dir fails with "invocation root does not match"), and a resolve-only workspace session is created. Probe: pure `contextSharing: true` script flow (no effects, no spawns) → `cwdRootBinding=true`, cross-run `cacheHit=undefined`. Rationale (ctx_spawn may spawn resource-bearing subflows) is sound, but the blast radius covers pure ctx_read/ctx_write flows that never spawn or write. | Narrow the trigger (only spawn-capable contexts; treat empty `effects[]` as no-op) or document the coupling on `shareContext`. Defer. | +| F4 | Low | `effects/validate.ts:519` (`prefix &&` guard); `runtime.ts:1187-1199` | Malformed **top-level** effects bag (`effects: "not-an-array"`) passes the runtime's pre-execution flow gate: `validateComposedEffectFlow` at top level skips per-phase IR validation (`directPhaseSummary` silently drops non-arrays). The phase then fails at admission (`effects-not-array`) after upstream phases may have spent tokens. `verify`/`compile` do catch it (per-phase `validateEffectIR`). Fail-closed eventually; no security hole, wasted spend. | Run per-phase IR validation unconditionally in the compose path (drop `prefix &&`). Defer. | +| F5 | Low | `effects/runtime-apply.ts:88-96` vs `verifiers/effects-lint.ts:28-65` | `fs.delete` (and `secret.read`/`service.call`) are valid EffectIR kinds → pass `verifyTaskflow`/compile — but `validateDeclaredEffectsBeforeAdmission` rejects any non-`fs.write` kind ("no bound resource backend in the 0.3 fs.write slice"). Runtime fails closed (safe), but static preflight advertises OK → confusing pre-run. | Document kinds without backends as advisory at static gates. Defer. | + +## OK coverage (verified green) + +- `effects-composition-cache.test.ts` (4): `flow.def` cross-run parent cache cannot skip a resource-bearing child; `expand.def` within-run resume re-enters the child; dynamic `flow.def` permanently binds the invocation root; `ctx_spawn`+`shareContext` cannot cache away a spawned writer. +- `effects-gateway-bypass.test.ts` (8): declared-write commit via resource intent; direct final-path write → `declared-path-bypass` + exact pre-state restore; workspace parent symlink escape rejected pre-body (`TFWS_PATH_ESCAPE`); multi-write content-map rejection; event-kernel flag uses the same transaction; typed dynamic PathRef resolves once at admission; isolated `cwd:"temp"` escape rejected pre-body; overlapping cross-run runs rejected on lease. +- `verify-effects.test.ts` (7): overlap error, unknown kind error, valid ok, no-op without effects, no false edge between independent phases, dependency-connected edge enforcement, flow.def/expand boundary enforcement, dynamic taint in both directions. +- `effects-trusted.test.ts` (22+): closed EffectIR schema, secret-material rejection, labels through effect-free intermediates, FlowIR translate/hash content-addresses effects, non-array effects fail closed at compile, structured whyEffect. +- `effects-agent-te.test.ts` (8): agent bypass fail-closed, gate fast path authority, illegal label-flow fails pre-body, **saved-child public sink rejected pre-body (calls=0)**, malformed non-array pre-body, unbound kind pre-body. +- Full focused suite: **66/66 pass**; `tsc --noEmit` (taskflow-core) clean at d3b2878. + +## Residual risk + +- **F1 blocks a documented composition end-to-end** (saved sub-flow + declared write) through pi/MCP; only `def`-inline children compose correctly today (probe flow D passes static + runtime). +- Undeclared writes stay outside the transaction (documented scope boundary): resolve-only sessions do not sandbox; a body writing an *undeclared* path is only caught if it also touches a *declared* path (content-id based detection at commit). +- External-process races on declared paths are detected + restored fail-closed via content-id guards, not fenced (documented; cooperative Taskflow writers are excluded by the persistent lease). +- Nested `use` reuse: sibling diamonds resolve fine (probe passed); the `seenUses` guard only fires on genuine recursion cycles (runtime rejects those anyway) — no false-taint path found. +- Non-idempotent child inside a cacheable `flow` parent is silently skipped on resume (no re-fire, no double-fire warning) — inconsistent with top-level non-idempotent semantics (pre-existing, not effects-specific). + +## Ready recommendation + +**NOT READY for GA.** F1 (High) must be fixed or explicitly scoped out before release: static gates reject legal saved-use + write compositions while the runtime accepts them — a schema/runtime mismatch that blocks the flagship composition through every adapter. F2 should be fixed or documented before the ledger-backed why-* surface is advertised. F3/F4/F5 are Low and fail-closed; schedule as follow-ups. The cache-vs-effects guarantees claimed in the PR body (def/expand/use/ctx_spawn children cannot be skipped by parent cache or resume reuse) are otherwise verified. diff --git a/docs/internal/reviews/0.3-adv-r3-security.md b/docs/internal/reviews/0.3-adv-r3-security.md new file mode 100644 index 00000000..7ef7979f --- /dev/null +++ b/docs/internal/reviews/0.3-adv-r3-security.md @@ -0,0 +1,53 @@ +# Adversarial Review R3 — Security lane: PathRef escape / undeclared-write honesty / SecretRef·ServiceRef fail-closed / store `.pi` discovery / MCP resource-authority + +> Lane: ADV-R3 Security (kanban t_87ab6675). Reviewer: coder profile. +> Target: `rc/0.3.0-trusted-effects` @ `d3b2878` (product candidate `96a32e8`; PR #122 DRAFT). +> Method: source audit of `resolve.ts` / `sandbox.ts` / `store.ts` / `file-transaction.ts` / +> `execution.ts` / `effects/*` / `verifiers/discover.ts` / `agents.ts` / `taskflow-mcp-core/src/mcp/server.ts`, +> plus live probes for the two discovery-walk findings (symlinked `.pi` redirect; verifier walk past home/temp). +> Focus areas per card: PathRef `..`/symlink/win32 case-fold; undeclared-write non-claims; +> SecretRef/ServiceRef fail-closed; store `.pi` discovery bypass; MCP bypass of resource authority. + +## Verdict + +**No Blocker. 1 High, 1 Medium, 1 Low.** The PathRef escape surface, undeclared-write +non-claims, SecretRef/ServiceRef fail-closed behavior, and MCP defineFile/reconcile +authority are all verified solid. The High and Medium are **discovery-walk boundary bugs**: +two of the three `.pi` convention-dir walkers (`verifiers/discover.ts`, and the store's +symlink-candidate handling) do not enforce the home/temp-root boundary that the store walk +was hardened for (commit `96a32e8`), and one of them **dynamically imports and executes** +the discovered files from the MCP server process. + +## Findings + +| ID | Sev | Location | Scenario | Fix / Defer | +|----|-----|----------|----------|-------------| +| R3-F1 | **HIGH** | `packages/taskflow-core/src/verifiers/discover.ts:27-40` (`findProjectVerifiersDir`) + `113-149` (`discoverVerifiers` → `await import("file://…")`), reached by MCP `taskflow_lint` / `taskflow_plan` (`taskflow-mcp-core/src/mcp/server.ts:1403,1435`) | The verifier walk-up has **no home-dir stop, no OS-temp-root stop, and no cwd canonicalization**: it walks `cwd → … → /` checking `.pi/taskflows/verifiers` at every level (it only skips the check at `dir === home` itself, then **continues to home's parent and the filesystem root**). Any attacker-writable ancestor — `/tmp/.pi/taskflows/verifiers/evil.js` when cwd is under `/tmp` (the norm for agent sandboxes), `/.pi/taskflows/verifiers/`, or a symlinked `.pi` in any ancestor — is discovered, **dynamically imported, and its module code executes in the taskflow/MCP server process with the invoking user's privileges**. Contrast: the store walk was explicitly hardened to stop at home + temp root with canonical paths (`store.ts:1033-1052`, tests "stops at home dir", "stops at the OS temp root", "canonicalizes relative and symlink aliases" — `store.test.ts:812-929`); verifier discovery never received the same boundary. Probe-proven: planted `evil.js` at `//.pi/taskflows/verifiers/` and `listVerifierPaths()` returned it. | Fix (recommended, small): reuse the store's canonicalization + boundary logic — canonicalize cwd (`canonicalDiscoveryPath`), stop the walk when `sameDiscoveryPath(dir, home) || sameDiscoveryPath(dir, tempRoot)`, and require the discovered dir to stay within the walked project tree (or at least reject home/temp-root `.pi`). Mirror the same fix in `agents.ts findNearestProjectAgentsDir` (R3-F3). Deferable only if `.pi/taskflows/verifiers/` is documented as "any ancestor is trusted" — it is not; the store lane explicitly treats ancestor `.pi` as untrusted. | +| R3-F2 | **MED** | `packages/taskflow-core/src/store.ts:1044-1046` (`findProjectFlowsDirInternal`) — candidate `.pi` accepted via `fs.existsSync` without canonicalization; same for `sidecarPathFor`/`saveFlow` (`:1153,1181`) | The walk canonicalizes **cwd/home/tempRoot** but returns `path.join(candidate, "taskflows")` for whatever `.pi` entry exists at the checked level — a **symlinked `.pi`** inside the walked tree redirects project-scope flow storage to an arbitrary target. Probe-proven: project with `.pi → ~/.pi` (fake home): `listFlows(proj)` listed a flow seeded in **home's** `.pi/taskflows` as `scope:"project"`, and `saveFlow(proj, …, "project")` **wrote the flow JSON into the home `.pi/taskflows`** — i.e. a checked-out repo can (a) read flows from a location the repo author/attacker controls (updated after clone), which `taskflow_run name=X` then executes — `script` phases are arbitrary shell — and (b) write flow files outside the project tree. This defeats the exact boundary the code comment claims ("**Never inherit `~/.pi/` or the shared OS temp root's `.pi/` while walking ancestors**"). The existing tests only cover symlink aliases of the **cwd**, not a symlinked **`.pi` candidate**. | Fix (recommended): `canonicalDiscoveryPath(candidate)` before accepting, and require the real path to remain within the canonical walked tree (or at least not equal home/temp-root `.pi`); reject symlinked `.pi` for create/save. Add a store test: `.pi` symlink → discovery returns the project's own `.pi` (or null), never the target's flows. | +| R3-F3 | LOW | `packages/taskflow-core/src/agents.ts:266-275` (`findNearestProjectAgentsDir`) | Same unbounded walk-up as R3-F1 (to filesystem root, no home/temp stop, no canonicalization) for `.pi/agents/*.md`. Impact is config/prompt injection rather than code execution (agent definitions override systemPrompt/model roles for subagents), but an attacker who can plant `.pi/agents/evil.md` in a walk-up ancestor injects prompts into every agent run below it. | Fix: same boundary helper as R3-F1 (shared `canonicalDiscoveryPath` + home/temp stop). Deferable to the same hardening pass. | + +**Severity counts: 0 Blocker, 1 High (R3-F1), 1 Medium (R3-F2), 1 Low (R3-F3).** + +## OK coverage (verified by source audit + probes) + +- **PathRef `..` / symlink escape / win32 case-fold (`resolve.ts:221-312`, `schema.ts:176-220`, `cwd-bridge.ts:73-160`):** + `normalizePortableRelativePath` rejects `..` segments, `\`, absolute/drive/UNC/device paths, NUL and control chars, and per-segment `.`/`..`, trailing dot/space, Windows reserved names, `<>:"|?*`. `resolvePhysicalTarget` realpaths the **root** and the **target** (existing) or the **nearest existing ancestor** (create) and enforces `isWithin(rootReal, …)` both lexically and physically — symlinked ancestors and targets that resolve outside are rejected (probe + `effects-gateway-bypass.test.ts:106-141`: `TFWS_PATH_ESCAPE`, body never invoked, no file written outside). Win32 case-fold cannot escape: `path.win32.relative` is case-insensitive for the prefix (verified `C:\Work\Sub` vs `c:\work\sub\file` → `"file"`), so a case-differing path is treated as *within* — the safe direction on a case-insensitive FS — and `sameDiscoveryPath` lowercases on win32 (`store.ts:1028-1031`). `resolvePermittedDefineFile` (MCP defineFile) realpaths and contains to cwd/tmpdir — win32-safe. +- **Undeclared-write non-claims (honesty):** scoreboard G5 explicitly states "Resolve-only execution does not prevent or restore writes to undeclared paths; that requires a future FileBroker/native sandbox and is not a 0.3 MVP claim"; `skills-src/taskflow/core.md:566-569` "What this is NOT — No FileBroker sandbox … Undeclared paths are not protected … `secret.read`/`service.call` are type-only fail-closed"; `execution.ts:1-11` header explicitly disclaims an OS filesystem sandbox/race-free FileBroker and `execution.ts:648-652` documents the residual external TOCTOU; MCP `taskflow_reconcile_workspace` description says "does not restore files or prove correctness" and is gated by `TASKFLOW_WORKSPACE_RECONCILE_MODE=explicit` + exact acknowledgement string (`execution.ts:392-400`). No overclaim found. +- **SecretRef/ServiceRef fail-closed (`effects/validate.ts:126-155,300-322`, `effects/schema.ts`):** `validateSecretRef` errors on missing/empty `secretId` **and** on any material field (`value`/`material`/`token`); `validateServiceRef` errors on missing/empty `serviceId`; target-kind mismatches are errors; the closed TypeBox `EffectDeclSchema` (`additionalProperties:false`) rejects unknown fields at shape level. Runtime admission (`runtime-apply.ts:75-98`) rejects any kind other than `fs.write` **before body execution** (`unsupported-effect-kind`), so `secret.read`/`service.call` can never reach a body in the 0.3 slice. `why-effect` derives authorization from the durable intent ledger only (no intent → `allowed:false`, `why.ts:331-353`) — fail-closed explainer. +- **MCP resource authority:** `taskflow_run` does not hand the client a path-writing handle; effects flows auto-create the resolve-only session inside the runtime (`runtime.ts:4449-4464`), `bindPhase` validates invocation-root identity + registry authority (`execution.ts:378-390`), and declared `fs.write` targets must re-resolve through `resolvePathRef` with `read-write` capability and correct intents (`execution.ts:527-548`); background/detached runs go through the same `executeTaskflow` session-creation path. `taskflow_save`/`show`/`search` only touch the store's flow dirs — which is exactly where R3-F2 applies. +- **File transaction commit discipline (`file-transaction.ts:474-531`):** payload ids must exactly match admitted effects; `assertExactPreState` before staging, `assertExactPostState` in the journal pre-commit guard; declared-path bypass → restore + `aborted-restored` (probe `effects-gateway-bypass.test.ts:73-104`). + +## Residual risk + +1. **R3-F1** is the only finding with direct arbitrary-code-execution reachability; until fixed, hosts running MCP `taskflow_lint`/`taskflow_plan` from an untrusted shared dir (notably `/tmp`, symlinked ancestors, or any dir whose walk-up passes an attacker-writable ancestor) execute attacker-provided verifier modules. R3-F2 is the same class one level down (flow definitions, not modules) and also affects `saveFlow`. +2. **External TOCTOU** between path resolution and filesystem write remains (documented non-claim, `execution.ts:648-652`) — requires a native file broker; not a 0.3 claim. +3. R2-lane MEDs (`file-transaction.ts:603-606` deferred-lease callback throw; `recoverResourceFileIntent` no drain hook) remain as previously reported (`adversarial-review-r2-resources-verdict.md`). + +## Ready recommendation + +**HOLD for merge/Ready on the R3-High.** The PathRef, sandbox-honesty, SecretRef/ServiceRef, +and MCP-authority claims verified clean; but `verifiers/discover.ts` should not ship with a +walk-up that executes `.ts`/`.js` from unbounded ancestor directories while the store lane +explicitly treats those same directories as untrusted. Recommend a small hardening commit +(R3-F1 + R3-F2 + R3-F3 share one boundary helper; each fix is ~5-15 lines + tests) before +"Ready". No merge/tag/npm performed (rules). diff --git a/docs/internal/reviews/0.3-adv-r4-honesty.md b/docs/internal/reviews/0.3-adv-r4-honesty.md new file mode 100644 index 00000000..15e31567 --- /dev/null +++ b/docs/internal/reviews/0.3-adv-r4-honesty.md @@ -0,0 +1,46 @@ +# ADV-R4 — Honesty: scoreboard / PR body / CHANGELOG / skills vs CI proof + +**Lane:** R4 (honesty lane) · **Reviewer:** coder · **Date:** 2026-08-11 +**Target:** `rc/0.3.0-trusted-effects` @ `d3b2878` (origin), Draft PR #122 +**Method:** adversarial claim-vs-evidence — every user-facing/CI claim in the scoreboard, PR body, release plan, CHANGELOG, skills, and host baseline was re-derived from the repo, the local test run at tip, the RC pipeline logs, and GitHub Actions run data (`gh run view`). + +## Verdict + +**No Blocker, no High in this lane.** The 0.3 candidate documents are substantially honest: L5/L6 stay FAIL, "NOT GA" is repeated, the live-Codex-CLI leg of L4 is disclosed as open, the host baseline makes no FileBroker claim, and the skills teach an explicit non-claims list. All findings are **Low, docs-only, deferable** — they are currency/precision slips in the evidence ledger itself, not overclaims. + +## Findings + +| ID | Sev | Location | Scenario | Fix / Defer | +|----|-----|----------|----------|-------------| +| R4-F1 | Low | `docs/internal/0.3.0-ga-scoreboard.md:5-8,24` | Scoreboard calls `e7c5e31` "the tip" and cites only run 31460133496 (@ e7c5e31) as "current evidence". The branch tip and the PR head are actually `d3b2878` (the scoreboard-refresh commit itself), and a second green exact-SHA run 31460495091 @ d3b2878 exists (verified: 10/10 jobs success). No overclaim (product code unchanged between the two SHAs), but the authoritative ledger is one run stale and its tip/head strings lag reality. | Fix (docs-only): cite 31460495091 as the branch-tip run, keep 31460133496 as the product-code-SHA run; correct "(tip e7c5e31…)" to "(tip d3b2878; product candidate e7c5e31)". Deferable. | +| R4-F2 | Low | `docs/internal/pr-0.3.0-trusted-effects.md:11,14,60` + **live PR #122 description** | File and live description say "Head @ `e7c5e31` (12 commits ahead of main)"; the actual PR head is `d3b2878` (13 commits; `gh pr view 122` → headRefOid d3b2878…). Commit list omits `d3b2878`. Description was synced at 0f9cf16 and not re-synced after the tip moved. Honest, stale. | Fix (docs-only): re-sync the PR body file and `gh pr edit 122 --body-file`. Deferable. | +| R4-F3 | Low | `docs/internal/0.3.0-release-plan.md:8-10,27` | Same staleness: "Code candidate `e7c5e31`", "PR head `e7c5e31`". The plan itself flags the condition ("If the rc tip moves again, re-verify the PR head before relying on this") — the tip did move to `d3b2878` after the plan was written. | Fix (docs-only): bump head references. Deferable. | +| R4-F4 | Low | `AGENTS.md:155` | "MCP roster **(19)**" and the enumerated list omit `taskflow_why_effect`. The MCP server exposes **20** tools (`packages/taskflow-mcp-core/src/mcp/server.ts:653`), and `README.md:185`, both marketplace JSONs, and the skills-build test (`skills-build.test.ts:34`) all say 20. AGENTS.md is the only stale roster (last touched at 0.2.7). | Fix (docs-only): 19→20 + add `taskflow_why_effect`. Deferable. | +| R4-F5 | Low | `docs/internal/0.3.0-ga-scoreboard.md:97` | "`pnpm audit --prod` at exact SHA — RC pipeline log at 6071fb2 **(no prod-code change since)**" — there WERE product-code changes after 6071fb2 (`467bc28 fix(effects)`, `96a32e8 fix(store)`). What actually didn't change is the **dependency graph** (verified: no `package.json`/`pnpm-lock.yaml` diff between 6071fb2 and HEAD), so the audit evidence remains valid — but the phrasing is loose for an honesty ledger. | Fix (docs-only): reword to "no dependency change since". Deferable. | + +## OK coverage (independently re-verified at tip `d3b2878`) + +- **Exact-SHA CI, both runs green:** 31460495091 @ d3b2878 (10/10 jobs: process-supervisor ubuntu/macos/windows, test node 22/24, e2e codex MCP network-free incl. `test:e2e-codex-mcp-full`, build dist, packed consumer 9 pkgs + CharterArc, website export, CodeQL) and 31460133496 @ e7c5e31. CI does **not** run audit — disclosed honestly in scoreboard. +- **Local at tip:** `pnpm run typecheck` exit 0; full `pnpm test` = **2202 tests, 2198 pass / 0 fail / 4 skipped** (exactly the scoreboard's number); focused `store.test.ts` 69/69, `detached.test.ts` 8/8, `store-extended.test.ts` 23/23 (all match scoreboard rows). +- **RC pipeline logs at 6071fb2** (`/tmp/taskflow-03-rc-final/`): `audit.log` = "No known vulnerabilities found"; `codex-mcp-full.log` = 16/16 comprehensive e2e against built dist incl. TE fixture, and `[stamp-build-info] … commit 6071fb2a3aed…` — matches the "evidence SHA 6071fb2 per build-info stamp" claim. +- **Release state is real:** no `v0.3.0` tag; all 10 workspace packages at `0.2.8`; CHANGELOG `## [0.3.0] — Unreleased`; PR #122 OPEN + DRAFT ("do not merge, do not publish"). L5/L6 FAIL is truthful. +- **Host baseline honest:** `conformance/workspace/host-support-baseline.json` — 5 hosts resolve-only, FileBroker/secret/service explicitly unsupported; evidence strings point at real seams (`resources/execution.ts`, `resources/file-transaction.ts`); checked-in probe result (`results/macos-26.3-arm64-25D125.json`) shows `resolve-only` classification with `pass:false` enforcement checks — i.e. no sandbox claimed. Trusted-loader rejection (digest/target/pass/symlink) is adversarially tested (`workspace-baseline.test.ts`). +- **Skills honest + drift-guarded:** `skills-src/taskflow/core.md:496-572` teaches `effects[]`, PathRef shape, "model proposes / resources runtime is the only commit authority", `taskflow_why_effect`, and an explicit "What this is NOT" list (no FileBroker, undeclared paths unprotected, SecretRef/ServiceRef type-only). Generated into all 5 host packages; `build-skills --check` drift guard 3/3 green; marketplace JSONs advertise 20 tools incl. `why_effect`. +- **G5 adversarial coverage — every named scenario has a test:** PathRef escape (`effects-trusted` symlink tests, `effects-gateway-bypass`), direct-write restore (`resource-file-transaction`, `effects-gateway-bypass`), crash recovery ("startup recovery restores a process-crashed partial multi-file mutation"), multi-file rollback ("later promotion failure rolls back every earlier file"), terminal cleanup failure ("post-terminal lease cleanup failure never makes commit retryable"), double-fault lease release ("activation plus journal-inspection double fault still releases lease"), before-image GC ("startup garbage-collects orphan snapshot directories"), gate fast path ("gate eval fast path … same authority path"), cross-run/dynamic-spawn cache bypass (`effects-composition-cache`: flow.def / expand.def / ctx_spawn), event-kernel fallback ("runtime event-kernel flag: declared effects use the same resource transaction semantics"). +- **8 deliverables** each have artifacts + contract tests (`effects-deliverables.test.ts` 7/7 incl. the host-baseline file check); invalid EffectIR gets no canonical hash (`compileTaskflowToIR: invalid EffectIR … never content-addressed`); store `.pi` discovery hardening (`96a32e8`) carries 69 store tests wired into CI on 3 OSes. + +## Residual risk + +1. **Live Codex CLI e2e (`test:e2e-codex`) not rerun on the current tip** — L4's live-host leg remains open; prior-candidate A→B→C is historical only. Scoreboard discloses this; a human must decide whether live-host proof is required before Ready. This is the only real evidence gap in the lane. +2. R1-R3 lanes (contracts / resources / security) are in flight; their findings may add blockers outside this lane's scope. +3. Docs currency (F1–F5) — Low, docs-only; will self-correct on the next resync. + +## Ready recommendation + +**HOLD** (PR stays DRAFT; no merge/tag/npm — unchanged). This lane finds **0 Blocker / 0 High / 5 Low**; nothing here blocks an RC, but "Ready" should not be claimed until: +1. R4-F1..F5 resynced (docs-only; one commit + `gh pr edit`). +2. R1–R3 findings adjudicated (synthesizer). +3. Human decision on the live-Codex-E2E prerequisite for L4. +4. Human tag + publish for L5→L6 (scoreboard currently FAIL — correct). + +Highest honest claim stands: **L4 real-environment (built-MCP fixture) with exact-SHA CI green; live Codex CLI, release, and GA gates open.** diff --git a/docs/internal/reviews/0.3-adv-synth.md b/docs/internal/reviews/0.3-adv-synth.md new file mode 100644 index 00000000..96b152d4 --- /dev/null +++ b/docs/internal/reviews/0.3-adv-synth.md @@ -0,0 +1,207 @@ +# ADV-SYNTH — Multi-agent cross review of 0.3 Trusted Effects RC + +**Date:** 2026-08-11 +**Target:** PR [#122](https://github.com/heggria/taskflow/pull/122) · branch `rc/0.3.0-trusted-effects` +**Base evidence tip (pre-review):** `d3b2878` · CI green `31460495091` +**Review artifacts tip (post-review docs):** see `git log` for `docs: ADV-R*` commits +**Lanes:** R1 contracts · R2 resources · R3 security · R4 honesty +**Policy:** no merge / tag / npm in this review wave + +--- + +## Executive verdict + +| Question | Answer | +|----------|--------| +| **Mark PR Ready now?** | **NO — HOLD** | +| **Merge to main now?** | **NO** | +| **Tag / npm 0.3.0?** | **NO** (L5/L6 human gates; still NOT GA) | +| **MVP TE claims (declared FS write + resources + CI)?** | **Largely stand** after R2/R4; **security discovery walk incomplete** (R3-F1 High) | +| **Ship with known issues?** | Only after **R3-F1** (and ideally R3-F2, R1-F1 scope decision) | + +**One-line:** Exact-SHA CI green and honesty ledger are fine; **do not Ready** until verifier/store walk boundaries match the store harden story, and decide whether static `flow{use}` over-taint (R1-F1) is fix or documented scope. + +--- + +## Deduped findings (highest severity wins) + +### Must-fix before Ready (Blocker / High) + +| ID | Sev | Source | Location | Scenario | Suggested fix | +|----|-----|--------|----------|----------|---------------| +| **S-H1** | **High** | R3-F1 | `verifiers/discover.ts:27-40,113-149` → MCP `taskflow_lint` / `taskflow_plan` | Verifier walk-up has **no home/temp stop**; can **import+execute** attacker modules under ancestor `.pi/taskflows/verifiers` (incl. `/tmp`). Store was hardened in `96a32e8`; verifiers were not. | Shared boundary helper with store: stop at home/temp, canonicalize cwd, refuse walking past project roots; red tests for `/tmp/.pi/...` | +| **S-H2** | **High** | R1-F1 | static `validateComposedEffectFlow` without loader vs runtime with `loadFlow` | Static gates taint every unresolved `flow{use}` → **reject legal saved-subflow + downstream write** that runtime accepts. pi/MCP validate-before-run → composition cannot run. Over-rejection (not under-enforcement). | Downgrade unresolvable-use taint to **warning** at static gates **or** pass resolve/loadFlow into static path; keep runtime admission authoritative for real violations | + +> No **Blocker** severity was filed. **S-H1 is the only RCE-class High.** + +### Should-fix soon (Med) + +| ID | Sev | Source | Location | Scenario | Suggested fix | +|----|-----|--------|----------|----------|---------------| +| **S-M1** | Med | R3-F2 | `store.ts:1044-1046` | Symlinked `.pi` accepted without canonicalization → project flows redirect to `~/.pi` (list/save/run). | Realpath candidate `.pi`; reject if target outside project or equals home/temp | +| **S-M2** | Med | R2-F1 | `file-transaction.ts:603-606` | Throwing `onDeferredLeaseRelease` can mask durable `{ok:true}` after commit (exported API; current execution path uses Set.add). | try/catch around deferred release callback | +| **S-M3** | Med | R1-F2 | `effects/why.ts:163-189` | `collectDeclaredEffects` does not recurse into nested flow.def/use/expand → top-level why-effect blind to nested writes. | Recurse with cycle guard; align with composition walk | + +### Ship-with / defer (Low / Med-Low) + +| ID | Sev | Notes | +|----|-----|-------| +| S-L1 | Med-Low | R2-F2 recovery lease release failure wedges scope until restart — add deferred drain | +| S-L2 | Low | R2-F3 half-open tx if restore+journal double-fault — set `#settled` earlier | +| S-L3 | Low | R2-F4 create-file mode 0o600 vs umask — document | +| S-L4 | Low | R2-F5 orphan lease-release markers — age-gated sweep | +| S-L5 | Low | R1-F3 empty `effects:[]` / shareContext disables cache broadly — conservative | +| S-L6 | Low | R1-F4 malformed top-level effects skipped at flow gate, caught at phase — eventually fail-closed | +| S-L7 | Low | R1-F5 unsupported kinds pass static verify then fail runtime — improve preflight | +| S-L8 | Low | R3-F3 `agents.ts` unbounded walk for `.pi/agents` — same boundary helper | +| S-L9 | Low | R4-F1..F5 docs tip/CI run currency, AGENTS.md roster 19→20, audit phrasing | + +--- + +## What is solid (cross-lane OK) + +- **Declared PathRef escape** (`..`, symlink, win32 case-fold): rejected at resolve (R3 OK + existing gateway-bypass tests). +- **Undeclared write non-claims** honest (no FileBroker / full OS sandbox claim). +- **SecretRef / ServiceRef** fail-closed before body. +- **Resource Commit-or-Restore**, multi-file crash windows, post-commit cleanup core path, lease dead-owner, journal WAL ordering: R2 probes + existing suites. +- **Cache vs nested effects** for def/expand/ctx_spawn: R1 focused 66/66; prior post-review hardening holds. +- **Honesty ledger**: L5/L6 FAIL, NOT GA, live Codex E2E open, skills non-claims — R4 finds **no overclaim**, only doc currency Lows. +- **CI**: full matrix green on tip (incl. Windows store tests) prior to review-doc pushes. + +--- + +## Go / no-go matrix + +| Gate | Status | Note | +|------|--------|------| +| Exact-SHA CI (pre-review tip) | PASS | 31460495091 @ d3b2878 | +| Claim honesty | PASS | R4 | +| Security discovery parity | **FAIL** | S-H1 | +| Static vs runtime composition UX | **FAIL / product decision** | S-H2 | +| Resource API polish | WARN | S-M2 | +| Live Codex CLI E2E on tip | OPEN | human optional | +| L5 tag/npm | FAIL | human only | + +**Ready:** after **S-H1** (+ strongly **S-M1**) land with tests; decide **S-H2** fix vs explicit product scope in scoreboard/PR. +**Merge:** only after Ready criteria + human. +**GA:** still human L5→L6. + +--- + +## Proposed next Kanban cards (not auto-created) + +1. **fix(security): shared .pi walk boundary for verifiers + agents (+ symlink .pi)** — S-H1, S-M1, S-L8 +2. **fix(effects): static composition gate vs flow{use} loader / warning downgrade** — S-H2 +3. **fix(resources): swallow onDeferredLeaseRelease errors after terminal commit** — S-M2 +4. **fix(why): recurse collectDeclaredEffects into nested flows** — S-M3 +5. **docs: resync scoreboard/PR tip + AGENTS roster after hardening** — S-L9 + +--- + +## Lane roll-up + +| Lane | Card | Blocker | High | Med | Low | Ready lean | +|------|------|---------|------|-----|-----|------------| +| R1 Contracts | t_c23ebed3 | 0 | 1 | 1 | 3 | NOT READY | +| R2 Resources | t_58558121 | 0 | 0 | 1 | 4* | MVP OK; polish | +| R3 Security | t_87ab6675 | 0 | 1 | 1 | 1 | HOLD | +| R4 Honesty | t_f8ee27cf | 0 | 0 | 0 | 5 | HOLD (draft) | + +\*R2 includes Med-Low counted under Low-ish polish. + +Artifacts: +- `docs/internal/reviews/0.3-adv-r1-contracts.md` +- `docs/internal/reviews/adversarial-review-r2-resources-verdict.md` +- `docs/internal/reviews/0.3-adv-r3-security.md` +- `docs/internal/reviews/0.3-adv-r4-honesty.md` +- this file + +--- + +## Clawdy operator note + +Parallel chat subagents were also dispatched; durable truth is **Kanban lane comments + these artifacts**. +Review wave intentionally **docs-only** on product risk; fixes are separate cards. + +--- + +## Addendum — chat parallel subagents (deleg_6e9e00d8) + +Dispatched in parallel with Kanban lanes; **static review only** at tip `d3b2878`. +These findings were **spot-checked against `file-transaction.ts` source** by the orchestrator after batch return. + +### Elevated / new (vs Kanban R2 “0 High”) + +| ID | Sev | Claim | Orchestrator verification | +|----|-----|-------|---------------------------| +| **S-C1** | **Critical** | Concurrent `commit()`/`reject()` race: second failure path `#rejectAndRestore` can restore **before-image over a WAL-committed success** | **Plausible on API.** `#settled` only set after successful `commitContent` (`:518`); no mutex around promote+journal. Dual `commit` both pass `#assertOpen`. If production always single-threads one tx handle, severity is **API contract High**; if any host can double-call, **Critical**. **No concurrent-commit test found.** | +| **S-H3** | **High** | `staged/${effectId}-…blob` path join allows `../` escape in control tree | **Code shape confirms** (`:494`). Residual depends on whether Effect IR **rejects** path-like ids at schema. Must sanitize basename regardless. | +| **S-H4** | **High** | Intermediate-path **symlink TOCTOU** prepare→commit: `ensureDirectory`/`replaceFileAtomic` follow mid-path symlink; commit lacks recovery’s `isWithin` recheck | **Code shape confirms** (`replaceFileAtomic`→`ensureDirectory`; commit uses `assertExactPreState` only). Script body can plant symlink after admission. Conflicts with “admitted PathRef escape covered” **if** claimed for full write path. | +| **S-H5** | **High** | `#finish` / `onDeferredLeaseRelease` throw flips durable success (matches Kanban R2-F1 Med elevated) | **Confirmed** — callback not try/caught (`:604`); `finally` on `commit` can replace return. | + +### Chat R1 (contracts) delta + +Chat R1 was **softer** than Kanban R1 on static `flow{use}` (rated Med over-taint / doc issue) and focused on **why-effect** composition gaps (Med). Treat Kanban **S-H2** as product/UX High still; chat **why bag/sibling deny** as Med polish. + +### Updated must-fix before Ready (merged) + +1. **S-H1** verifier discover walk RCE (Kanban R3) +2. **S-H4** commit-path intermediate symlink TOCTOU + containment recheck (chat R3) +3. **S-C1** serialize commit/reject **or** prove single-caller + red test (chat R2) +4. **S-H3** sanitize `effectId` for stage filenames (chat R2) +5. **S-H2** static `flow{use}` over-taint decision (Kanban R1) +6. **S-H5 / S-M2** swallow deferred lease release errors after terminal (both tracks) +7. **S-M1** symlink `.pi` store (Kanban R3) + +### Ready verdict (unchanged direction, stronger) + +**HOLD Ready / merge.** +Kanban honesty lane still clean on L5/L6/live-E2E wording; **resource write containment claims must be tightened** until S-H4 has tests. + +### Proposed cards (updated) + +1. `fix(security): verifier+agents+.pi walk boundary` (S-H1, S-M1, S-L8) +2. `fix(resources): commit containment recheck + nofollow mkdir` (S-H4) +3. `fix(resources): mutex/settled-on-enter for commit/reject + concurrent test` (S-C1) +4. `fix(resources): sanitize effectId for stage paths` (S-H3) +5. `fix(resources): try/catch onDeferredLeaseRelease after terminal` (S-H5) +6. `fix(effects): static flow{use} loader or warning` (S-H2) +7. `fix(why)+docs: nested why + scoreboard PathRef claim honesty` + + +--- + +## Fix wave (2026-08-11) — High harden landed + +**Commit:** `cbb4131` `fix(security): harden .pi discovery walks and file-transaction commit path` +**CI:** https://github.com/heggria/taskflow/actions/runs/31463823993 **success** @ `cbb4131` + +| ID | Status | +|----|--------| +| S-H1 verifier/agents walk | **fixed** (shared `discovery-boundary.ts`) | +| S-M1 symlink `.pi` | **fixed** | +| S-H3 effectId stage path | **fixed** | +| S-C1 concurrent commit | **fixed** (`#busy`) | +| S-H4 commit symlink TOCTOU | **fixed** (containment + nofollow mkdir) | +| S-H5 deferred lease throw | **fixed** (try/catch) | +| S-H2 static `flow{use}` over-taint | **fixed** (580daa0; see below) | + +Ready: still **HOLD** until S-H2 decision + optional second ADV pass; no merge/tag/npm. + +--- + +## Fix wave (2026-08-11) — S-H2 static `flow{use}` over-taint + +**Commit:** `580daa0` `fix(effects): downgrade unresolved flow{use} taint at static gates (S-H2)` + +**Approach (A) implemented:** `validateComposedEffectFlow` gains `downgradeUnresolvedUse`; every static gate (`validateTaskflow`, `verifyTaskflow`/`detectEffectsIssues`, FlowIR `translate` + `compile`) sets it, so an unresolved `flow{use: }` boundary now emits advisory warnings (`unresolved-flow-use` / `unresolved-flow-use-taint`) instead of hard confidentiality/integrity taint. Dynamic inline `flow{def: "{steps.plan.json}"}` boundaries stay hard-tainted (not downgraded). + +**Resolver injection:** the static gates accept an optional `resolveFlow`; pi `action=run` and MCP `taskflow_run` inject the store-backed loader, so saved children are checked with their real effects when resolvable — real violations still hard-fail pre-run. Runtime admission (`executeTaskflow` with `deps.loadFlow`) remains the authoritative fail-closed gate; a loader that misses the name stays advisory at static time and fails closed at runtime. + +**Coverage:** `packages/taskflow-core/test/effects-static-use-downgrade.test.ts` (4 tests) — benign saved child + downstream declared write passes static + runtime (RED on pre-fix tip, 3/4 fail); real violation advisory without loader but hard-fails with resolver and at runtime (calls=0); dynamic def stays hard-tainted; loader-miss advisory statically, fail-closed at runtime. + +**CI:** full suite 2209 pass / 0 fail; `tsc --noEmit` clean; `pnpm run build` clean @ `580daa0`. + +| ID | Status | +|----|--------| +| S-H2 static `flow{use}` over-taint | **fixed** | diff --git a/docs/internal/reviews/0.3-ready-checklist.md b/docs/internal/reviews/0.3-ready-checklist.md new file mode 100644 index 00000000..3d95c795 --- /dev/null +++ b/docs/internal/reviews/0.3-ready-checklist.md @@ -0,0 +1,58 @@ +# 0.3.0-beta.1 Trusted Effects beta — not GA + +**Branch tip:** `f5284da` on `rc/0.3.0-trusted-effects` +**PR:** https://github.com/heggria/taskflow/pull/122 (**DRAFT** — do not auto-merge) +**Exact-SHA CI:** [31613909075](https://github.com/heggria/taskflow/actions/runs/31613909075) **GREEN** (full matrix) +**Base:** `main` @ `4b04e0d` · **~23 commits** ahead + +> **BETA RELEASE, NOT GA.** L6 remains FAIL. Agents must not merge/tag/publish without explicit human authority. + +--- + +## What is green (agent-verified) + +| Item | Evidence | +|------|----------| +| Product TE + post-review harden | history includes `e6efcd4` … `467bc28`, store `96a32e8` | +| Windows store test harness | `e2f8990` / `e3d128f` | +| ADV High harden (discovery + file-tx) | `cbb4131` + tests `adv-high-fix` | +| S-H2 static flow{use} over-taint | `580daa0` | +| Local focused recheck | 110/110 (residual pass) | +| Remote CI tip | run 31613909075 success | +| Honesty | L5/L6 FAIL; live Codex E2E **PASS @ 4524d2d**; no FileBroker claim | + +## ADV fix ledger (closed for Ready-eligible) + +| ID | Status | +|----|--------| +| S-H1 verifier/agents walk | fixed `cbb4131` | +| S-M1 symlink .pi | fixed `cbb4131` | +| S-H3 effectId stage path | fixed `cbb4131` | +| S-C1 concurrent commit | fixed `cbb4131` | +| S-H4 commit symlink TOCTOU | fixed `cbb4131` | +| S-H5 deferred lease throw | fixed `cbb4131` | +| S-H2 static flow{use} | fixed `580daa0` | + +## Optional before you click Ready + +- [ ] Spot-read `docs/internal/reviews/0.3-adv-synth.md` + residual note +- [x] Live `test:e2e-codex` PASS on tip `4524d2d` (worker-mac) — `docs/internal/evidence/e2e-codex-live-4524d2d.md` +- [ ] Skim PR diff vs main for surprise docs noise + +## Human-only gates (in order) + +1. **Mark PR Ready for review** (still not merge) — only if you accept the optional bar above +2. **Merge** `rc/0.3.0-trusted-effects` → `main` (after review) +3. **Version bump** to `0.3.0-beta.1` per the beta release plan +4. **Tag** `v0.3.0-beta.1` + **npm publish --tag beta** with explicit authority +5. Update the beta release record; keep L6 GA FAIL + +## Explicit non-actions for agents + +- Do **not** mark Ready / merge / tag / npm without new human instruction +- Do **not** claim GA + +## Kanban + +- GOAL: `t_62544c00` tenant `taskflow-0.3-converge` +- Residual completed by Clawdy after orch crash handoff diff --git a/docs/internal/reviews/0.3-residual-recheck.md b/docs/internal/reviews/0.3-residual-recheck.md new file mode 100644 index 00000000..2723ae2e --- /dev/null +++ b/docs/internal/reviews/0.3-residual-recheck.md @@ -0,0 +1,41 @@ +# Residual ADV recheck after S-H2 (Clawdy handoff) + +**Date:** 2026-08-11 +**Tip:** `0551f62` (`0551f629e69076cf59da5a330fb0da383687ce60`) +**PR:** #122 Draft +**CI:** [31466355338](https://github.com/heggria/taskflow/actions/runs/31466355338) **success** (full matrix incl. windows) + +## Focused tests (local) +``` +node --conditions=development --experimental-strip-types --test \ + packages/taskflow-core/test/adv-high-fix.test.ts \ + packages/taskflow-core/test/resource-file-transaction.test.ts \ + packages/taskflow-core/test/verifier-discover.test.ts \ + packages/taskflow-core/test/store.test.ts \ + packages/taskflow-core/test/verify-effects.test.ts \ + packages/taskflow-core/test/effects-composition-cache.test.ts +``` +**Result: 110 pass / 0 fail** + +## Surface recheck + +| Surface | Status | Notes | +|---------|--------|-------| +| discovery-boundary / store / verifiers / agents | OK | covered by adv-high-fix + verifier-discover + store boundary tests | +| file-transaction busy/effectId/TOCTOU/lease callback | OK | adv-high-fix + resource-file-transaction | +| S-H2 static flow{use} | OK | 580daa0; composition + verify-effects green | +| New Blocker/High on these surfaces | **None observed** in this residual pass | + +## Not re-litigated here +- Live Codex CLI E2E (still open by design) +- Full second multi-agent ADV swarm (optional) +- S-H2 product polish beyond landed fix + +## Conclusion +Residual technical gate for Ready-eligible handoff: **PASS** at tip `0551f62` with CI green. Human gates (Ready/merge/tag/npm) unchanged. + +## Addendum — live Codex host dogfood + +**2026-08-11:** `pnpm run test:e2e-codex` on worker-mac @ clean `4524d2d` → **PASS** (~27.6s). +A→B→C (`Mango`→`MANGO`) + TE `fs.write` + ledger why. +Evidence: `docs/internal/evidence/e2e-codex-live-4524d2d.md`. diff --git a/docs/internal/reviews/adversarial-review-r2-resources-verdict.md b/docs/internal/reviews/adversarial-review-r2-resources-verdict.md new file mode 100644 index 00000000..191b3fd8 --- /dev/null +++ b/docs/internal/reviews/adversarial-review-r2-resources-verdict.md @@ -0,0 +1,71 @@ +# Adversarial Review R2 — Resources lane (file-transaction / lease / journal / cleanup / GC) — 2026-08-11 + +> Lane: ADV-R2 Resources. Reviewer: coder profile (kanban t_58558121). +> Target: `rc/0.3.0-trusted-effects` @ `d3b2878` (product candidate `96a32e8`). +> Method: source audit of `packages/taskflow-core/src/resources/{file-transaction,journal,leases,permits,persistence,execution,types}.ts` + +> 14 adversarial probes (crash windows, GC retention, lease double-fault, journal concurrency, mode fidelity), +> plus the focused suites: resource-file-transaction / resource-journal / resource-leases / resource-permits / +> resource-persistence / resources-authority-resolve / effects-{trusted,agent-te,deliverables,gateway-bypass,composition-cache} +> → 104 pass / 0 fail / 4 skipped (root-only permission fault injection skips). + +## Findings + +| # | Sev | Location | Scenario | Fix/Defer | +|---|-----|----------|----------|-----------| +| F-R2-1 | MED | `file-transaction.ts:603-606` (`#finish` → `this.#onDeferredLeaseRelease?.()`) | Lease release fails 3× (control-plane hiccup) **and** the `onDeferredLeaseRelease` callback throws → `#finish()` throws inside `commit()`'s `finally`, **replacing the durable `{ok:true,…}` return**. Caller (runtime finalize at `runtime.ts:1295`) sees an exception; phase is marked failed while the mutation is durably committed; a retry re-commits → second generation. Violates the module's own invariant "A terminal journal record must remain the operation result" (`file-transaction.ts:235-237`) and AGENTS.md safe-emit. **Probe-proven (P1):** file content `A`, intent `committed-content`, but `commit()` threw `injected deferred-release callback throw` and returned no result. Not reachable through current `execution.ts:564` wiring (`Set.add` cannot throw), but this is the exported shared authority API. | Fix (recommended): wrap the callback in try/catch (log + continue) in `#finish()` and in the prepare-catch call at `file-transaction.ts:727`. ~4 lines; restores the invariant for every caller. | +| F-R2-2 | MED-LOW | `file-transaction.ts:434-438` (`recoverResourceFileIntent` finally) | Startup recovery acquires a lease on the crashed intent's scopes, restores, then `releaseBestEffort` fails 3×. **This path has no deferral/drain hook** — unlike every other lease cleanup (transaction `#finish`, `execution.ts:#releaseLeaseBestEffort`). The leaked lease (`recovery-` owner, marker `held`, live process) blocks the recovered scope for the entire session; the next admission on that scope times out until process restart. Fail-closed (no corruption), but a transient control-dir write failure mid-recovery wedges the domain. **Probe-proven (P12):** after recovery with a failing-release coordinator, subsequent acquire `blocked=true`, `leases=1`. | Fix (recommended): give `recoverResourceFileIntent` an optional `onDeferredLeaseRelease` hook wired to the session's `#pendingLeaseReleases` drain, or route the recovery lease through the same drain. | +| F-R2-3 | LOW | `file-transaction.ts:561-571` (`#rejectAndRestore` catch) | Restore fails (external actor replaced the target with a directory) **and** `journal.getIntent` throws → `#settled` is never set, so the transaction stays half-open **after the lease was released**; a second `commit()`/`reject()` passes `#assertOpen` and re-enters the reject path; caller sees an exception instead of a `FileTransactionResult`. Converges to `dirty-unknown` at next startup (fail-closed), no silent corruption. **Probe-proven (P13):** first and second `commit()` both threw the injected journal error; tx never settled. | Fix (recommended): set `#settled = true` before the `getIntent`/`markUnknown` bookkeeping (mirror the prepare catch at `file-transaction.ts:719-722`), so the settled contract holds even under double fault. | +| F-R2-4 | LOW | `file-transaction.ts:502` (`replaceFileAtomic(…, snapshot.mode ?? 0o600)`) | New (`create-file`) files written through the trusted-effects transaction are hard-coded `0o600` regardless of umask; a plain `fs.writeFileSync` under umask 022 yields `0644`. **Probe-proven (P8):** baseline `644`, tx file `600`. Committed artifacts are owner-only → group/other processes (CI, docs server, another OS user) cannot read them; `git status` is unaffected (git tracks only the exec bit). No PathRef mode field exists to request another mode. | Defer: document the `0o600` default; consider honoring umask or adding a mode option to PathRef in a later iteration. More restrictive is safer — divergence, not data loss. | +| F-R2-5 | LOW | `leases.ts:296-297` + `235-244` | Crash between `#createReleaseMarker` (writes `lease-release--.json`, state `held`) and the state-file write leaves an orphan release marker that is never collected: `#isStale`/`#cleanupReleaseMarkers` only consult records, and no record references the marker. Inert (never blocks) but accumulates across crash/acquire cycles in the control dir. | Defer: startup sweep of `lease-release-*.json` without a matching record, age-gated like the transaction-orphan GC (`file-transaction.ts:258`). | + +**Severity counts: 0 Blocker, 0 High, 1 MED (F-R2-1), 1 MED-LOW (F-R2-2), 3 LOW (F-R2-3/4/5).** + +## OK coverage (verified) + +- **Multi-file crash Commit-or-Restore** — every crash window is consistent: + crash mid-replace → intent `pending` → startup recovery restores all before-images → `aborted-restored` + (`resource-file-transaction.test.ts:446` child-process crash; probe P4/P7); crash after the terminal WAL + append (durability point `journal.ts:522-526`) → `committed-content` retained and dir GC'd (P7); partial + commit WAL tail → truncated + `dirty-unknown` (`resource-journal.test.ts:189`, P10); partial intent WAL + tail → orphan dir aged out, files untouched (code walk); double recovery idempotent (P11); missing + before-image degrades to `dirty-unknown` fail-closed, never silent restore (P3). +- **Post-commit cleanup** — terminal commit/abort GCs before-images (`resource-file-transaction.test.ts:129,157,484`); + `cleanupStaging` throw does not fail commit (test:368); release-after-durable-unlock failure does not fail + commit (test:331); committed tx dir removed post-commit, abandoned pending dir retained (P7). +- **Lease** — cross-process contention + dead-owner stale recovery (`resource-leases.test.ts:194`); durable + release marker survives state-cleanup loss (test:130); corrupt live marker blocks only its overlapping + scope (test:163); activation+inspection double fault still releases the lease (test:414); failed-state + release pruned on next acquire (P6); abort cancels waiters but never revokes a granted writer (test:98). +- **Before-image / orphan GC** — `pending`/`dirty-unknown` dirs retained; `reconciled`/terminal removed; + aged pre-intent orphans collected; fresh pre-intent window preserved (P2 + tests:491,507). +- **Journal concurrency honesty** — WAL order across parallel commits in one domain (P5); cross-process + disjoint commits get generations 1,2 with no corruption (P9); overlapping pending/dirty scopes block while + disjoint proceed (`resource-journal.test.ts:131`); explicit reconciliation advances generation durably + (test:151); `beforeGeneration` CAS in `prepare`; all WAL appends/reads under the persistent mutex with + unterminated-tail truncation only under the mutex; no-op commit records honest `before==after` evidence + (P14); restored-abort is terminal and generation-neutral (test:260). +- **Restore correctness** — declared-path bypass → `aborted-restored` + created-parent prune + (`resource-file-transaction.test.ts:136`, P4); restore over a directory target → fail-closed + `dirty-unknown`; snapshot manifests content-address verified (`loadRecoverySnapshots`). + +## Residual risk + +- **External TOCTOU**: the window between `preCommitGuard` (post-state check) and the terminal WAL append is + not fenced against hostile external writers; the ledger can drift from disk after commit. Documented + resolve-only limitation (`file-transaction.ts` header, `execution.ts:648-652`); requires a native + file broker to eliminate. +- **Restore is content+mode only**: `restoreSnapshot` → `replaceFileAtomic` creates a new inode, so mtime / + ctime / ownership are not part of the "durable pre-state" claim (evidence is contentId-based). +- **Append-only unsharded journal**: fold is O(n) per op; scale/capacity for thousands of intents is + deferred (scoreboard). +- **F-R2-2 wedge** is the only residual that can degrade availability of a live session (until restart); + F-R2-1/3 are contract violations on exported API, currently unreachable through `execution.ts`. + +## Ready recommendation + +**No Blocker, no High. The MVP Commit-or-Restore, lease, journal-concurrency, and before-image/orphan-GC +claims are verified.** Recommend a small pre-GA hardening pass for **F-R2-1 + F-R2-2** (each ~4-10 lines; +F-R2-3 optional with them) because they touch the module's core "committed result must remain the result" +and "cleanup must not wedge the session" invariants under exactly the control-plane failure conditions the +0.3 design promises to survive. F-R2-4/F-R2-5 defer. Product code unchanged in this review; probes were +temporary and removed. CI baseline re-verified at `d3b2878`: focused resource/effects suites 104 pass / 0 fail. diff --git a/docs/internal/rfc-0.3.0-control-plane-v7.6.md b/docs/internal/rfc-0.3.0-control-plane-v7.6.md new file mode 100644 index 00000000..5078d5a0 --- /dev/null +++ b/docs/internal/rfc-0.3.0-control-plane-v7.6.md @@ -0,0 +1,896 @@ +> **SNAPSHOT (read-only reference):** copied verbatim from archive branch +> `origin/backup/0.3-archive/mac-feat-0.3.0-control-plane` @ docs/internal/rfc-0.3.0-control-plane.md (v7.6, 2026-07-22). +> 0.3-C 以本快照为**设计蓝本**:抄概念,不搬代码。P1–P16 ADR 与 wire-freeze 见 +> `docs/internal/p-adrs/` 与 `docs/internal/wire-freeze.md`。任何后续修订只能发生在 P-ADR 层, +> 本快照不再演进。 + +# RFC: taskflow 0.3.0 — Coding-Agent Control Plane + +> **Document version:** **v7.6 (MASTER RFC FROZEN for expansion)** +> **Branch:** `feat/0.3.0` +> **Date:** 2026-07-22 +> **Approver action:** Architecture **Approved**; protocol model **Approved with conditions**; Steps 1–2.5 **go**; wire freeze **not** yet (P1–P16). +> **No further master-RFC growth** except typo/conflict fixes. Detail → P-ADRs + TypeBox only. +> +> | Layer | Status | +> |-------|--------| +> | Architecture | **Approved** | +> | 0.3 protocol model | **Approved with conditions** (this version) | +> | Wire / TypeBox freeze | **Not yet** | +> | Implementation now | **§22 steps 1–2.5** | +> | DomainTransfer / merged user journal | **Out of 0.3** | +> +> **Self-contained:** implementers need not read v1–v6 history. +> **Supersession:** for 0.3+ ControlHost clients this RFC wins over conflicting bullets in +> [`rfc-local-daemon.md`](./rfc-local-daemon.md) and [`competitive-map-2026-h2.md`](./competitive-map-2026-h2.md) +> (see §25). Those files’ 0.2.x history is labeled **Historical**. + +**Normative dependencies:** +[`rfc-workspace-capabilities.md`](./rfc-workspace-capabilities.md) · +[`rfc-background-run.md`](./rfc-background-run.md) · +[`../rfc-0.2.0-architecture.md`](../rfc-0.2.0-architecture.md) · +[`../0.2.0-north-star.md`](../0.2.0-north-star.md) + +--- + +## §0. TL;DR (approved) + +1. **Product:** Coding-Agent Control Plane. + `Program → BoundPlan → Run → Receipt`. + +2. **Soul:** single execution semantics · immutable BoundPlan/BoundFragment · durable per-project journal. + +3. **Scoped authority (accepted):** + - **Project ControlStore** = Run / Command / Approval / Receipt authority. + - **UserCoordinatorStore** = singleton lease + **concurrency reservations** + narrow **CoordinatorCommandRecord** authority (not project Run history). + - **ControlRegistry** = non-authoritative discovery / projection. + - One ControlDomain per project; daemon multi-mount; **no** DomainTransfer / merged user journal in 0.3. + +4. **`unknown` is reconcilable and non-terminal.** Auto-reconcile and client wait are **bounded**, but timeout **must not invent** a provider terminal, free a committed slot, or issue a final Receipt (§8.4). + +5. **`controlMode: auto`** + fresh-install bootstrap (§5). No silent full-power fallback. + +6. **D21** public 0.2.4 surface at GA; headless approval compat explicit (§17.3). + +7. **Toolchain (D28):** Node ≥22.19 + @types/node 22 + CI **22/24 required, 26 allowed-to-fail**; TS 7 root; DSL TS 6 API isolated; pnpm 11. + +8. **Start now:** Steps 1–2.5. **Hold:** wire freeze. + +--- + +## §1. Product sentence & non-goals + +> Taskflow links programs under policy and capabilities into immutable BoundPlans/BoundFragments, executes them with one semantic kernel on heterogeneous providers, and records a durable **per-project** journal from which runs, receipts, and replays are derived. A user-level daemon **mounts** many project ledgers and provides a unified console—not a merged total ledger. + +**Non-goals (0.3):** DomainTransfer; physical merge of project journals; **cross-project parent/coordinator Runs** (federated multi-repo workflows); multi-cluster; exactly-once marketing; Squad/Paperclip clones; silent dual runtimes; GA capability regression; ControlStore on `node:sqlite` without P14. + +--- + +## §2. Human model (normative narrative) + +| Concept | Plain language | +|---------|----------------| +| **ControlDomain** | Project **jurisdiction** for command order, Run/Approval/Receipt. | +| **Project ControlStore** | Official **project ledger** (commands, events, projections, receipts). Process speech is not truth. | +| **UserCoordinatorStore** | Narrow **user-level authority** for daemon singleton + **global concurrency reservations** only. Not project Run history. | +| **ControlRegistry** | Non-authoritative **directory** of project ledgers (paths, mount, rebuildable indexes). | +| **taskflowd / embedded supervisor / standalone** | **Clerk** processes. Swap clerks; **do not swap project ledgers** in 0.3. Embedded multi-mount uses same singleton as taskflowd. | +| **Receipt** | Evidence package derived from the **project** ledger. | +| **CoordinatorLease / ConcurrencyReservation** | Records in UserCoordinatorStore for global **maxActiveRuns**. | +| **CoordinatorCommandRecord** | Narrow user-level command ledger for coordinator ops (set maxActiveRuns, force-release) — not project Run history. | +| **needs-operator** | Auto-reconcile exhausted; Run may stay `unknown`; wait returns snapshot + TF_RECONCILE_REQUIRED. | + +```text +Codex / Pi / CLI / Claude + │ commands + ▼ +standalone (one project) OR taskflowd / embedded multi-mount (singleton) + │ + ├── ControlRegistry ────────── discovery (non-authoritative) + ├── UserCoordinatorStore ───── concurrency leases (scoped authority) + └── project ControlStore ───── Run/Command/Approval/Receipt authority + │ + ▼ + ExecutionProvider +``` + +**Daily multi-project UX** = registry aggregates + coordinator concurrency + Receipt budget **stats**. Not a merged total journal. + +--- + +## §3. Architecture decisions (complete) + +| ID | Choice | +|----|--------| +| **D1** | CACP | +| **D2** | Single scheduler; legacy phase code as executors only | +| **D3** | Planes: Intent · Compile · Link · Control · Exec · Ledger (+ diagnostic Trace) | +| **D4** | Entities: ControlDomain, Project ControlStore, UserCoordinatorStore, CoordinatorLease, ConcurrencyReservation, **CoordinatorCommandRecord**, ControlRegistry, CommandRecord, Program/FlowIR, BoundPlan, BoundFragment, SpawnTemplate, Run, NodeInstance, Attempt, ProviderJobHandle, ControlEvent, ArtifactRef, SecretRef, Receipt | +| **D5** | `controlMode`: **auto** default; coordinated fail-closed; standalone explicit | +| **D6** | **Scoped authority:** Project ControlStore = Run/Command/Approval/Receipt; UserCoordinatorStore = singleton + concurrency + **coordinator commands**; ControlRegistry = non-authoritative | +| **D7** | `projectId` UUID stored in **ControlStore header** and Registry; `directoryBinding`; rebind binding only | +| **D8** | Thin MCP + CLI; stable tool names | +| **D9** | Async ExecutionProvider; control mints Receipts | +| **D10** | Policy: deny \| substitute \| attenuate | +| **D11** | Unsupported sandbox fail closed | +| **D12** | Migration: read-old/write-new; tiered rollback; **no DomainTransfer** | +| **D13** | 0.3 ships ControlHost + journal + BoundPlan/Fragment + CLI; WebUI 0.3.1 | +| **D14** | Workspace capability RFC normative | +| **D15** | OS principal + optional adapter credential; `mcp:*` label alone weak | +| **D16** | at-least-once + idempotent submit + reconcile | +| **D17** | BoundPlan template + BoundFragment chains | +| **D18** | One ControlHost semantics in all modes | +| **D19** | Grant refs + revalidation; plan ≠ bearer | +| **D20** | Dual-write: 0.3 stops self; cannot kill foreign 0.2 writers | +| **D21** | Public 0.2.4 surface compatible at GA | +| **D22** | One physical journal **per project ControlDomain**; `commitSeq` per domain | +| **D23** | Orthogonal enforcement capabilities | +| **D24** | CommandRecord in same atomic batch as events; principal-scoped replay **with re-auth** | +| **D25** | boundFragmentHash (audit) + executionSemanticHash (reuse) | +| **D26** | ArtifactRef content digests; SecretRef **no** content digest | +| **D27** | **Per-project domain fixed at first registration; no DomainTransfer in 0.3** | +| **D28** | Node ≥22.19; **TS 7** workspace; **DSL TS 6 API isolated**; pnpm 11 | +| **D29** | User **ControlRegistry** for multi-project mount/aggregate | +| **D30** | **Global concurrency = `maxActiveRuns` with `slots ≡ 1` per admitted Run.** Capacity: `count(reserved\|committed\|orphan-suspect) ≤ maxActiveRuns`. Not a global subagent cap (`flow.concurrency` stays per-Run). Global budget: statistics only. | +| **D31** | **RunStatus** vs **RunStage** are distinct fields (§8); RunStage includes **`parked`** | +| **D32** | Embedded multi-mount supervisor must use the **same user singleton lock + endpoint** as taskflowd | +| **D33** | **`unknown` non-terminal**; auto-reconcile/wait **bounded**; timeout → **needs-operator**, keep capacity, **no** final Receipt / no fake `failed` (§8.4) | +| **D34** | Approval durability: **compat-auto-reject \| durable-optional \| durable-required** (§17.3) | +| **D35** | **No federated multi-ControlStore workflow** in 0.3 (one Run ↔ one project ControlStore). Multi-root *within* one project is workspace-capability, not this. | +| **D36** | Concurrency: **`reserved` TTL-reclaimable; `committed` never TTL-only release**; release **only via D37** `normalRelease` or `forceRelease` | +| **D37** | **Release predicates** `normalRelease` / `forceRelease` (§4.3.2) — do not use weaker shorthands | +| **D38** | Durable approval **park**: stage **`parked`**, release run-slot when quiescent; on approve → **`queued`** + re-reserve (§8.3, §17.4) | + +--- + +## §4. ControlDomain, Project ControlStore, ControlRegistry, UserCoordinatorStore + +### 4.1 Per-project ControlDomain (0.3 default — frozen) + +- First successful project control registration creates: + - `projectId` (stable UUID) in **ControlStore header** and mirrored in Registry + - `ControlDomainId` (stable UUID, 1:1 with project ledger) + - on-disk **Project ControlStore** (path in P14) + - `directoryBinding` for swap/move detection +- **ControlDomainId does not change** on daemon restart, standalone↔daemon, or client upgrade. +- **No second domain** for the same projectId in 0.3. + +### 4.2 Project ControlStore (Run authority) + +Records: CommandRecords, ControlEvents, Run projections, approval state, idempotency, receipt metadata, artifact/secret refs, recovery cursors. + +**Process speech is not truth; committed project ControlStore records are** for Run/Command/Approval/Receipt. + +Storage engine: **always specified in P14** (files-only is still an engine: fsync, batch, locks, recovery, compaction). No `node:sqlite` without P14 covering stability. + +### 4.3 ControlRegistry (user-level, non-authoritative for project Runs) + +```text +ControlRegistry +├── projectId → { controlDomainId, storePath, directoryBinding, mountState, summary? } +└── rebuildable indexes (run list, open approvals) — derived, not project-run authority +``` + +- taskflowd **mounts** each project store listed in the registry. +- Mutating project commands always commit to the **project** ControlStore. +- Global run list / search / approval inbox = **aggregate views**. + +#### 4.3.1 Registry loss / rebuild (P3 must implement) + +- Every **ControlStore header** persists `{ projectId, controlDomainId, schemaVersion, directoryBinding evidence }`. +- If Registry is lost: **on next open** of a project path, re-register from store header (no invention of run state). +- Optional full-disk discovery only if **explicit discovery roots** are configured (not implicit whole home crawl by default). +- **clone / copy / worktree / move** policy (P3): + - **move** same inode evidence after rebind → same projectId when rebind succeeds; + - **copy/clone** → **new projectId** (new domain) unless explicit “adopt identity” operator command; + - **git worktree** → new binding; default **new projectId** (avoid two worktrees sharing one live journal without exclusive lease). + +#### 4.3.2 UserCoordinatorStore (scoped authority — not a project total ledger) + +```text +UserCoordinatorStore (user-private) +├── CoordinatorLease { holderId, fencingEpoch, endpoint, expiresAt } +├── maxActiveRuns +├── CoordinatorCommandRecord { # narrow command authority (D6) +│ commandId, requestHash, callerPrincipal, +│ kind: setMaxActiveRuns | forceRelease | … +│ firstCommitSeq, lastCommitSeq, status +│ } +├── ConcurrencyReservation { +│ reservationId +│ state: reserved | committed | released | expired | orphan-suspect +│ slots: 1 # FIXED in 0.3 — not weighted +│ # required when state ∈ {committed, orphan-suspect}: +│ projectId, projectControlDomainId, runId +│ projectAdmitCommitSeq # REQUIRED +│ attemptId?, providerJobHandle? +│ coordinatorEpoch +│ reservedExpiresAt? # ONLY while reserved +│ renewedAt? +│ } +└── (no project Run history / project Receipts) +``` + +**Capacity (D30):** + +```text +count(reservations where state ∈ {reserved, committed, orphan-suspect}) + ≤ maxActiveRuns +``` + +Each admitted Run occupies **exactly one** slot (`slots: 1`). Future weighted runs need a separate `admissionWeight` ADR — not 0.3. + +**Metering:** `maxActiveRuns` = concurrent **admitted Runs**. +`flow.concurrency` = concurrent **subagents inside one Run**. Do not conflate. + +**0.3 global budget:** statistics only. + +**P16 lifecycle:** + +```text +reserve (reserved, TTL OK) + → Run Admitted + projectAdmitCommitSeq + → committed (no TTL release) + → dispatch / park / reconciling … + → normalRelease | forceRelease +``` + +**Release predicates (D37 — product pin):** + +```text +noLiveOrAmbiguousSideEffects = + provider/isolation proves no live process tree + AND no open ambiguous provider job for this run + AND (if unknown/reconciling: NOT merely "auto-reconcile timed out") + +normalRelease = + noLiveOrAmbiguousSideEffects + AND ( + runIsTerminal (completed|failed|blocked|cancelled) + OR runIsParkedAndFutureDispatchRequiresReadmission + // e.g. durable approval pause with provider quiescent + ) + +forceRelease = + authorizedOperatorCommand (CoordinatorCommandRecord) + AND explicitRiskAcknowledgement + → mark concurrency guarantee operator-overridden +``` + +| State | TTL auto-reclaim? | Notes | +|-------|-------------------|--------| +| **reserved** | Yes | pre-admit | +| **committed** | **Never by TTL** | **only D37** `normalRelease` / `forceRelease` | +| **orphan-suspect** | holds capacity | crash / reconcile-automation exhausted; still counts in capacity formula | + +**Forbidden:** weaker shorthands than D37; status field alone without `noLiveOrAmbiguousSideEffects`; fake terminal after reconcile timeout; CLI mutating reservations without CoordinatorCommandRecord. + +Crash matrices → **P16 ADR** only. + +### 4.4 DomainTransfer — **out of 0.3** + +**Deferred to 0.3.1+** (or never, if registry model suffices). + +Rationale: daily multi-project needs do not require merging ledgers; transfer has dual-commit crash windows and is a cross-store transaction. +**0.3 rule:** change the clerk (standalone ↔ daemon), **keep the same project ControlStore**. + +If a future product requires “two projects, one atomic admission Receipt,” design a **separate coordinator domain** later—do not swallow project histories. + +### 4.5 Cross-project features without merge + +| Need | 0.3 mechanism | +|------|----------------| +| Unified dashboard | Registry + aggregate index | +| Global concurrency | **UserCoordinatorStore** under singleton multi-mount control | +| Global budget | **Statistics only** (aggregate Receipts) | +| **Federated multi-ControlStore workflow** | **Out of 0.3 (D35)** — no parent Run spanning project ledgers; multi-root *within* one project is workspace-capability | +| Unified approvals | Inbox of **refs** into each project’s durable approval (when enabled); decisions write home store | + +--- + +## §5. controlMode & fresh-install bootstrap + +| Mode | Behavior | +|------|----------| +| **auto (default)** | Ensure user registry + project ControlStore; start or attach the **user singleton multi-mount control** (taskflowd **or** embedded supervisor that competes for the **same** lock/endpoint — D32); fail closed if control cannot run | +| **coordinated** | External control required; fail closed if down | +| **standalone** | Explicit. In-process ControlHost opens **the same project ControlStore**; single-owner lease; **no** global concurrency claims across projects | + +**Silent fallback from auto → full standalone is forbidden.** Only explicit `controlMode: standalone`. + +### 5.1 Embedded multi-mount supervisor (not a forked auto) + +If a host package embeds a multi-mount supervisor instead of spawning an external `taskflowd` binary: + +1. It **must** compete for the **same user-level singleton lock** and **same coordination endpoint** (UDS path / pipe name) as the standalone `taskflowd`. +2. Losers **attach as clients** to the winner — they must **not** each become an independent multi-mount authority. +3. Wire protocol, fencing epoch, and UserCoordinatorStore path are **identical** to external daemon. +4. Failing the lock and then running “local multi-mount alone” is **forbidden** (that is silent fork). + +### 5.2 Fresh-install / upgrade contract (GA must pass) + +1. **Bundled control binary** path documented (`taskflowd` / host package bin). +2. First `taskflow_run` (or CLI equivalent) with defaults: creates registry entry + project ControlStore if missing, starts/attaches singleton control, completes one run **without manual daemon config**. +3. **Concurrent client start:** single-instance lock / socket acquire; losers attach to winner (no dual writers). +4. **Stale socket:** detect dead peer (pid/lock), remove socket, restart. +5. **Version skew:** handshake rejects incompatible client/daemon; upgrade path documented (restart daemon from matching package). +6. **Platforms:** Unix UDS required for 0.3 GA; **Windows named pipe** supported **or** Windows explicitly **non-GA** in release notes (choose in bootstrap P-ADR; default proposal: UDS primary, Windows pipe in same ADR before GA). +7. Outage must not corrupt journal; new admits fail until control returns. + +--- + +## §6. Planes + +```text +INTENT → COMPILE → LINK → CONTROL → EXEC → LEDGER (per-project ControlStore) + │ │ + │ └── ArtifactStore / SecretStore (scoped) + └── BoundFragment +``` + +--- + +## §7. BoundPlan, BoundFragment, dynamic paths + +### 7.1 BoundPlan (immutable template) + +Link → BoundPlan. Never mutated. Holds template bindings, SpawnTemplate, savedFlowPins, grantRefs, claims, enforcement capabilities, dynamicPolicy. + +**Evidence not bearer:** revalidate grants at admit and per enforcement rules. + +### 7.2 BoundFragment + +Dynamic IR after Compile+Link under attenuated parent authority. + +```text +parentBoundPlanHash, parentBoundFragmentHash? +sourceEventId, sourceCommitSeq +fragmentIRHash, fragmentPolicyHash, capabilitySetHash, authorityEpoch +boundFragmentHash, executionSemanticHash +``` + +### 7.3 Dynamic inventory + +| Path | Rule | +|------|------| +| flow{def}, expand nested, expand graft, ctx_spawn subflow | BoundFragment chain | +| saved flow use | Pin irHash/boundPlanHash at root Link; no mutable re-resolve | +| flat ctx_spawn | SpawnTemplate ceiling → NodeInstance; else fragment or deny | +| map/loop/tournament items | Deterministic nodeInstanceId if obligations bound | + +**Cache:** store fragment ArtifactRef, both hashes, event range, outputs. Re-Link/validate; reuse only if §11 predicate holds. No blind promotedPhases restore. + +### 7.4 SpawnTemplate + +allowedAgentClasses, allowedProviderClasses, tool/effect ceilings, maxChildren, maxDepth, budgetShare. + +--- + +## §8. RunStatus, RunStage, Attempt machines + +### 8.1 Two orthogonal fields (D31 — frozen) + +```text +RunStatus = running | completed | failed | paused | blocked | cancelled | unknown +RunStage = received | compiled | linked | queued | admitted + | executing | parked | reconciling | terminal +``` + +| Field | Meaning | +|-------|---------| +| **RunStatus** | User-visible / API lifecycle | +| **RunStage** | Control pipeline progress | + +**Terminal RunStatus only:** `completed | failed | blocked | cancelled`. +**`unknown` is NOT terminal** (D33). + +**Pairings (normative):** + +| Situation | RunStatus | RunStage | Slot | +|-----------|-----------|----------|------| +| Durable approval, provider quiescent | `paused` | **`parked`** | released (D37/D38) | +| Cancel-in-flight, worker still live | `paused` | **`executing`** | held | +| Provider ambiguous | `unknown` | **`reconciling`** | held / orphan-suspect | +| True end | terminal status | **`terminal`** | released only via D37 | + +### 8.2 RunStatus ↔ 0.2.4 mapping (P5 goldens) + +| 0.3 RunStatus | 0.2.4 | Notes | +|---------------|-------|--------| +| running | running | Active work | +| completed | completed | Wire keeps `completed` (not `success`) | +| failed | failed | | +| paused | paused | Approval park **or** cancel-in-flight (stage distinguishes) | +| blocked | blocked | Gate / project budget / approval expired | +| cancelled | — | Explicit cancel **settled** | +| unknown | — | Ambiguous; reconcile path | + +**Cancellation import / resume (frozen intent for P5):** + +| 0.2.4 observation | 0.3 import | +|-------------------|------------| +| `paused` + `detachedCancel` + worker **still live** | `paused` + stage **`executing`** (slot held) | +| `paused` + `detachedCancel` + worker **terminated** / confirmed dead | **`cancelled`** + stage **`terminal`** | +| failed message mentions cancel only | stay **`failed`** unless durable cancel marker exists | +| clean cancel with no paused intermediate | **`cancelled`** | + +P5 must include resume-after-detachedCancel goldens. + +### 8.3 RunStage progression + +```text +received → compiled → linked → queued → admitted → executing + ⇄ reconciling + → parked // D38: approval pause, slot released + → queued // approval approved → re-queue + → admitted → executing +parked → terminal // reject / expire / cancel settle +* → terminal // completed|failed|blocked|cancelled +``` + +- Fragment link: status often `running`; stage stays `executing`. +- **`executing → queued` is illegal** without going through **`parked`** (or full re-admit from a defined restart path in P5). +- **True terminal stage** only with terminal RunStatus `completed|failed|blocked|cancelled`. + +### 8.4 `unknown` + reconcile (D33 — bounded wait ≠ fake terminal) + +**Decision:** `unknown` is **reconcilable and non-terminal**. +**Bounded** auto-reconcile / client waits **must not invent** objective provider termination. + +```text +provider ambiguous / crash window + → RunStatus = unknown, RunStage = reconciling + → ReconcileStarted + → poll / Provider.reconcile + → if still running → may return running + executing + → if outcome proven → terminal + ReconcileSettled + → if auto-reconcile budget exhausted + → stop auto-polling + → stay unknown / reconciling + → reservation → orphan-suspect (still occupies maxActiveRuns) + → no final Receipt + → needs-operator (TF_RECONCILE_REQUIRED) +``` + +| Rule | Normative | +|------|-----------| +| Auto-reconcile deadline / max attempts | **Required** (numbers in P5) — bounds **automation**, not truth | +| On auto-reconcile exhaustion | **Do not** force `failed`/`completed`; keep `unknown` + `reconciling` | +| Final Receipt | **Only** after true terminal + D37-compatible end of side effects | +| Checkpoints | Allowed while reconciling; not final Receipt | +| Committed / orphan-suspect slot | **Held** until **D37** `normalRelease` or `forceRelease` only | +| `taskflow_runs(wait)` | Bounded snapshot + needs-operator; never hang forever; never imply work is dead | +| Operator force-release | CoordinatorCommandRecord; guarantee **`operator-overridden`** | + +**Forbidden:** timeout → fake terminal → free slot → new Run while old provider may still mutate workspace. +**Release wording:** always **“via D37 `normalRelease` / `forceRelease` only”** — never weaker shorthands. + +**Attempt:** + +```text +AttemptPrepared + → DispatchIntentRecorded + → submit(idempotencyKey) + → DispatchAcknowledged | rejected | ambiguous + → progress observations + → collect/reconcile → terminal +``` + +Idempotency key from stable Attempt identity. Crash windows as prior (intent retry / reconcile / unknown). + +--- + +## §9. CommandRecord (authority, atomic, re-auth) + +### 9.1 Placement + +CommandRecord is an **immutable authority record inside the same atomic commit batch** as its ControlEvents (log-structured or equivalent). +Unique index `(controlDomainId, commandId)` **rebuildable from the journal**. +Not a mutable side table that can drift from the log. + +### 9.2 Fields + +```text +commandId, requestHash +callerPrincipal, authorizationContextHash +projectId, controlDomainId +status, firstCommitSeq, lastCommitSeq +responseArtifactRef? +recordedAt +``` + +### 9.3 Atomic batch + +1. Durable-write response Artifact (rename/fsync) if any. +2. Atomic commit: CommandRecord + all events; assign contiguous commitSeq. +3. Then RPC accepted. +Orphan blobs GC; never accept with dangling refs. + +### 9.4 Idempotent **execution** vs **disclosure** + +| Case | Behavior | +|------|----------| +| Same commandId + requestHash | **Do not re-execute** side effects | +| Return prior response body | **Only after re-checking** current principal authorization for that project/command class (revocation → deny even if command already ran) | +| Same id, different hash | TF_IDEMPOTENCY_CONFLICT | +| Different principal, same id | TF_CROSS_PRINCIPAL_COMMAND | + +`authorizationContextHash` is **audit metadata** recorded at accept; disclosure still uses **live** authz. + +### 9.5 Artifact access + +`ArtifactRef.digest` is **not a bearer token**. Read requires current principal + project scope + **ledger reachability** (artifact referenced by authorized run/command). + +--- + +## §10. ControlEvent envelope + +```text +eventId, schemaVersion, controlDomainId +streamId, streamSeq, commitSeq +commandId? (FK, non-unique), commandEventIndex? +causationId, correlationId, projectId, recordedAt +payload (small; ArtifactRef/SecretRef for bulk) +``` + +`commitSeq` never renumbered by compaction. + +--- + +## §11. executionSemanticHash & cache + +**boundFragmentHash:** full link audit identity. + +**executionSemanticHash** includes resolved execution descriptor: + +- model id + revision/digest when available +- sampling / reasoning / seed policy +- system prompt / agent body digest +- tool schema versions + allow/deny +- runner + parser buildInfo +- task/input digests + OutputContract +- fragment IR semantic body +- declared resource-read versions/digests + +Authority epoch alone does **not** enter executionSemanticHash. +Class folding only under published equivalence contracts. + +**Reuse iff:** authority valid ∧ lease/version valid ∧ executionSemanticHash equal ∧ artifact integrity ∧ output contract OK ∧ re-Link/validate allows. + +--- + +## §12. ArtifactRef & SecretRef + +```text +ArtifactRef { digest, size, mediaType, storageClass, redactionClass } +SecretRef { secretId, issuer } // NO content digest +``` + +Secrets never in general ArtifactStore as content-addressed blobs. + +--- + +## §13. Receipt & compaction + +### 13.1 Receipt (issued once, immutable) + +At issue time must include: + +```text +controlDomainId, runId, boundPlanHash|boundFragmentHash +eventManifest[] | merkle/hash-chain root over included ControlEvent ids +startCommitSeq, endCommitSeq // bounds only; not sole proof +artifactRefs[] +assurance { … } +buildInfo +``` + +**Compaction must not renumber commitSeq.** +Compaction must not require mutating old Receipts; manifests/roots issued at receipt time remain valid, or Receipt embeds sufficient digests. +Missing blob after retention → `artifactIntegrity: unknown`, not silent verify. + +### 13.2 assurance + +```text +journalContinuity, providerOutcome, artifactIntegrity, provenance +enforcement: { + resolution, mutationMediation, processIsolation, + revocation: admission-only | per-mutation + | { mode: "bounded-latency", maxLatencyMs } // promised +} +// observedRevocationLatencyMs optional on Receipt when applicable +``` + +--- + +## §14. Policy + +```text +effectiveAuthority = host ∩ user ∩ project ∩ invocation +``` + +deny∪ · capability∩ · substitution conflict→deny · catalog≠authority · project cannot enlarge user/host. +Ops: deny | substitute | attenuate. Security unknown fields fail closed. Single canonical hash library. + +--- + +## §15. Enforcement + +| Capability | Meaning | +|------------|---------| +| resolution | contained \| unbound | +| mutationMediation | none \| brokered (per mutation) | +| processIsolation | none \| sandboxed (sealed plan) | +| revocation | admission-only \| per-mutation \| `{mode:"bounded-latency", maxLatencyMs}` | + +Live process kill after revoke remains best-effort; Receipt records promise vs observation when latency mode used. + +Wire to workspace PreparedSandboxPlan / ResourceEnforcer. + +--- + +## §16. ExecutionProvider + +```ts +interface ExecutionProvider { + probe(ctx: ProbeContext): Promise; + prepare(req: PrepareRequest): Promise; + submit(req: SubmitRequest): Promise; + watch(req: WatchRequest): AsyncIterable; + poll(req: PollRequest): Promise; + cancel(req: CancelRequest): Promise; + collect(req: CollectRequest): Promise; + reconcile(req: ReconcileRequest): Promise; +} +``` + +Discriminated unions for accepted | rejected | ambiguous. + +--- + +## §17. Approval (outline; full wire in **P15**) + +0.2.4 `ApprovalRequest` is minimal (`phaseId/message/upstream`). 0.3 is a protocol upgrade. + +### 17.1 Objects + +```text +ApprovalRequest { + approvalRequestId + runId, nodeInstanceId + boundPlanHash | boundFragmentHash + expectedRunVersion + allowedDecisions: approve | reject | edit + owner / audience / requiredPrincipals? + deadline, timeoutPolicy + status: pending | approved | rejected | edited | expired | cancelled + createdAt, decidedAt? + decisionCommandId? // CommandRecord of the decision + editArtifactRef? // when edit +} +``` + +### 17.2 Rules (normative minimum) + +- RunStatus **`paused`** while request `pending`. +- Decision is a **CommandRecord** (idempotent; re-auth on disclosure). +- **CancelRequested first** → later ApprovalDecision CAS fails; request → `cancelled`. +- **ApprovalDecision first** → clears pause; later CancelRequested may still cancel Run. +- Same `expectedRunVersion` race → first commit wins. +- **Timeout → ApprovalRequest `expired` only**; Run → **`blocked`** (never permanent `paused`). Never default approve. +- **edit output** → OutputContract check; no re-link. +- **edit plan** → re-Link required. +- Decider principal/audience in P15; restart + dual-client tests required. + +### 17.3 Approval durability modes (D34 — P15 wire) + +Do **not** collapse “requires” and “allows”: + +| Mode | Link/Admit | Runtime | +|------|------------|---------| +| **`compat-auto-reject`** (default) | OK without durable inbox | Immediate **blocked** (0.2.4 headless) | +| **`durable-optional`** | OK if host/caller lack durable | Prefer durable if negotiated; else auto-reject → blocked | +| **`durable-required`** | If host or caller cannot durable → **`TF_FEATURE_REQUIRED` at Link/Admit** — **not** fake human reject | `paused` + pending until decide/timeout/cancel | + +Negotiation when durable is used: flow mode + ControlHost offers + caller accepts. +History: auto-rejected stays blocked unless resume/re-run; pending may be decided by any authorized durable client. + +**P15** owns TypeBox + CAS — stop expanding master RFC. + +### 17.4 Approval vs maxActiveRuns (D38 — product pin) + +| Situation | Run-slot | +|-----------|----------| +| Durable approval **pending** and provider **quiescent** (no live/ambiguous side effects) | **normalRelease** (park); Run stays `paused` | +| Approval **approved** | Run → **`queued`** (or re-admit path); must **re-reserve** before further execute | +| Approval **rejected/expired** → `blocked` | release if quiescent (terminal park) | +| Cancel-in-flight / `unknown` / non-quiescent | **keep** committed or orphan-suspect slot | +| compat-auto-reject | never holds long-lived approval slot | + +Joint **P15 × P16** tests required. + +--- + +## §18. Negotiation & errors + +```text +protocolMajor, supportedReadSchemas[], supportedWriteSchemas[] +requiredFeatures[], offeredFeatures[], buildInfo +``` + +```text +{ + code, message, + recoveryAction: retry-same-command | retry-new-command | refresh + | reconcile | operator | none, + sideEffects: none | possible | unknown, + commandId?, commitSeq?, controlDomainId?, projectId? +} +``` + +Codes include: TF_PROTOCOL_INCOMPATIBLE, TF_SCHEMA_*, TF_FEATURE_REQUIRED, TF_POLICY_DENIED, TF_AUTHORITY_REVOKED, TF_STALE_VERSION, TF_IDEMPOTENCY_CONFLICT, TF_CROSS_PRINCIPAL_COMMAND, TF_LEGACY_CONFLICT, TF_PROVIDER_AMBIGUOUS, TF_JOURNAL_UNAVAILABLE, TF_DURABILITY_FAILED, TF_CURSOR_EXPIRED, TF_COMMAND_FAILED, TF_BOOTSTRAP_FAILED, **TF_RECONCILE_REQUIRED**. + +**TF_RECONCILE_REQUIRED (P4 pin):** `recoveryAction: operator`, `sideEffects: unknown`. +`taskflow_runs(wait)` / status RPCs return a **normal snapshot** (status=unknown, needs-operator flags) — **not** a transport-level RPC failure. + +Cursor: `minAvailableCommitSeq`, cursor lease/TTL, TF_CURSOR_EXPIRED → checkpoint resync. + +--- + +## §19. D21 parity & P5 + +Public surface sources: schema, docs/skills, examples, exports, tests, promised errors. +Step 1 converts surface → goldens. + +P5 matrix: + +```text +PHASE_TYPES (all, incl. race/expand) +× when | join:any | retry | timeout | expect | budget | cache + | cwd | workspace | shareContext | dynamic def | saved use + | resume | recompute | replay | approval | cancel/abort | cancelled + | detachedCancel → cancelled mapping + | foreground | detached | idempotent:false + | final-output attribution + | score | onBlock:retry | reflexion | tree reduce + | RunStatus × RunStage (incl. unknown + reconciling + needs-operator) + | approval modes: compat-auto-reject | durable-optional | durable-required + | approval park → release slot → re-reserve on approve +``` + +Ternary suites: expand×cache×authority epoch; detached×approval×resume; concurrency reserve×crash×release. + +Fail-at-link: **dev-only**. + +--- + +## §20. Compatibility & rollback + +- 0.2 import: LegacyEvidenceImported only. +- legacy-conflict: 0.3 stops new Attempts. +- Rollback: full before 0.3 writes; after 0.3 writes read-only / lossy export without execute promise. +- **No DomainTransfer.** + +--- + +## §21. Packages & toolchain (D28) + +```text +taskflow-core / taskflow-control / taskflow-daemon +taskflow-mcp-core (thin) / taskflow-hosts / taskflow-cli +taskflow-web (0.3.1+) / host delivery packages +``` + +| Item | Baseline | +|------|----------| +| Node engines | ≥22.19; **@types/node@22** for package typecheck | +| Node CI | **22 + 24 required green**; **26 Current required-to-run, allowed-to-fail** (warn only) until baseline raise ADR | +| TypeScript | **Root TS 7** CLI/typecheck; **taskflow-dsl** isolated **TS 6** compiler API; resolution guard test | +| pnpm | **11.x** stable (`packageManager` field) | +| SQLite | only via **required P14** | + +--- + +## §22. Implementation order + +```text +1. Green trunk +1.a Public 0.2.4 surface → golden plan +1.5 Toolchain: pnpm 11, TS7 root, DSL TS6 isolation, @types/node 22; CI 22/24 required, 26 allowed-to-fail +2. Single scheduler convergence +2.5 P-ADRs P1–P16 (all required before wire freeze) +3. Wire freeze + TypeBox +4. ControlHost extract +5. Project ControlStore + Registry + UserCoordinatorStore +6. Bootstrap + singleton multi-mount +7. Linker + admission + concurrency reserve path +8. ExecutionProviders + reconcile unknown / surface needs-operator +9. Thin MCP + CLI +10. WebUI 0.3.1 +``` + +**Allowed now: 1–2.5.** + +### P-ADR wire-freeze gate (unified — no optional holes) + +> **P1–P16 are all required before wire freeze.** +> Files-only storage is still specified in **P14** (fsync, atomic batch, locks, recovery, compaction). +> **P16 is not foldable into P13.** + +| ID | Topic | +|----|--------| +| P1 | Policy overlay | +| P2 | Empty-policy Exposure | +| P3 | Domain + Registry rebuild + clone/worktree identity | +| P4 | Negotiation + errors + recoveryAction | +| P5 | Phase × feature + RunStatus/Stage + cancelled + unknown/needs-operator bounds + approval modes | +| P6 | Canonical hash + ArtifactRef + SecretRef | +| P7 | Dynamic paths + dual hashes + cache | +| P8 | Enforcement capabilities | +| P9 | legacy-conflict | +| P10 | Rollback tiers | +| P11 | Compaction + cursor + minAvailableCommitSeq | +| P12 | Command batch + re-auth disclosure | +| P13 | Bootstrap / fresh-install / singleton lock / platforms | +| P14 | **ControlStore engine** (always — including files-only) | +| P15 | **Approval protocol** (headless compat + wire status `expired` only) | +| P16 | UserCoordinatorStore: slots≡1, capacity formula, release predicates, CoordinatorCommandRecord, orphan-suspect, crash matrix | + +--- + +## §23. GA acceptance (minimum) + +- [ ] Fresh install, default auto, one run, no manual daemon config +- [ ] Concurrent client start → single writer / singleton attach +- [ ] Stale socket recovery +- [ ] Per-project store; registry multi-mount; standalone+daemon same store +- [ ] Registry wiped → reopen project restores same projectId/domainId from store header +- [ ] maxActiveRuns with slots≡1; capacity formula; N+1 compete +- [ ] committed / orphan-suspect release **only via D37** normalRelease/forceRelease +- [ ] reconcile automation exhausted → unknown + needs-operator + no final Receipt +- [ ] wait returns snapshot + TF_RECONCILE_REQUIRED (not RPC fail) +- [ ] CoordinatorCommandRecord for setMaxActiveRuns / force-release +- [ ] Approval: `paused+parked` releases slot; approve → queued + re-reserve; dual-client + restart + timeout/cancel/approve CAS +- [ ] Command idempotency; disclosure re-auth; ArtifactRef ledger-reachability authz +- [ ] Receipt event manifest survives compaction; bounded-latency assurance verified when used +- [ ] No DomainTransfer; no federated multi-ControlStore workflow +- [ ] Public-surface goldens; cancelled; detachedCancel; approval three modes; RunStage parked +- [ ] P1–P16 ADRs present for shipped wire types + +--- + +## §24. Structured status + +```text +Architecture: Approved +Protocol model (0.3): Approved with conditions (v7.6 MASTER FROZEN) +Wire freeze: Not approved (P1–P16 ADRs + TypeBox) +Steps 1–2.5: Approved to start +Master RFC: FROZEN — no expansion; P-ADRs only +RunStage.parked closed for D38 +Release: D37 only (no weaker shorthand) +``` + +--- + +## §25. Supersession + +**0.3+ clients:** this RFC supersedes local-daemon / competitive-map bullets that require daemon default-off or silent in-process full-power degrade. + +**Retained:** disk authority; UDS/auth; handshake; one admission authority when claimed; no default network listener. + +**Historical 0.2.x:** process-less detached lifecycle remains valid for unupgraded clients. + +--- + +## Appendix A — Vocabulary + +ControlDomain · Project ControlStore · UserCoordinatorStore · CoordinatorLease · ConcurrencyReservation · CoordinatorCommandRecord · maxActiveRuns · orphan-suspect · needs-operator · ControlRegistry · CommandRecord · BoundPlan · BoundFragment · RunStatus · RunStage · reconciling · executionSemanticHash · ArtifactRef · SecretRef · ControlHost · recoveryAction · public surface · compat-auto-reject · durable-optional · durable-required · operator-overridden · noLiveOrAmbiguousSideEffects + +## Appendix B — Explicitly cut from 0.3 + +DomainTransfer · merged project journal · federated multi-ControlStore workflow · silent auto→standalone · GA fail-at-link for public 0.2.4 features · node:sqlite without P14 · commitSeq renumbering · fake terminal after reconcile timeout · hard cross-project budget · committed-slot TTL reclaim · global subagent cap via UserCoordinatorStore · weighted slots in 0.3 + +--- + +*End RFC v7.6. Architecture approved; protocol approved with conditions; Steps 1–2.5 go. **Master RFC FROZEN** — implement via P1–P16 ADRs only.* diff --git a/docs/internal/wire-freeze.md b/docs/internal/wire-freeze.md new file mode 100644 index 00000000..343e54f3 --- /dev/null +++ b/docs/internal/wire-freeze.md @@ -0,0 +1,188 @@ +# 0.3-C TypeBox Wire Freeze(提案) + +> **Status:** PROPOSED — 待 P1–P16 ADR + 本清单评审后冻结 +> **Branch:** `rc/0.3.0-trusted-effects` +> **Date:** 2026-08-12 +> **Normative parent:** [RFC v7.6 快照 §22/§24](./rfc-0.3.0-control-plane-v7.6.md)(步骤 3: "Wire freeze + TypeBox") +> **前置门槛:** [P1–P16 ADR](./p-adrs/README.md) 全部 required,无可选洞(RFC §22.5)。 + +## 1. 目标 + +把 0.3-C Control Plane 的**核心 wire 契约**冻结为 TypeBox schema 清单:标明哪些**复用 TE 现有 schema**、哪些**新增**、哪些**修改**。本文件是设计文档;实际 `.ts` 实现随 S2(taskflow-control 包)落地,落地时以本清单为准并回填文件引用。 + +## 2. TypeBox 约定(沿用 TE 现行风格) + +- 包名 `typebox`(vendored,TE 已依赖);`import { Type } from "typebox"`。 +- 枚举用 `StringEnum(...)`(`src/typebox-helpers.ts`,兼容不支持 anyOf/const 的 provider)。 +- 对象 schema 一律 `{ additionalProperties: false }`(closed contract)。 +- 类型导出用 `Static`;同一 schema 文件是 wire 单一事实来源。 +- 每个顶层 wire doc 带 `schemaVersion`;协商交换 `supportedReadSchemas[] / supportedWriteSchemas[]`(P4)。 +- **禁止** optional holes:wire 必填字段不得用 `Type.Optional` 回避(例外需在对应 P-ADR 显式批准)。 + +## 3. 核心 wire types 清单 + +图例:🟩 **REUSE** = 直接复用 TE 现有 schema(不改动);🟨 **MODIFY** = 在 TE schema 基础上扩展/收紧;🟥 **NEW** = 0.3-C 新增。 + +### 3.1 执行权威层(TE 已有,冻结不动) + +| Type | 标记 | 来源 | 0.3-C 用途 | +|------|------|------|-----------| +| `PathRefSchema`(literalPath/argPath/segments + PathIntent) | 🟩 REUSE | `taskflow-core/src/resources/schema.ts` | BoundPlan/BoundFragment 路径绑定、ArtifactRef 物理路径 | +| `EffectDeclSchema` / EffectIR(closed kinds + labels) | 🟩 REUSE | `taskflow-core/src/effects/schema.ts` | BoundPlan 声明式效果面、Receipt 效果清单 | +| `SecretRef { secretId, issuer? }` | 🟩 REUSE | `taskflow-core/src/effects/types.ts` | 与 RFC §12 一致(RFC 显示 issuer 必填;0.3-C 以 TE 可选 issuer 为兼容超集,binder 填充) | +| `ServiceRef { serviceId, operation? }` | 🟩 REUSE | `taskflow-core/src/effects/types.ts` | 0.3-C 无活 adapter,wire 保留句柄形态 | +| `ConfidentialityLabel / IntegrityLabel` | 🟩 REUSE | `taskflow-core/src/effects/types.ts` | Receipt.assurance.provenance 标签 | +| `ExecutionOwner { runId, phaseId, attemptId, unitId, ancestry }` | 🟩 REUSE | `taskflow-core/src/resources/types.ts` | Run/Attempt 身份基座;0.3-C 映射 nodeInstanceId | +| `ScopedContentEvidence { effectId?, capabilityBindingId?, beforeContentId?, afterContentId? }` | 🟩 REUSE | `taskflow-core/src/resources/types.ts` | ArtifactRef ledger-reachability 证据、Receipt 内容完整性 | +| `WriteIntentRecord / WriteIntentStatus` | 🟩 REUSE | `taskflow-core/src/resources/journal.ts` | execution-authority 层账本(与 ControlStore 账本并存,后者引用前者证据) | +| `HostProbeClassification / HostSupportCell` | 🟩 REUSE | `taskflow-core/src/resources/baseline.ts` | EnforcementCapabilities.processIsolation 证据面(P8) | +| `BoundCapabilityLifetimeSchema` | 🟩 REUSE | `taskflow-core/src/resources/schema.ts` | capability 绑定生命周期(phase/run/external) | +| `canonical-hash.ts`(sha256 库) | 🟩 REUSE | `taskflow-core/src/flowir/canonical-hash.ts` | 唯一哈希库(P6) | + +### 3.2 ControlDomain / ControlStore 层 + +| Type | 标记 | 决策出处 | 说明 | +|------|------|----------|------| +| `ControlDomainId` / `projectId` | 🟥 NEW | P3 | UUID 标量约定;1:1 domain↔project ledger | +| `ControlStoreHeader { projectId, controlDomainId, schemaVersion, directoryBinding }` | 🟥 NEW | P3/P14 | 权威身份来源;registry 重建依据 | +| `ControlRegistryEntry { projectId, controlDomainId, storePath, directoryBinding, mountState, summary? }` | 🟥 NEW | P3 | 非权威发现/投影 | +| `ControlStoreStatus` | 🟥 NEW | P14 | store 健康/恢复状态(正常/恢复中/fail-closed) | +| `BootstrapManifest { controlBinaryPath, controlHome, singletonEndpoint, fencingEpoch }` | 🟥 NEW | P13 | 安装/启动契约 | + +### 3.3 命令与事件(权威叙事) + +| Type | 标记 | 决策出处 | 说明 | +|------|------|----------|------| +| `CommandRecord` | 🟥 NEW | P12 | 不可变权威记录;`(controlDomainId, commandId)` 唯一;与 events 同批原子提交 | +| `ControlEvent`(envelope: eventId, schemaVersion, controlDomainId, streamId, streamSeq, commitSeq, commandId?, commandEventIndex?, causationId, correlationId, projectId, recordedAt, payload) | 🟥 NEW | P12 | 账本事件信封;commitSeq 永不重编号 | +| `CompactionCheckpointEvent { throughCommitSeq }` | 🟥 NEW | P11 | cursor floor 唯一权威形态(journal 内事件) | +| `CursorState { minAvailableCommitSeq, cursorId, leaseExpiresAt }` | 🟥 NEW | P11 | 游标/订阅 | + +### 3.4 计划与运行 + +| Type | 标记 | 决策出处 | 说明 | +|------|------|----------|------| +| `BoundPlan`(template: bindings, SpawnTemplate, savedFlowPins, grantRefs, claims, enforcementCapabilities, dynamicPolicy, boundPlanHash) | 🟥 NEW | P1/P7/P8 | 不可变模板;evidence-not-bearer | +| `BoundFragment`(双 hash + parent 链 + sourceEventId/sourceCommitSeq + fragmentIRHash/fragmentPolicyHash/capabilitySetHash/authorityEpoch) | 🟥 NEW | P7 | 动态 IR 产物 | +| `SpawnTemplate`(allowedAgentClasses, allowedProviderClasses, tool/effect ceilings, maxChildren, maxDepth, budgetShare) | 🟥 NEW | P7 | 动态展开天花板 | +| `RunStatus`(running\|completed\|failed\|paused\|blocked\|cancelled\|unknown) | 🟥 NEW | P5 | StringEnum;unknown 非终态 | +| `RunStage`(received\|compiled\|linked\|queued\|admitted\|executing\|parked\|reconciling\|terminal) | 🟥 NEW | P5 | 正交于 RunStatus | +| `RunSnapshot`(RunStatus + RunStage + slot 状态 + needs-operator 标志 + projectAdmitCommitSeq?) | 🟥 NEW | P5/P16 | wait/status RPC 正常返回形态 | + +### 3.5 审批 + +| Type | 标记 | 决策出处 | 说明 | +|------|------|----------|------| +| `ApprovalRequest`(approvalRequestId, runId, nodeInstanceId, boundPlanHash\|boundFragmentHash, expectedRunVersion, allowedDecisions, owner/audience, deadline, timeoutPolicy, status 含 expired, decisionCommandId?, editArtifactRef?) | 🟥 NEW | P15 | 0.2.4 形态的协议升级 | +| `ApprovalDecisionCommand`(= CommandRecord.kind: "approval.decide" + decision + expectedRunVersion CAS) | 🟥 NEW | P15 | 决策=命令;CAS first-commit-wins | +| `ApprovalMode`(compat-auto-reject \| durable-optional \| durable-required) | 🟥 NEW | P15 | D34 | + +### 3.6 并发协调(UserCoordinatorStore) + +| Type | 标记 | 决策出处 | 说明 | +|------|------|----------|------| +| `CoordinatorLease { holderId, fencingEpoch, endpoint, expiresAt }` | 🟥 NEW | P16 | singleton 租约 | +| `ConcurrencyReservation`(reservationId, state 含 orphan-suspect, slots=1, projectId/projectControlDomainId/runId, projectAdmitCommitSeq, attemptId?, providerJobHandle?, coordinatorEpoch, reservedExpiresAt?, renewedAt?) | 🟥 NEW | P16 | 全局容量;committed 永不 TTL 释放 | +| `CoordinatorCommandRecord`(commandId, requestHash, callerPrincipal, kind: setMaxActiveRuns\|forceRelease, firstCommitSeq, lastCommitSeq, status) | 🟥 NEW | P16 | 窄命令权威 | +| `CapacitySnapshot { maxActiveRuns, active, reserved, committed, orphanSuspect }` | 🟥 NEW | P16 | 计量/统计 | + +### 3.7 策略与执行能力 + +| Type | 标记 | 决策出处 | 说明 | +|------|------|----------|------| +| `PolicyBundle { hostCeiling, userCeiling?, projectCeiling?, invocationCeiling? }` | 🟥 NEW | P1/P2 | 空策略=显式继承+fail-closed 兜底;authorizationContextHash 记录决策 | +| `EnforcementCapabilities { resolution, mutationMediation, processIsolation, revocation }` | 🟥 NEW | P8 | 正交四维;取值绑定 TE 证据面(host probe / resources/*) | +| `CapabilitySetHash / policyHash` | 🟥 NEW | P7 | BoundFragment 携带 | + +### 3.8 证据与结果 + +| Type | 标记 | 决策出处 | 说明 | +|------|------|----------|------| +| `ArtifactRef { digest, size, mediaType, storageClass, redactionClass }` | 🟥 NEW | P6 | digest 非 bearer;ledger-reachability 授权 | +| `Receipt`(controlDomainId, runId, boundPlanHash\|boundFragmentHash, eventManifest[]\|hash-chain root, startCommitSeq, endCommitSeq, artifactRefs[], assurance, buildInfo) | 🟥 NEW | P11/P14 | 一次性、不可变;compaction 后仍有效 | +| `ReceiptAssurance { journalContinuity, providerOutcome, artifactIntegrity, provenance, enforcement }` | 🟥 NEW | P8/P11 | enforcement promise vs observation(observedRevocationLatencyMs 可选) | + +### 3.9 传输与错误 + +| Type | 标记 | 决策出处 | 说明 | +|------|------|----------|------| +| `NegotiationHandshake`(protocolMajor, supportedReadSchemas[], supportedWriteSchemas[], requiredFeatures[], offeredFeatures[], buildInfo) | 🟥 NEW | P4 | 握手 | +| `ErrorEnvelope`(code, message, recoveryAction, sideEffects, commandId?, commitSeq?, controlDomainId?, projectId?) | 🟥 NEW | P4 | 统一错误;TF_* code 全集见 P4 | +| `ExecutionProvider` DTO 组(probe/prepare/submit/watch/poll/cancel/collect/reconcile 的 discriminated unions: accepted\|rejected\|ambiguous) | 🟥 NEW | RFC §16 | 0.3-C 唯一实现 = TE resources/* 执行面 | + +## 4. 复用 vs 新增 vs 修改汇总 + +| 类别 | 数量 | 内容 | +|------|------|------| +| 🟩 复用 TE(不动) | 11 | PathRef / EffectIR / SecretRef / ServiceRef / labels / ExecutionOwner / ScopedContentEvidence / WriteIntentRecord / HostProbe / BoundCapabilityLifetime / canonical-hash | +| 🟨 修改 TE | 0 | 本 freeze **不改任何 TE schema**(TE 是执行权威层,0.3-C 只引用其证据;新增 wire 全部落在 0.3-C 新包) | +| 🟥 新增(0.3-C) | ~33 | 见 §3.2–§3.9 | + +## 5. 冻结规则 + +1. **P1–P16 全部 required**(RFC §22.5);本清单的每行必须能在对应 P-ADR 找到出处(出处列已标)。 +2. **无 optional holes**:wire 必填字段不得用 `Type.Optional` 规避;`BoundPlan.enforcementCapabilities`、`ConcurrencyReservation.slots`、`CommandRecord.requestHash` 等为必填。 +3. **D21 兼容**:0.2.4 公共表面 golden 不变(P5);`ApprovalRequest` 从 0.2.4 最小形态升级为 P15 协议——升级路径显式,不静默降级。 +4. **扩展策略**:冻结后只允许 **additive** 变更(新字段 + schemaVersion bump);旧 reader 见未知 schema → `TF_SCHEMA_UNSUPPORTED`(P4),绝不静默重解析。 +5. **哈希纪律**:仅 sha256(P6);`executionSemanticHash` 不含 authority epoch。 +6. **位置**:TE schema 留在 `taskflow-core`;0.3-C wire types 落在 S2 新建的 `taskflow-control` 包(RFC §21 包结构),import TE schema 为只读依赖。 +7. **Trusted-local-disk 限制**:P14 信任边界(整根一致回滚排除)进入 release notes 与 operator 文档,不随 wire 冻结悄悄扩大承诺。 + +## 6. 批准动作 + +- [ ] P1–P16 ADR 评审通过(13 Accepted + 3 Proposed: P8/P14/P15 重写版) +- [ ] 本清单与 P-ADR 出处核对无遗漏、无额外类型 +- [x] S2 落地 `taskflow-control` 包时回填每个类型的实际文件路径,并保持本文件同步(见 §7) + +## 7. S2 落地回填(文件引用) + +> `packages/taskflow-control/`(RFC §21 包结构)。TE schema 只读导入;`resources/*` +> 因 workspace-capability 冻结不导出(`smoke-packed-packages.mjs` 断言 +> `taskflow-core/resources/index` 不可解析),故镜像于 `src/schema/te-mirrors.ts`, +> TE 仍为权威。 + +### 7.1 🟩 REUSE — TE 只读依赖 / 镜像 + +| Type | 落地 | 说明 | +|------|------|------| +| `PathRefSchema`(literalPath/argPath/segments + PathIntent) | `src/schema/te-mirrors.ts` | 镜像(TE `resources/schema.ts` 不导出) | +| `EffectDeclSchema` / EffectIR | `taskflow-core/effects/schema` → `src/schema/index.ts` re-export | 只读导入 | +| `SecretRef { secretId, issuer? }` | `taskflow-core/effects/types`(类型) | 只读导入 | +| `ServiceRef { serviceId, operation? }` | `taskflow-core/effects/types`(类型) | 只读导入 | +| `ConfidentialityLabel / IntegrityLabel` | `taskflow-core/effects/types`(类型) | Receipt.assurance.provenance | +| `ExecutionOwner { runId, phaseId, attemptId, unitId, ancestry }` | `src/schema/te-mirrors.ts` | 镜像 | +| `ScopedContentEvidence` | `src/schema/te-mirrors.ts` | 镜像 | +| `WriteIntentRecord / WriteIntentStatus` | `src/schema/te-mirrors.ts` | 镜像 | +| `HostProbeClassification` | `src/schema/te-mirrors.ts` | EnforcementCapabilities 证据面 | +| `BoundCapabilityLifetimeSchema` | `src/schema/te-mirrors.ts` | 镜像 | +| canonical-hash(sha256 库) | `taskflow-core/flowir/canonical-hash` → `src/schema/index.ts` re-export | 只读导入(P6) | + +### 7.2 🟥 NEW — wire types(全部在 `packages/taskflow-control/src/schema/`) + +| 类别 | 文件 | +|------|------| +| `ControlDomainId`/`projectId`、`ControlStoreHeader`、`ControlRegistryEntry`、`ControlStoreStatus`、`BootstrapManifest` | `header.ts` | +| `CommandRecord`、`ControlEvent`(envelope + payload union)、`CompactionCheckpointEvent`、`CursorState` | `commands.ts` | +| `BoundPlan`、`BoundFragment`、`SpawnTemplate` | `plan.ts` | +| `RunStatus`、`RunStage`、`RunSnapshot` | `run.ts` | +| `ApprovalRequest`、`ApprovalDecisionCommand`、`ApprovalMode` | `approval.ts` | +| `CoordinatorLease`、`ConcurrencyReservation`(+ D2/D3 不变量)、`CoordinatorCommandRecord`、`CapacitySnapshot` | `coordinator.ts` | +| `PolicyBundle`、`EnforcementCapabilities` | `policy.ts` | +| `ArtifactRef`、`Receipt`、`ReceiptAssurance` | `evidence.ts` | +| `NegotiationHandshake`、`ErrorEnvelope` + TF_* 全集、ExecutionProvider DTO 组(accepted\|rejected\|ambiguous) | `transport.ts` | +| 公共标量:`CONTROL_WIRE_SCHEMA_VERSION`、UUID/SHA-256/CanonicalHashRef | `common.ts` | + +### 7.3 ControlHost 实现 + +| 模块 | 文件 | +|------|------| +| controlMode 解析 + fail-closed 决策 | `src/modes.ts` | +| 用户 singleton lock/endpoint/fencing + stale 恢复 + 释放 | `src/singleton.ts` | +| hello-before-RPC 协商门 | `src/hello.ts` | +| TE 唯一执行权威适配(P8 能力映射) | `src/te-provider.ts` | +| ControlHost(auto/coordinated/standalone 契约 + dispatch) | `src/control-host.ts` | +| 统一错误信封(P4 全集 + ControlError) | `src/errors.ts` | + +### 7.4 单元测试(`packages/taskflow-control/test/`) + +`modes.test.ts`(模式选择/fail-closed)、`singleton.test.ts`(fencing/竞态/stale endpoint 恢复/释放)、`hello.test.ts`(hello-before-RPC)、`te-provider.test.ts`(TE 委托 + 仅 TE 权威)、`control-host.test.ts`(模式契约 + 集成)、`schema.test.ts`(closed contract + schemaVersion + 枚举)。 diff --git a/examples/trusted-effects-write.json b/examples/trusted-effects-write.json new file mode 100644 index 00000000..220846a6 --- /dev/null +++ b/examples/trusted-effects-write.json @@ -0,0 +1,34 @@ +{ + "name": "trusted-effects-write", + "description": "0.3 Trusted Effects MVP demo: script phase declares fs.write; content is stdout; the resources transaction is the only finalizer (no LLM).", + "phases": [ + { + "id": "write-report", + "type": "script", + "run": [ + "node", + "-e", + "process.stdout.write('# Trusted Effects report\\n\\nCommitted via resource intent.\\n')" + ], + "idempotent": false, + "effects": [ + { + "id": "report", + "kind": "fs.write", + "purpose": "write final report", + "confidentiality": "internal", + "integrity": "project", + "target": { + "kind": "path", + "path": { + "workspace": "project", + "subpath": { "literalPath": "out/report.md" }, + "intent": "create-file" + } + } + } + ], + "final": true + } + ] +} diff --git a/package.json b/package.json index a2427a5e..23024ebb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pi-taskflow-monorepo", - "version": "0.2.10", + "version": "0.3.0-beta.1", "private": true, "description": "Monorepo for the Taskflow engine and DSL, host adapters, delivery packages, documentation, and the private CharterArc experiment.", "type": "module", @@ -9,7 +9,7 @@ }, "packageManager": "pnpm@11.17.0", "scripts": { - "build": "pnpm run build:skills && pnpm --filter taskflow-core build && pnpm --filter charterarc build && pnpm --filter taskflow-mcp-core build && pnpm --filter taskflow-hosts build && pnpm --filter taskflow-dsl build && pnpm --filter pi-taskflow build && pnpm --filter codex-taskflow build && pnpm --filter claude-taskflow build && pnpm --filter opencode-taskflow build && pnpm --filter grok-taskflow build && pnpm --filter hermes-taskflow build", + "build": "pnpm run build:skills && pnpm --filter taskflow-core build && pnpm --filter charterarc build && pnpm --filter taskflow-control build && pnpm --filter taskflow-mcp-core build && pnpm --filter taskflow-hosts build && pnpm --filter taskflow-dsl build && pnpm --filter pi-taskflow build && pnpm --filter codex-taskflow build && pnpm --filter claude-taskflow build && pnpm --filter opencode-taskflow build && pnpm --filter grok-taskflow build && pnpm --filter hermes-taskflow build", "build:website": "cd website && npm run build", "build:skills": "node scripts/build-skills.mjs", "test:pack": "pnpm run build && node scripts/smoke-packed-packages.mjs", @@ -48,6 +48,7 @@ "@earendil-works/pi-tui": "^0.83.0", "@types/node": "^26", "charterarc": "workspace:*", + "taskflow-control": "workspace:*", "taskflow-core": "workspace:*", "taskflow-hosts": "workspace:*", "typebox": "^1.3.10", diff --git a/packages/claude-taskflow/package.json b/packages/claude-taskflow/package.json index faf00742..b238d649 100644 --- a/packages/claude-taskflow/package.json +++ b/packages/claude-taskflow/package.json @@ -1,6 +1,6 @@ { "name": "claude-taskflow", - "version": "0.2.10", + "version": "0.3.0-beta.1", "description": "Run taskflow on Claude Code: the npm tarball provides the Claude subagent runner and MCP server; the plugin is distributed through the repository marketplace.", "keywords": [ "claude", diff --git a/packages/claude-taskflow/plugin/.claude-plugin/plugin.json b/packages/claude-taskflow/plugin/.claude-plugin/plugin.json index b31669c8..2e61eb70 100644 --- a/packages/claude-taskflow/plugin/.claude-plugin/plugin.json +++ b/packages/claude-taskflow/plugin/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "taskflow", - "version": "0.2.10", + "version": "0.3.0-beta.1", "description": "Declarative, verifiable DAG orchestration for Claude Code subagents — fan-out, gates, loops, tournaments, approvals, resumable runs, and saveable commands, with intermediate transcripts kept out of your context.", "author": { "name": "heggria", diff --git a/packages/claude-taskflow/plugin/.mcp.json b/packages/claude-taskflow/plugin/.mcp.json index 1d4824e2..31697ba6 100644 --- a/packages/claude-taskflow/plugin/.mcp.json +++ b/packages/claude-taskflow/plugin/.mcp.json @@ -2,7 +2,7 @@ "mcpServers": { "taskflow": { "command": "npx", - "args": ["-y", "-p", "claude-taskflow@0.2.10", "claude-taskflow-mcp"] + "args": ["-y", "-p", "claude-taskflow@0.3.0-beta.1", "claude-taskflow-mcp"] } } } diff --git a/packages/claude-taskflow/plugin/skills/taskflow/SKILL.md b/packages/claude-taskflow/plugin/skills/taskflow/SKILL.md index 62c31bfa..a6666836 100644 --- a/packages/claude-taskflow/plugin/skills/taskflow/SKILL.md +++ b/packages/claude-taskflow/plugin/skills/taskflow/SKILL.md @@ -28,6 +28,7 @@ runs as an isolated `claude -p` session. | `taskflow_trace` | Read a run's append-only event timeline. | | `taskflow_replay` | Replay recorded decisions offline with optional overrides — zero model calls. | | `taskflow_why_stale` | Explain why phases are stale from observed and declared dependencies — zero tokens. | +| `taskflow_why_effect` | Explain why a declared effect is authorized, from the durable resource-intent ledger (`runId` + `effectId`, optional `phaseId`; `json: true` for the full record). Declaration alone is not authorization — zero tokens, read-only. | | `taskflow_recompute` | Compute the stale frontier (**dry-run only** over MCP; never executes phases). | | `taskflow_reconcile_workspace` | After inspection/repair, accept a failed resolve-only workspace. Requires host `TASKFLOW_WORKSPACE_RECONCILE_MODE=explicit`; never restores files. | | `taskflow_save` | Save a reusable flow and optional library metadata. | @@ -218,6 +219,7 @@ back cleanly: precedence is `define` (inline) > `defineFile` (disk) > `name` | `timeout` | max ms per subagent call (>= 1000). On expiry the subagent is aborted and the phase fails with a `timedOut` marker — deterministic, **never retried**. Caps EACH call, so a map/parallel/race/loop/tournament phase's wall time is per item/iteration/variant (a tournament's judge call gets its own cap too). Script phases keep their own child-process timeout (default 60s, max 300s). Not supported on approval/flow/expand. Pair with `optional: true` + a downstream fallback phase to degrade instead of failing the run. | | `expect` | output contract for `output: "json"` phases (agent/gate/reduce/loop): a JSON-Schema-like shape `{type, properties, required, items, enum}` validated the moment the subagent finishes. A violation fails the phase with per-path diagnostics (e.g. `$.score: required key is missing`) and is retryable under the phase's explicit `retry`. `verify`/`compile` also statically warn when a `{steps.X.json.field}` ref names a field absent from X's declared contract. | | `idempotent` | side-effect classification. Default `true` (safe to cache + auto-retry). Set `false` on phases with **irreversible side effects** (webhook POSTs, deploys, DB writes, file mutations): transient provider errors are **not** auto-retried (an explicit `retry{}` IS still honored — it's your declaration that repeats are acceptable) and the result is **never cached** in any scope (within-run resume, cross-run, `incremental` — the phase re-runs every time). The phase state records `sideEffect: true` (rendered as ⚡). | +| `effects` | **[0.3 Trusted Effects]** declared side-effect bag for this phase — typed `fs.read` / `fs.write` / `fs.delete` / `secret.read` / `service.call` declarations with PathRef/SecretRef/ServiceRef targets and optional confidentiality/integrity labels. See **Trusted Effects** below. | | `optional` | fail-soft — a failed/blocked phase won't abort the run; downstream sees empty output. Pair with a fallback phase guarded by `when`. | | `cache` | per-phase reuse policy (`run-only` default / `cross-run` / `off`). See `configuration.md` §8. | @@ -507,6 +509,82 @@ output is exact. "input": "{steps.analyze.output}", "dependsOn": ["analyze"], "final": true } ``` +### Trusted Effects (`effects[]` — declared side effects, 0.3) + +> **One-line authority: the model proposes content; the resources runtime is +> the only commit authority.** A phase declares *what* it intends to touch; +> for admitted declared `fs.write` targets the runtime runs the +> resource-controlled **file transaction** — durable snapshot → persistent +> lease → journal intent/permit → stage → **Commit** or **Restore+Reject** — +> and no other code finalizes declared content. + +Trusted Effects (0.3 MVP) adds an optional `effects[]` bag to **any** phase: a +closed vocabulary of typed side-effect declarations. `verify` / `compile` +statically check the bag (unknown kinds, malformed targets, and illegal +label flows surface as `[effects]` issues), and a run admits every declared +target through PathRef resolution — lease, durable intent, mutation permit — +**before** the phase body executes. Start from the runnable example +**`examples/trusted-effects-write.json`** (a `script` phase that declares one +`fs.write` and commits it via the resource transaction — no LLM involved). + +Each effect: + +| field | meaning | +|-------|---------| +| `id` | stable id within the flow — the handle the why-* audit explains | +| `kind` | `fs.read` · `fs.write` · `fs.delete` · `secret.read` · `service.call` | +| `target` | `{ kind: "path", path: }`, or the `secret` / `service` handle shapes | +| `confidentiality` | optional label `public` · `internal` · `secret` — a higher label must not flow to a lower sink | +| `integrity` | optional label `untrusted` · `project` · `verified` — lower integrity must not overwrite higher | +| `purpose` | free-text note surfaced by the why-* explainers (**not** authority) | + +**PathRef shape** — the FS target, always relative to a workspace scope: + +```jsonc +"target": { + "kind": "path", + "path": { + "workspace": "project", // scope the path resolves in + "subpath": { "literalPath": "out/report.md" }, // or { "argPath": "out" } / { "segments": [ { "segment": "out" } ] } + "intent": "create-file" // create-file | create-directory | existing-file | existing-directory | executable + } +} +``` + +**Phase output is the payload.** With one declared `fs.write`, the phase's +output becomes the staged file content (see the example: `process.stdout.write` += the report). With several `fs.write` effects, the phase must emit JSON +mapping each effect id to its content (`{ "report": "…", "backup": "…" }`). +Commit promotes each file atomically; a later failure restores every admitted +file to its durable pre-state, and a direct write by the agent/script to a +**declared final path** is detected and restored — only the resource +transaction may finalize declared content. + +**Only `fs.write` has a bound runtime backend in this cut.** The other kinds +are valid to declare and verify, but fail **closed** (no bound resource +backend): `fs.delete` is not supported by the file transaction, and +`secret.read` / `service.call` have no vault/network adapters in 0.3 — do not +author a flow expecting them to do anything yet. + +**Audit with `taskflow_why_effect` (zero tokens, read-only).** Pass `runId` + +`effectId` (add `phaseId` to disambiguate a repeated id; `json: true` for the +full record) to explain a declared effect's authorization and lifecycle from +the durable resource-intent ledger — principal, capability binding, intent id, +journal status, and lifecycle (`declared` / `staged` / `committed` / +`rejected` / `unknown`). **Declaration alone is not authorization**: if no +durable intent admitted the effect for this run/phase, `authorized.allowed` is +`false` (fail-closed). + +**What this is NOT (honesty baseline):** + +- **No FileBroker sandbox.** Every host's PathRef support is *resolve-only*; + this is not an OS sandbox, and no host claims a FileBroker guarantee. +- **Undeclared paths are not protected.** Only writes to *declared* final + targets are detected and restored; writes outside the declared set remain + host-policy dependent. +- **`secret.read` / `service.call` are type-only fail-closed** (see above) — + valid declarations, no backend in the MVP. + ### Race phases (first success wins) A `race` phase runs static `branches[]` concurrently and **returns the first diff --git a/packages/claude-taskflow/test/mcp-server.test.ts b/packages/claude-taskflow/test/mcp-server.test.ts index 181bf863..84275ecf 100644 --- a/packages/claude-taskflow/test/mcp-server.test.ts +++ b/packages/claude-taskflow/test/mcp-server.test.ts @@ -45,7 +45,7 @@ test("claude mcp: initialize returns the protocol version + serverInfo", async ( assert.equal(res.result.protocolVersion, "2025-06-18"); assert.ok(res.result.capabilities.tools, "advertises tools capability"); assert.equal(res.result.serverInfo.name, "taskflow-claude"); - assert.equal(res.result.serverInfo.version, "0.2.10"); + assert.equal(res.result.serverInfo.version, "0.3.0-beta.1"); }); test("claude mcp: tools/list exposes the same taskflow tools as codex", async () => { @@ -53,7 +53,7 @@ test("claude mcp: tools/list exposes the same taskflow tools as codex", async () const names = res.result.tools.map((t: any) => t.name); assert.deepEqual( names.sort(), - ["taskflow_analytics", "taskflow_compile", "taskflow_lint", "taskflow_list", "taskflow_peek", "taskflow_plan", "taskflow_recompute", "taskflow_reconcile_workspace", "taskflow_replay", "taskflow_resume", "taskflow_run", "taskflow_runs", "taskflow_save", "taskflow_search", "taskflow_show", "taskflow_trace", "taskflow_verify", "taskflow_version", "taskflow_why_stale"], + ["taskflow_analytics", "taskflow_compile", "taskflow_lint", "taskflow_list", "taskflow_peek", "taskflow_plan", "taskflow_recompute", "taskflow_reconcile_workspace", "taskflow_replay", "taskflow_resume", "taskflow_run", "taskflow_runs", "taskflow_save", "taskflow_search", "taskflow_show", "taskflow_trace", "taskflow_verify", "taskflow_version", "taskflow_why_effect", "taskflow_why_stale"], ); for (const t of res.result.tools) { assert.equal(typeof t.description, "string"); @@ -83,6 +83,6 @@ test("claude mcp: makeToolHandlers exposes the tools", () => { const tools = makeToolHandlers(process.cwd()); assert.deepEqual( Object.keys(tools).sort(), - ["taskflow_analytics", "taskflow_compile", "taskflow_lint", "taskflow_list", "taskflow_peek", "taskflow_plan", "taskflow_recompute", "taskflow_reconcile_workspace", "taskflow_replay", "taskflow_resume", "taskflow_run", "taskflow_runs", "taskflow_save", "taskflow_search", "taskflow_show", "taskflow_trace", "taskflow_verify", "taskflow_version", "taskflow_why_stale"], + ["taskflow_analytics", "taskflow_compile", "taskflow_lint", "taskflow_list", "taskflow_peek", "taskflow_plan", "taskflow_recompute", "taskflow_reconcile_workspace", "taskflow_replay", "taskflow_resume", "taskflow_run", "taskflow_runs", "taskflow_save", "taskflow_search", "taskflow_show", "taskflow_trace", "taskflow_verify", "taskflow_version", "taskflow_why_effect", "taskflow_why_stale"], ); }); diff --git a/packages/codex-taskflow/package.json b/packages/codex-taskflow/package.json index d20164a6..f0f4adf1 100644 --- a/packages/codex-taskflow/package.json +++ b/packages/codex-taskflow/package.json @@ -1,6 +1,6 @@ { "name": "codex-taskflow", - "version": "0.2.10", + "version": "0.3.0-beta.1", "description": "Run taskflow on OpenAI Codex: the npm tarball provides the Codex subagent runner and MCP server; the plugin is distributed through the repository marketplace.", "keywords": [ "codex", diff --git a/packages/codex-taskflow/plugin/.codex-plugin/plugin.json b/packages/codex-taskflow/plugin/.codex-plugin/plugin.json index 1e45ae97..60bb65c6 100644 --- a/packages/codex-taskflow/plugin/.codex-plugin/plugin.json +++ b/packages/codex-taskflow/plugin/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "taskflow", - "version": "0.2.10", + "version": "0.3.0-beta.1", "description": "Declarative, verifiable DAG orchestration for Codex subagents — fan-out, gates, loops, tournaments, approvals, resumable runs, and saveable commands, with intermediate transcripts kept out of your context.", "author": { "name": "heggria", diff --git a/packages/codex-taskflow/plugin/.mcp.json b/packages/codex-taskflow/plugin/.mcp.json index 1d0f3b9e..2d99b912 100644 --- a/packages/codex-taskflow/plugin/.mcp.json +++ b/packages/codex-taskflow/plugin/.mcp.json @@ -2,7 +2,7 @@ "mcpServers": { "taskflow": { "command": "npx", - "args": ["-y", "-p", "codex-taskflow@0.2.10", "codex-taskflow-mcp"], + "args": ["-y", "-p", "codex-taskflow@0.3.0-beta.1", "codex-taskflow-mcp"], "tool_timeout_sec": 1800 } } diff --git a/packages/codex-taskflow/plugin/skills/taskflow/SKILL.md b/packages/codex-taskflow/plugin/skills/taskflow/SKILL.md index fd07d528..8503249d 100644 --- a/packages/codex-taskflow/plugin/skills/taskflow/SKILL.md +++ b/packages/codex-taskflow/plugin/skills/taskflow/SKILL.md @@ -27,6 +27,7 @@ the Codex form (`taskflow_verify`). | `taskflow_trace` | Read a run's append-only event timeline. | | `taskflow_replay` | Replay recorded decisions offline with optional overrides — zero model calls. | | `taskflow_why_stale` | Explain why phases are stale from observed and declared dependencies — zero tokens. | +| `taskflow_why_effect` | Explain why a declared effect is authorized, from the durable resource-intent ledger (`runId` + `effectId`, optional `phaseId`; `json: true` for the full record). Declaration alone is not authorization — zero tokens, read-only. | | `taskflow_recompute` | Compute the stale frontier (**dry-run only** over MCP; never executes phases). | | `taskflow_reconcile_workspace` | After inspection/repair, accept a failed resolve-only workspace. Requires host `TASKFLOW_WORKSPACE_RECONCILE_MODE=explicit`; never restores files. | | `taskflow_save` | Save a reusable flow and optional library metadata. | @@ -213,6 +214,7 @@ back cleanly: precedence is `define` (inline) > `defineFile` (disk) > `name` | `timeout` | max ms per subagent call (>= 1000). On expiry the subagent is aborted and the phase fails with a `timedOut` marker — deterministic, **never retried**. Caps EACH call, so a map/parallel/race/loop/tournament phase's wall time is per item/iteration/variant (a tournament's judge call gets its own cap too). Script phases keep their own child-process timeout (default 60s, max 300s). Not supported on approval/flow/expand. Pair with `optional: true` + a downstream fallback phase to degrade instead of failing the run. | | `expect` | output contract for `output: "json"` phases (agent/gate/reduce/loop): a JSON-Schema-like shape `{type, properties, required, items, enum}` validated the moment the subagent finishes. A violation fails the phase with per-path diagnostics (e.g. `$.score: required key is missing`) and is retryable under the phase's explicit `retry`. `verify`/`compile` also statically warn when a `{steps.X.json.field}` ref names a field absent from X's declared contract. | | `idempotent` | side-effect classification. Default `true` (safe to cache + auto-retry). Set `false` on phases with **irreversible side effects** (webhook POSTs, deploys, DB writes, file mutations): transient provider errors are **not** auto-retried (an explicit `retry{}` IS still honored — it's your declaration that repeats are acceptable) and the result is **never cached** in any scope (within-run resume, cross-run, `incremental` — the phase re-runs every time). The phase state records `sideEffect: true` (rendered as ⚡). | +| `effects` | **[0.3 Trusted Effects]** declared side-effect bag for this phase — typed `fs.read` / `fs.write` / `fs.delete` / `secret.read` / `service.call` declarations with PathRef/SecretRef/ServiceRef targets and optional confidentiality/integrity labels. See **Trusted Effects** below. | | `optional` | fail-soft — a failed/blocked phase won't abort the run; downstream sees empty output. Pair with a fallback phase guarded by `when`. | | `cache` | per-phase reuse policy (`run-only` default / `cross-run` / `off`). See `configuration.md` §8. | @@ -502,6 +504,82 @@ output is exact. "input": "{steps.analyze.output}", "dependsOn": ["analyze"], "final": true } ``` +### Trusted Effects (`effects[]` — declared side effects, 0.3) + +> **One-line authority: the model proposes content; the resources runtime is +> the only commit authority.** A phase declares *what* it intends to touch; +> for admitted declared `fs.write` targets the runtime runs the +> resource-controlled **file transaction** — durable snapshot → persistent +> lease → journal intent/permit → stage → **Commit** or **Restore+Reject** — +> and no other code finalizes declared content. + +Trusted Effects (0.3 MVP) adds an optional `effects[]` bag to **any** phase: a +closed vocabulary of typed side-effect declarations. `verify` / `compile` +statically check the bag (unknown kinds, malformed targets, and illegal +label flows surface as `[effects]` issues), and a run admits every declared +target through PathRef resolution — lease, durable intent, mutation permit — +**before** the phase body executes. Start from the runnable example +**`examples/trusted-effects-write.json`** (a `script` phase that declares one +`fs.write` and commits it via the resource transaction — no LLM involved). + +Each effect: + +| field | meaning | +|-------|---------| +| `id` | stable id within the flow — the handle the why-* audit explains | +| `kind` | `fs.read` · `fs.write` · `fs.delete` · `secret.read` · `service.call` | +| `target` | `{ kind: "path", path: }`, or the `secret` / `service` handle shapes | +| `confidentiality` | optional label `public` · `internal` · `secret` — a higher label must not flow to a lower sink | +| `integrity` | optional label `untrusted` · `project` · `verified` — lower integrity must not overwrite higher | +| `purpose` | free-text note surfaced by the why-* explainers (**not** authority) | + +**PathRef shape** — the FS target, always relative to a workspace scope: + +```jsonc +"target": { + "kind": "path", + "path": { + "workspace": "project", // scope the path resolves in + "subpath": { "literalPath": "out/report.md" }, // or { "argPath": "out" } / { "segments": [ { "segment": "out" } ] } + "intent": "create-file" // create-file | create-directory | existing-file | existing-directory | executable + } +} +``` + +**Phase output is the payload.** With one declared `fs.write`, the phase's +output becomes the staged file content (see the example: `process.stdout.write` += the report). With several `fs.write` effects, the phase must emit JSON +mapping each effect id to its content (`{ "report": "…", "backup": "…" }`). +Commit promotes each file atomically; a later failure restores every admitted +file to its durable pre-state, and a direct write by the agent/script to a +**declared final path** is detected and restored — only the resource +transaction may finalize declared content. + +**Only `fs.write` has a bound runtime backend in this cut.** The other kinds +are valid to declare and verify, but fail **closed** (no bound resource +backend): `fs.delete` is not supported by the file transaction, and +`secret.read` / `service.call` have no vault/network adapters in 0.3 — do not +author a flow expecting them to do anything yet. + +**Audit with `taskflow_why_effect` (zero tokens, read-only).** Pass `runId` + +`effectId` (add `phaseId` to disambiguate a repeated id; `json: true` for the +full record) to explain a declared effect's authorization and lifecycle from +the durable resource-intent ledger — principal, capability binding, intent id, +journal status, and lifecycle (`declared` / `staged` / `committed` / +`rejected` / `unknown`). **Declaration alone is not authorization**: if no +durable intent admitted the effect for this run/phase, `authorized.allowed` is +`false` (fail-closed). + +**What this is NOT (honesty baseline):** + +- **No FileBroker sandbox.** Every host's PathRef support is *resolve-only*; + this is not an OS sandbox, and no host claims a FileBroker guarantee. +- **Undeclared paths are not protected.** Only writes to *declared* final + targets are detected and restored; writes outside the declared set remain + host-policy dependent. +- **`secret.read` / `service.call` are type-only fail-closed** (see above) — + valid declarations, no backend in the MVP. + ### Race phases (first success wins) A `race` phase runs static `branches[]` concurrently and **returns the first diff --git a/packages/codex-taskflow/test/e2e-codex.mts b/packages/codex-taskflow/test/e2e-codex.mts index e5113966..9abc7e3a 100644 --- a/packages/codex-taskflow/test/e2e-codex.mts +++ b/packages/codex-taskflow/test/e2e-codex.mts @@ -12,7 +12,14 @@ */ import assert from "node:assert/strict"; -import { executeTaskflow, type RuntimeDeps } from "taskflow-core"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { + executeTaskflow, + whyEffectFromDurableJournal, + type RuntimeDeps, +} from "taskflow-core"; import { codexSubagentRunner } from "taskflow-hosts/codex"; import type { AgentConfig } from "taskflow-core"; import type { Taskflow } from "taskflow-core"; @@ -28,7 +35,7 @@ const AGENTS: AgentConfig[] = [ }, ]; -function mkState(def: Taskflow): RunState { +function mkState(def: Taskflow, cwd: string): RunState { return { runId: `e2e-codex-${Date.now()}`, flowName: def.name, @@ -38,7 +45,7 @@ function mkState(def: Taskflow): RunState { phases: {}, createdAt: Date.now(), updatedAt: Date.now(), - cwd: process.cwd(), + cwd, }; } @@ -57,13 +64,39 @@ const def: Taskflow = { agent: "responder", task: 'Phase pick said: "{steps.pick.output}". Reply with that same word in UPPERCASE, nothing else.', dependsOn: ["pick"], + }, + { + id: "persist", + type: "agent", + agent: "responder", + task: 'Reply with exactly "{steps.use.output}" and nothing else.', + dependsOn: ["use"], + tools: ["read"], + effects: [{ + id: "result", + kind: "fs.write", + purpose: "persist the live Codex result through resource authority", + confidentiality: "internal", + integrity: "project", + target: { + kind: "path", + path: { + workspace: "project", + subpath: { literalPath: "out/result.txt" }, + intent: "create-file", + }, + }, + }], final: true, }, ], }; +const root = fs.mkdtempSync(path.join(os.tmpdir(), "taskflow-codex-e2e-")); +const controlDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "taskflow-codex-control-")); const deps: RuntimeDeps = { - cwd: process.cwd(), + cwd: root, + workspaceControlDirectory: controlDirectory, agents: AGENTS, runTask: codexSubagentRunner.runTask, onProgress: (s) => { @@ -74,25 +107,46 @@ const deps: RuntimeDeps = { }, }; -console.log("▶ running 2-phase taskflow on codex (real subagents)…\n"); -const t0 = Date.now(); -const res = await executeTaskflow(mkState(def), deps); -const dt = ((Date.now() - t0) / 1000).toFixed(1); +try { + console.log("▶ running 3-phase taskflow on codex (real subagents + Trusted Effects)…\n"); + const t0 = Date.now(); + const state = mkState(def, root); + const res = await executeTaskflow(state, deps); + const dt = ((Date.now() - t0) / 1000).toFixed(1); + + process.stderr.write("\n"); + console.log(`\n✓ run finished in ${dt}s — ok=${res.ok}`); + console.log(" phase pick.output:", JSON.stringify(res.state.phases["pick"]?.output?.trim())); + console.log(" final output :", JSON.stringify(res.finalOutput?.trim())); + console.log(" total usage :", JSON.stringify(res.totalUsage)); -process.stderr.write("\n"); -console.log(`\n✓ run finished in ${dt}s — ok=${res.ok}`); -console.log(" phase pick.output:", JSON.stringify(res.state.phases["pick"]?.output?.trim())); -console.log(" final output :", JSON.stringify(res.finalOutput?.trim())); -console.log(" total usage :", JSON.stringify(res.totalUsage)); + assert.equal(res.ok, true, "run should succeed"); + assert.ok((res.state.phases["pick"]?.output ?? "").trim().length > 0, "phase pick produced output"); + assert.ok((res.finalOutput ?? "").trim().length > 0, "final output non-empty"); + // Phase use uppercases pick; persist echoes that content from a read-only Codex + // sandbox and the resource transaction alone promotes it to the final path. + const pickWord = (res.state.phases["pick"]?.output ?? "").trim().replace(/[^a-zA-Z]/g, "").toUpperCase(); + const finalWord = (res.finalOutput ?? "").trim().replace(/[^a-zA-Z]/g, "").toUpperCase(); + assert.ok(finalWord.length > 0, "final word non-empty"); + assert.equal(finalWord, pickWord, `data should flow A→B→C: pick=${pickWord} final=${finalWord}`); + assert.equal(fs.readFileSync(path.join(root, "out/result.txt"), "utf8").trim(), finalWord); -assert.equal(res.ok, true, "run should succeed"); -assert.ok((res.state.phases["pick"]?.output ?? "").trim().length > 0, "phase pick produced output"); -assert.ok((res.finalOutput ?? "").trim().length > 0, "final output non-empty"); -// The final phase should have echoed pick's word in uppercase — prove data flowed -// A→B by checking the final output is uppercase and shares letters with pick. -const pickWord = (res.state.phases["pick"]?.output ?? "").trim().replace(/[^a-zA-Z]/g, "").toUpperCase(); -const finalWord = (res.finalOutput ?? "").trim().replace(/[^a-zA-Z]/g, "").toUpperCase(); -assert.ok(finalWord.length > 0, "final word non-empty"); -assert.equal(finalWord, pickWord, `data should flow A→B: pick=${pickWord} final=${finalWord}`); + const why = await whyEffectFromDurableJournal({ + flow: def, + runId: state.runId, + phaseId: "persist", + effectId: "result", + workspaceRoot: root, + controlDirectory, + }); + assert.equal(why.ok, true); + if (!why.ok) throw new Error(why.error); + assert.equal(why.why.status, "committed"); + assert.equal(why.why.authorized.allowed, true); + assert.equal(why.why.authorized.principalId, "local-host-invocation"); -console.log("\n✅ E2E PASS — the taskflow engine ran end-to-end on codex, data flowed A→B."); + console.log("\n✅ E2E PASS — live Codex data flowed A→B→C; fs.write committed with ledger-backed authority."); +} finally { + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(controlDirectory, { recursive: true, force: true }); +} diff --git a/packages/codex-taskflow/test/e2e-mcp-comprehensive.mts b/packages/codex-taskflow/test/e2e-mcp-comprehensive.mts index afeebc95..f99af34f 100644 --- a/packages/codex-taskflow/test/e2e-mcp-comprehensive.mts +++ b/packages/codex-taskflow/test/e2e-mcp-comprehensive.mts @@ -26,10 +26,13 @@ import * as fs from "node:fs"; import * as os from "node:os"; import { execFileSync } from "node:child_process"; import { fileURLToPath } from "node:url"; +import { defaultWorkspaceControlDirectory } from "../../taskflow-core/src/resources/execution.ts"; const here = path.dirname(fileURLToPath(import.meta.url)); const repo = path.resolve(here, ".."); const bin = path.join(repo, "dist", "mcp", "bin.js"); +const serverCwd = fs.mkdtempSync(path.join(os.tmpdir(), "tf-codex-mcp-effects-e2e-")); +const workspaceControl = defaultWorkspaceControlDirectory(serverCwd); const expectedVersion = JSON.parse(fs.readFileSync(path.join(repo, "package.json"), "utf8")).version; assert.ok(fs.existsSync(bin), `built bin not found at ${bin} — run: npm run build -w codex-taskflow`); @@ -41,7 +44,7 @@ const ok = (label: string) => { }; // --- subprocess MCP client ------------------------------------------------ -const proc = spawn("node", [bin], { cwd: repo, stdio: ["pipe", "pipe", "pipe"] }); +const proc = spawn("node", [bin], { cwd: serverCwd, stdio: ["pipe", "pipe", "pipe"] }); const responses: any[] = []; let buf = ""; proc.stdout.on("data", (d) => { @@ -89,7 +92,7 @@ send({ jsonrpc: "2.0", method: "notifications/initialized" }); send({ jsonrpc: "2.0", id: 2, method: "tools/list", params: {} }); const list = await waitFor(2, "tools/list"); const toolNames = list.result.tools.map((t: any) => t.name).sort(); -assert.deepEqual(toolNames, ["taskflow_analytics", "taskflow_compile", "taskflow_lint", "taskflow_list", "taskflow_peek", "taskflow_plan", "taskflow_recompute", "taskflow_reconcile_workspace", "taskflow_replay", "taskflow_resume", "taskflow_run", "taskflow_runs", "taskflow_save", "taskflow_search", "taskflow_show", "taskflow_trace", "taskflow_verify", "taskflow_version", "taskflow_why_stale"]); +assert.deepEqual(toolNames, ["taskflow_analytics", "taskflow_compile", "taskflow_lint", "taskflow_list", "taskflow_peek", "taskflow_plan", "taskflow_recompute", "taskflow_reconcile_workspace", "taskflow_replay", "taskflow_resume", "taskflow_run", "taskflow_runs", "taskflow_save", "taskflow_search", "taskflow_show", "taskflow_trace", "taskflow_verify", "taskflow_version", "taskflow_why_effect", "taskflow_why_stale"]); for (const t of list.result.tools) { assert.equal(t.inputSchema.type, "object", `${t.name} has object schema`); assert.equal(typeof t.description, "string"); @@ -243,7 +246,54 @@ assert.equal(runBad.result.isError, true, "empty-phases flow is invalid"); assert.match(runBad.result.content[0].text, /invalid|phase/i); ok("taskflow_run invalid flow → isError with reason"); -// === 9. protocol robustness: batch pipelining + errors in one burst ======== +// === 9. Trusted Effects through the built Codex MCP host ================== +const effectRun = await callTool(12, "taskflow_run", { + define: { + name: "trusted-effects-host-e2e", + phases: [{ + id: "write", + type: "script", + run: ["node", "-e", "process.stdout.write('HOST_EFFECT')"], + idempotent: false, + effects: [{ + id: "report", + kind: "fs.write", + confidentiality: "internal", + integrity: "project", + target: { + kind: "path", + path: { + workspace: "project", + subpath: { literalPath: "out/report.txt" }, + intent: "create-file", + }, + }, + }], + final: true, + }], + }, +}); +assert.equal(effectRun.result.isError, false, effectRun.result.content[0].text); +assert.equal(fs.readFileSync(path.join(serverCwd, "out/report.txt"), "utf8"), "HOST_EFFECT"); +const effectText: string = effectRun.result.content[0].text; +const runId = /· run ([^\s]+)/.exec(effectText)?.[1]; +assert.ok(runId, `taskflow_run did not report a run id: ${effectText}`); +const whyEffect = await callTool(13, "taskflow_why_effect", { + runId, + phaseId: "write", + effectId: "report", + json: true, +}); +assert.equal(whyEffect.result.isError, false, whyEffect.result.content[0].text); +const why = JSON.parse(whyEffect.result.content[0].text); +assert.equal(why.authorized.allowed, true); +assert.equal(why.authorized.principalId, "local-host-invocation"); +assert.equal(why.status, "committed"); +assert.equal(why.journalStatus, "committed-content"); +assert.match(why.intentId, /^[0-9a-f-]{36}$/i); +ok("built Codex MCP → fs.write committed through resources + ledger-backed why-effect"); + +// === 10. protocol robustness: batch pipelining + errors in one burst ======= send({ jsonrpc: "2.0", id: 20, method: "ping" }); send({ jsonrpc: "2.0", id: 21, method: "does/not/exist" }); send({ jsonrpc: "2.0", method: "notifications/somethingUnknown" }); // ignored @@ -277,5 +327,7 @@ await sleep(50); if (stderr.trim()) { console.log("\n⚠ server stderr (non-fatal):\n" + stderr.trim()); } +fs.rmSync(serverCwd, { recursive: true, force: true }); +fs.rmSync(workspaceControl, { recursive: true, force: true }); console.log(`\n✅ COMPREHENSIVE E2E PASS — ${pass} checks against the built dist bin.`); process.exit(0); diff --git a/packages/codex-taskflow/test/mcp-server.test.ts b/packages/codex-taskflow/test/mcp-server.test.ts index 0394244d..eb430f15 100644 --- a/packages/codex-taskflow/test/mcp-server.test.ts +++ b/packages/codex-taskflow/test/mcp-server.test.ts @@ -51,7 +51,7 @@ test("mcp: initialize returns the protocol version + serverInfo codex expects", assert.equal(res.result.protocolVersion, "2025-06-18"); assert.ok(res.result.capabilities.tools, "advertises tools capability"); assert.equal(res.result.serverInfo.name, "taskflow-codex"); - assert.equal(res.result.serverInfo.version, "0.2.10"); + assert.equal(res.result.serverInfo.version, "0.3.0-beta.1"); }); test("mcp: tools/list exposes the taskflow tools with schemas", async () => { @@ -59,7 +59,7 @@ test("mcp: tools/list exposes the taskflow tools with schemas", async () => { const names = res.result.tools.map((t: any) => t.name); assert.deepEqual( names.sort(), - ["taskflow_analytics", "taskflow_compile", "taskflow_lint", "taskflow_list", "taskflow_peek", "taskflow_plan", "taskflow_recompute", "taskflow_reconcile_workspace", "taskflow_replay", "taskflow_resume", "taskflow_run", "taskflow_runs", "taskflow_save", "taskflow_search", "taskflow_show", "taskflow_trace", "taskflow_verify", "taskflow_version", "taskflow_why_stale"], + ["taskflow_analytics", "taskflow_compile", "taskflow_lint", "taskflow_list", "taskflow_peek", "taskflow_plan", "taskflow_recompute", "taskflow_reconcile_workspace", "taskflow_replay", "taskflow_resume", "taskflow_run", "taskflow_runs", "taskflow_save", "taskflow_search", "taskflow_show", "taskflow_trace", "taskflow_verify", "taskflow_version", "taskflow_why_effect", "taskflow_why_stale"], ); for (const t of res.result.tools) { assert.equal(typeof t.description, "string"); @@ -273,7 +273,7 @@ test("mcp: makeToolHandlers exposes the tools", () => { const tools = makeToolHandlers(process.cwd()); assert.deepEqual( Object.keys(tools).sort(), - ["taskflow_analytics", "taskflow_compile", "taskflow_lint", "taskflow_list", "taskflow_peek", "taskflow_plan", "taskflow_recompute", "taskflow_reconcile_workspace", "taskflow_replay", "taskflow_resume", "taskflow_run", "taskflow_runs", "taskflow_save", "taskflow_search", "taskflow_show", "taskflow_trace", "taskflow_verify", "taskflow_version", "taskflow_why_stale"], + ["taskflow_analytics", "taskflow_compile", "taskflow_lint", "taskflow_list", "taskflow_peek", "taskflow_plan", "taskflow_recompute", "taskflow_reconcile_workspace", "taskflow_replay", "taskflow_resume", "taskflow_run", "taskflow_runs", "taskflow_save", "taskflow_search", "taskflow_show", "taskflow_trace", "taskflow_verify", "taskflow_version", "taskflow_why_effect", "taskflow_why_stale"], ); }); @@ -581,17 +581,39 @@ test("mcp: taskflow_resume forks failed history, applies override, and preserves discoverAgents, executeTaskflow, newRunId, + readSubagentSettings, runsDir, saveRun, } = await import("taskflow-core"); const { makeToolHandlers: makeCoreToolHandlers } = await import("taskflow-mcp-core/server"); const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "tf-mcp-resume-")); + const previousAgentDir = process.env.PI_CODING_AGENT_DIR; + const userAgentDir = path.join(cwd, "user-agent-root"); + process.env.PI_CODING_AGENT_DIR = userAgentDir; try { + fs.mkdirSync(path.join(userAgentDir, "agents"), { recursive: true }); + fs.writeFileSync( + path.join(userAgentDir, "agents", "resume-fixture.md"), + "---\nname: resume-fixture\ndescription: user resume fixture\n---\nUSER RESUME FIXTURE\n", + ); + fs.writeFileSync( + path.join(userAgentDir, "settings.json"), + JSON.stringify({ subagents: { globalThinking: "high" }, taskflow: { builtInAgents: false } }), + ); + const projectAgentFile = path.join(cwd, ".pi", "agents", "resume-fixture.md"); + fs.mkdirSync(path.dirname(projectAgentFile), { recursive: true }); + fs.writeFileSync( + projectAgentFile, + "---\nname: resume-fixture\ndescription: project resume fixture\n---\nPROJECT RESUME FIXTURE\n", + ); + const settings = readSubagentSettings(); + const { agents } = discoverAgents(cwd, "user", settings.modelRoles, settings.taskflow); const def: Taskflow = { name: "resume-me", + agentScope: "user", phases: [ - { id: "a", type: "agent", agent: "executor", task: "stable" }, - { id: "b", type: "agent", agent: "executor", task: "fail-me", dependsOn: ["a"], final: true }, + { id: "a", type: "agent", agent: "resume-fixture", task: "stable" }, + { id: "b", type: "agent", agent: "resume-fixture", task: "fail-me", dependsOn: ["a"], final: true }, ], }; const parent: RunState = { @@ -606,7 +628,9 @@ test("mcp: taskflow_resume forks failed history, applies override, and preserves ...(task === "fail-me" ? { errorMessage: "boom" } : {}), }); const parentResult = await executeTaskflow(parent, { - cwd, agents: discoverAgents(cwd, "both").agents, + cwd, + agents, + globalThinking: settings.globalThinking, runTask: parentRunner, }); assert.equal(parentResult.ok, false); @@ -615,11 +639,14 @@ test("mcp: taskflow_resume forks failed history, applies override, and preserves const parentFile = path.join(runsDir(cwd), def.name, `${parent.runId}.json`); const parentBefore = fs.readFileSync(parentFile, "utf8"); - const childTasks: string[] = []; + const childCalls: Array<{ task: string; sources: string[]; globalThinking?: string }> = []; const childRunner: SubagentRunner = { usageAccounting: "tokens-only", - runTask: async (_cwd, _agents, agent, task) => { - childTasks.push(task); + runTask: async (_cwd, childAgents, agent, task, _opts, globalThinking) => { + const sources = childAgents + .map((entry) => (entry as { source?: unknown }).source) + .filter((source): source is string => typeof source === "string"); + childCalls.push({ task, sources, globalThinking }); return { agent, task, exitCode: 0, output: `child:${task}`, stderr: "", usage, stopReason: "end" }; }, }; @@ -640,10 +667,14 @@ test("mcp: taskflow_resume forks failed history, applies override, and preserves assert.notEqual(child.runId, parent.runId); assert.equal(child.parentRunId, parent.runId); assert.equal(child.host, "codex"); - assert.deepEqual(childTasks, ["fixed"], "done phase a is reused; only overridden b re-runs"); + assert.deepEqual(childCalls.map((call) => call.task), ["fixed"], "done phase a is reused; only overridden b re-runs"); + assert.deepEqual(childCalls[0]?.sources, ["user"], "resume honors the persisted user-only agent scope"); + assert.equal(childCalls[0]?.globalThinking, "high", "resume preserves configured global thinking"); assert.equal(child.def.phases.find((phase) => phase.id === "b")?.task, "fixed"); assert.equal(parent.def.phases.find((phase) => phase.id === "b")?.task, "fail-me"); } finally { + if (previousAgentDir === undefined) delete process.env.PI_CODING_AGENT_DIR; + else process.env.PI_CODING_AGENT_DIR = previousAgentDir; fs.rmSync(cwd, { recursive: true, force: true }); } }); diff --git a/packages/grok-taskflow/package.json b/packages/grok-taskflow/package.json index d5d7f5bd..94bbdd80 100644 --- a/packages/grok-taskflow/package.json +++ b/packages/grok-taskflow/package.json @@ -1,6 +1,6 @@ { "name": "grok-taskflow", - "version": "0.2.10", + "version": "0.3.0-beta.1", "description": "Run taskflow on Grok Build: the npm tarball provides the Grok subagent runner and MCP server; the plugin is distributed through the repository marketplace.", "keywords": [ "grok", diff --git a/packages/grok-taskflow/plugin/.grok-plugin/plugin.json b/packages/grok-taskflow/plugin/.grok-plugin/plugin.json index fd2fcef3..dcad3c67 100644 --- a/packages/grok-taskflow/plugin/.grok-plugin/plugin.json +++ b/packages/grok-taskflow/plugin/.grok-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "taskflow", - "version": "0.2.10", + "version": "0.3.0-beta.1", "description": "Declarative, verifiable DAG orchestration for Grok Build subagents — fan-out, gates, loops, tournaments, approvals, resumable runs, and saveable commands, with intermediate transcripts kept out of your context.", "author": { "name": "heggria", diff --git a/packages/grok-taskflow/plugin/.mcp.json b/packages/grok-taskflow/plugin/.mcp.json index 48e707dc..8ba0dc1d 100644 --- a/packages/grok-taskflow/plugin/.mcp.json +++ b/packages/grok-taskflow/plugin/.mcp.json @@ -2,7 +2,7 @@ "mcpServers": { "taskflow": { "command": "npx", - "args": ["-y", "-p", "grok-taskflow@0.2.10", "grok-taskflow-mcp"], + "args": ["-y", "-p", "grok-taskflow@0.3.0-beta.1", "grok-taskflow-mcp"], "tool_timeout_sec": 1800 } } diff --git a/packages/grok-taskflow/plugin/skills/taskflow/SKILL.md b/packages/grok-taskflow/plugin/skills/taskflow/SKILL.md index b6914fbd..eed118d7 100644 --- a/packages/grok-taskflow/plugin/skills/taskflow/SKILL.md +++ b/packages/grok-taskflow/plugin/skills/taskflow/SKILL.md @@ -37,6 +37,7 @@ kernel enforcement is unavailable. | `taskflow_trace` | Read a run's append-only event timeline. | | `taskflow_replay` | Replay recorded decisions offline with optional overrides — zero model calls. | | `taskflow_why_stale` | Explain why phases are stale from observed and declared dependencies — zero tokens. | +| `taskflow_why_effect` | Explain why a declared effect is authorized, from the durable resource-intent ledger (`runId` + `effectId`, optional `phaseId`; `json: true` for the full record). Declaration alone is not authorization — zero tokens, read-only. | | `taskflow_recompute` | Compute the stale frontier (**dry-run only** over MCP; never executes phases). | | `taskflow_reconcile_workspace` | After inspection/repair, accept a failed resolve-only workspace. Requires host `TASKFLOW_WORKSPACE_RECONCILE_MODE=explicit`; never restores files. | | `taskflow_save` | Save a reusable flow and optional library metadata. | @@ -223,6 +224,7 @@ back cleanly: precedence is `define` (inline) > `defineFile` (disk) > `name` | `timeout` | max ms per subagent call (>= 1000). On expiry the subagent is aborted and the phase fails with a `timedOut` marker — deterministic, **never retried**. Caps EACH call, so a map/parallel/race/loop/tournament phase's wall time is per item/iteration/variant (a tournament's judge call gets its own cap too). Script phases keep their own child-process timeout (default 60s, max 300s). Not supported on approval/flow/expand. Pair with `optional: true` + a downstream fallback phase to degrade instead of failing the run. | | `expect` | output contract for `output: "json"` phases (agent/gate/reduce/loop): a JSON-Schema-like shape `{type, properties, required, items, enum}` validated the moment the subagent finishes. A violation fails the phase with per-path diagnostics (e.g. `$.score: required key is missing`) and is retryable under the phase's explicit `retry`. `verify`/`compile` also statically warn when a `{steps.X.json.field}` ref names a field absent from X's declared contract. | | `idempotent` | side-effect classification. Default `true` (safe to cache + auto-retry). Set `false` on phases with **irreversible side effects** (webhook POSTs, deploys, DB writes, file mutations): transient provider errors are **not** auto-retried (an explicit `retry{}` IS still honored — it's your declaration that repeats are acceptable) and the result is **never cached** in any scope (within-run resume, cross-run, `incremental` — the phase re-runs every time). The phase state records `sideEffect: true` (rendered as ⚡). | +| `effects` | **[0.3 Trusted Effects]** declared side-effect bag for this phase — typed `fs.read` / `fs.write` / `fs.delete` / `secret.read` / `service.call` declarations with PathRef/SecretRef/ServiceRef targets and optional confidentiality/integrity labels. See **Trusted Effects** below. | | `optional` | fail-soft — a failed/blocked phase won't abort the run; downstream sees empty output. Pair with a fallback phase guarded by `when`. | | `cache` | per-phase reuse policy (`run-only` default / `cross-run` / `off`). See `configuration.md` §8. | @@ -512,6 +514,82 @@ output is exact. "input": "{steps.analyze.output}", "dependsOn": ["analyze"], "final": true } ``` +### Trusted Effects (`effects[]` — declared side effects, 0.3) + +> **One-line authority: the model proposes content; the resources runtime is +> the only commit authority.** A phase declares *what* it intends to touch; +> for admitted declared `fs.write` targets the runtime runs the +> resource-controlled **file transaction** — durable snapshot → persistent +> lease → journal intent/permit → stage → **Commit** or **Restore+Reject** — +> and no other code finalizes declared content. + +Trusted Effects (0.3 MVP) adds an optional `effects[]` bag to **any** phase: a +closed vocabulary of typed side-effect declarations. `verify` / `compile` +statically check the bag (unknown kinds, malformed targets, and illegal +label flows surface as `[effects]` issues), and a run admits every declared +target through PathRef resolution — lease, durable intent, mutation permit — +**before** the phase body executes. Start from the runnable example +**`examples/trusted-effects-write.json`** (a `script` phase that declares one +`fs.write` and commits it via the resource transaction — no LLM involved). + +Each effect: + +| field | meaning | +|-------|---------| +| `id` | stable id within the flow — the handle the why-* audit explains | +| `kind` | `fs.read` · `fs.write` · `fs.delete` · `secret.read` · `service.call` | +| `target` | `{ kind: "path", path: }`, or the `secret` / `service` handle shapes | +| `confidentiality` | optional label `public` · `internal` · `secret` — a higher label must not flow to a lower sink | +| `integrity` | optional label `untrusted` · `project` · `verified` — lower integrity must not overwrite higher | +| `purpose` | free-text note surfaced by the why-* explainers (**not** authority) | + +**PathRef shape** — the FS target, always relative to a workspace scope: + +```jsonc +"target": { + "kind": "path", + "path": { + "workspace": "project", // scope the path resolves in + "subpath": { "literalPath": "out/report.md" }, // or { "argPath": "out" } / { "segments": [ { "segment": "out" } ] } + "intent": "create-file" // create-file | create-directory | existing-file | existing-directory | executable + } +} +``` + +**Phase output is the payload.** With one declared `fs.write`, the phase's +output becomes the staged file content (see the example: `process.stdout.write` += the report). With several `fs.write` effects, the phase must emit JSON +mapping each effect id to its content (`{ "report": "…", "backup": "…" }`). +Commit promotes each file atomically; a later failure restores every admitted +file to its durable pre-state, and a direct write by the agent/script to a +**declared final path** is detected and restored — only the resource +transaction may finalize declared content. + +**Only `fs.write` has a bound runtime backend in this cut.** The other kinds +are valid to declare and verify, but fail **closed** (no bound resource +backend): `fs.delete` is not supported by the file transaction, and +`secret.read` / `service.call` have no vault/network adapters in 0.3 — do not +author a flow expecting them to do anything yet. + +**Audit with `taskflow_why_effect` (zero tokens, read-only).** Pass `runId` + +`effectId` (add `phaseId` to disambiguate a repeated id; `json: true` for the +full record) to explain a declared effect's authorization and lifecycle from +the durable resource-intent ledger — principal, capability binding, intent id, +journal status, and lifecycle (`declared` / `staged` / `committed` / +`rejected` / `unknown`). **Declaration alone is not authorization**: if no +durable intent admitted the effect for this run/phase, `authorized.allowed` is +`false` (fail-closed). + +**What this is NOT (honesty baseline):** + +- **No FileBroker sandbox.** Every host's PathRef support is *resolve-only*; + this is not an OS sandbox, and no host claims a FileBroker guarantee. +- **Undeclared paths are not protected.** Only writes to *declared* final + targets are detected and restored; writes outside the declared set remain + host-policy dependent. +- **`secret.read` / `service.call` are type-only fail-closed** (see above) — + valid declarations, no backend in the MVP. + ### Race phases (first success wins) A `race` phase runs static `branches[]` concurrently and **returns the first diff --git a/packages/grok-taskflow/test/mcp-server.test.ts b/packages/grok-taskflow/test/mcp-server.test.ts index 1349728a..a09127eb 100644 --- a/packages/grok-taskflow/test/mcp-server.test.ts +++ b/packages/grok-taskflow/test/mcp-server.test.ts @@ -45,7 +45,7 @@ test("grok mcp: initialize returns the protocol version + serverInfo", async () assert.equal(res.result.protocolVersion, "2025-06-18"); assert.ok(res.result.capabilities.tools, "advertises tools capability"); assert.equal(res.result.serverInfo.name, "taskflow-grok"); - assert.equal(res.result.serverInfo.version, "0.2.10"); + assert.equal(res.result.serverInfo.version, "0.3.0-beta.1"); }); test("grok mcp: tools/list exposes the same taskflow tools as other hosts", async () => { @@ -72,6 +72,7 @@ test("grok mcp: tools/list exposes the same taskflow tools as other hosts", asyn "taskflow_trace", "taskflow_verify", "taskflow_version", + "taskflow_why_effect", "taskflow_why_stale", ], ); @@ -122,6 +123,7 @@ test("grok mcp: makeToolHandlers exposes the tools", () => { "taskflow_trace", "taskflow_verify", "taskflow_version", + "taskflow_why_effect", "taskflow_why_stale", ], ); diff --git a/packages/hermes-taskflow/package.json b/packages/hermes-taskflow/package.json index 97bebf30..f3f9e78e 100644 --- a/packages/hermes-taskflow/package.json +++ b/packages/hermes-taskflow/package.json @@ -1,6 +1,6 @@ { "name": "hermes-taskflow", - "version": "0.2.10", + "version": "0.3.0-beta.1", "description": "Run taskflow on Hermes Agent: a Hermes subagent runner plus an MCP server (and a config scaffold) that exposes the taskflow_* tools to Hermes users.", "keywords": [ "hermes", diff --git a/packages/hermes-taskflow/plugin/hermes.config.snippet.yaml b/packages/hermes-taskflow/plugin/hermes.config.snippet.yaml index 0d37a976..035f5af4 100644 --- a/packages/hermes-taskflow/plugin/hermes.config.snippet.yaml +++ b/packages/hermes-taskflow/plugin/hermes.config.snippet.yaml @@ -14,7 +14,7 @@ taskflow: command: "npx" - args: ["-y", "-p", "hermes-taskflow@0.2.10", "hermes-taskflow-mcp"] + args: ["-y", "-p", "hermes-taskflow@0.3.0-beta.1", "hermes-taskflow-mcp"] env: # Uncomment for mutating agent phases (terminal / file write). # Leave unset for verify/plan/script-only flows. diff --git a/packages/hermes-taskflow/plugin/skills/taskflow/SKILL.md b/packages/hermes-taskflow/plugin/skills/taskflow/SKILL.md index 0c8b114c..944ca7b5 100644 --- a/packages/hermes-taskflow/plugin/skills/taskflow/SKILL.md +++ b/packages/hermes-taskflow/plugin/skills/taskflow/SKILL.md @@ -30,6 +30,7 @@ session. | `taskflow_trace` | Read a run's append-only event timeline. | | `taskflow_replay` | Replay recorded decisions offline with optional overrides — zero model calls. | | `taskflow_why_stale` | Explain why phases are stale from observed and declared dependencies — zero tokens. | +| `taskflow_why_effect` | Explain why a declared effect is authorized, from the durable resource-intent ledger (`runId` + `effectId`, optional `phaseId`; `json: true` for the full record). Declaration alone is not authorization — zero tokens, read-only. | | `taskflow_recompute` | Compute the stale frontier (**dry-run only** over MCP; never executes phases). | | `taskflow_reconcile_workspace` | After inspection/repair, accept a failed resolve-only workspace. Requires host `TASKFLOW_WORKSPACE_RECONCILE_MODE=explicit`; never restores files. | | `taskflow_save` | Save a reusable flow and optional library metadata. | @@ -218,6 +219,7 @@ back cleanly: precedence is `define` (inline) > `defineFile` (disk) > `name` | `timeout` | max ms per subagent call (>= 1000). On expiry the subagent is aborted and the phase fails with a `timedOut` marker — deterministic, **never retried**. Caps EACH call, so a map/parallel/race/loop/tournament phase's wall time is per item/iteration/variant (a tournament's judge call gets its own cap too). Script phases keep their own child-process timeout (default 60s, max 300s). Not supported on approval/flow/expand. Pair with `optional: true` + a downstream fallback phase to degrade instead of failing the run. | | `expect` | output contract for `output: "json"` phases (agent/gate/reduce/loop): a JSON-Schema-like shape `{type, properties, required, items, enum}` validated the moment the subagent finishes. A violation fails the phase with per-path diagnostics (e.g. `$.score: required key is missing`) and is retryable under the phase's explicit `retry`. `verify`/`compile` also statically warn when a `{steps.X.json.field}` ref names a field absent from X's declared contract. | | `idempotent` | side-effect classification. Default `true` (safe to cache + auto-retry). Set `false` on phases with **irreversible side effects** (webhook POSTs, deploys, DB writes, file mutations): transient provider errors are **not** auto-retried (an explicit `retry{}` IS still honored — it's your declaration that repeats are acceptable) and the result is **never cached** in any scope (within-run resume, cross-run, `incremental` — the phase re-runs every time). The phase state records `sideEffect: true` (rendered as ⚡). | +| `effects` | **[0.3 Trusted Effects]** declared side-effect bag for this phase — typed `fs.read` / `fs.write` / `fs.delete` / `secret.read` / `service.call` declarations with PathRef/SecretRef/ServiceRef targets and optional confidentiality/integrity labels. See **Trusted Effects** below. | | `optional` | fail-soft — a failed/blocked phase won't abort the run; downstream sees empty output. Pair with a fallback phase guarded by `when`. | | `cache` | per-phase reuse policy (`run-only` default / `cross-run` / `off`). See `configuration.md` §8. | @@ -507,6 +509,82 @@ output is exact. "input": "{steps.analyze.output}", "dependsOn": ["analyze"], "final": true } ``` +### Trusted Effects (`effects[]` — declared side effects, 0.3) + +> **One-line authority: the model proposes content; the resources runtime is +> the only commit authority.** A phase declares *what* it intends to touch; +> for admitted declared `fs.write` targets the runtime runs the +> resource-controlled **file transaction** — durable snapshot → persistent +> lease → journal intent/permit → stage → **Commit** or **Restore+Reject** — +> and no other code finalizes declared content. + +Trusted Effects (0.3 MVP) adds an optional `effects[]` bag to **any** phase: a +closed vocabulary of typed side-effect declarations. `verify` / `compile` +statically check the bag (unknown kinds, malformed targets, and illegal +label flows surface as `[effects]` issues), and a run admits every declared +target through PathRef resolution — lease, durable intent, mutation permit — +**before** the phase body executes. Start from the runnable example +**`examples/trusted-effects-write.json`** (a `script` phase that declares one +`fs.write` and commits it via the resource transaction — no LLM involved). + +Each effect: + +| field | meaning | +|-------|---------| +| `id` | stable id within the flow — the handle the why-* audit explains | +| `kind` | `fs.read` · `fs.write` · `fs.delete` · `secret.read` · `service.call` | +| `target` | `{ kind: "path", path: }`, or the `secret` / `service` handle shapes | +| `confidentiality` | optional label `public` · `internal` · `secret` — a higher label must not flow to a lower sink | +| `integrity` | optional label `untrusted` · `project` · `verified` — lower integrity must not overwrite higher | +| `purpose` | free-text note surfaced by the why-* explainers (**not** authority) | + +**PathRef shape** — the FS target, always relative to a workspace scope: + +```jsonc +"target": { + "kind": "path", + "path": { + "workspace": "project", // scope the path resolves in + "subpath": { "literalPath": "out/report.md" }, // or { "argPath": "out" } / { "segments": [ { "segment": "out" } ] } + "intent": "create-file" // create-file | create-directory | existing-file | existing-directory | executable + } +} +``` + +**Phase output is the payload.** With one declared `fs.write`, the phase's +output becomes the staged file content (see the example: `process.stdout.write` += the report). With several `fs.write` effects, the phase must emit JSON +mapping each effect id to its content (`{ "report": "…", "backup": "…" }`). +Commit promotes each file atomically; a later failure restores every admitted +file to its durable pre-state, and a direct write by the agent/script to a +**declared final path** is detected and restored — only the resource +transaction may finalize declared content. + +**Only `fs.write` has a bound runtime backend in this cut.** The other kinds +are valid to declare and verify, but fail **closed** (no bound resource +backend): `fs.delete` is not supported by the file transaction, and +`secret.read` / `service.call` have no vault/network adapters in 0.3 — do not +author a flow expecting them to do anything yet. + +**Audit with `taskflow_why_effect` (zero tokens, read-only).** Pass `runId` + +`effectId` (add `phaseId` to disambiguate a repeated id; `json: true` for the +full record) to explain a declared effect's authorization and lifecycle from +the durable resource-intent ledger — principal, capability binding, intent id, +journal status, and lifecycle (`declared` / `staged` / `committed` / +`rejected` / `unknown`). **Declaration alone is not authorization**: if no +durable intent admitted the effect for this run/phase, `authorized.allowed` is +`false` (fail-closed). + +**What this is NOT (honesty baseline):** + +- **No FileBroker sandbox.** Every host's PathRef support is *resolve-only*; + this is not an OS sandbox, and no host claims a FileBroker guarantee. +- **Undeclared paths are not protected.** Only writes to *declared* final + targets are detected and restored; writes outside the declared set remain + host-policy dependent. +- **`secret.read` / `service.call` are type-only fail-closed** (see above) — + valid declarations, no backend in the MVP. + ### Race phases (first success wins) A `race` phase runs static `branches[]` concurrently and **returns the first diff --git a/packages/hermes-taskflow/test/mcp-server.test.ts b/packages/hermes-taskflow/test/mcp-server.test.ts index 44e4c0e1..ddea0765 100644 --- a/packages/hermes-taskflow/test/mcp-server.test.ts +++ b/packages/hermes-taskflow/test/mcp-server.test.ts @@ -38,7 +38,7 @@ test("hermes mcp: initialize returns the protocol version + serverInfo", async ( assert.equal(res.result.protocolVersion, "2025-06-18"); assert.ok(res.result.capabilities.tools, "advertises tools capability"); assert.equal(res.result.serverInfo.name, "taskflow-hermes"); - assert.equal(res.result.serverInfo.version, "0.2.10"); + assert.equal(res.result.serverInfo.version, "0.3.0-beta.1"); }); test("hermes mcp: tools/list exposes the taskflow tools", async () => { @@ -46,7 +46,7 @@ test("hermes mcp: tools/list exposes the taskflow tools", async () => { const names = res.result.tools.map((t: any) => t.name); assert.deepEqual( names.sort(), - ["taskflow_analytics", "taskflow_compile", "taskflow_lint", "taskflow_list", "taskflow_peek", "taskflow_plan", "taskflow_recompute", "taskflow_reconcile_workspace", "taskflow_replay", "taskflow_resume", "taskflow_run", "taskflow_runs", "taskflow_save", "taskflow_search", "taskflow_show", "taskflow_trace", "taskflow_verify", "taskflow_version", "taskflow_why_stale"], + ["taskflow_analytics", "taskflow_compile", "taskflow_lint", "taskflow_list", "taskflow_peek", "taskflow_plan", "taskflow_recompute", "taskflow_reconcile_workspace", "taskflow_replay", "taskflow_resume", "taskflow_run", "taskflow_runs", "taskflow_save", "taskflow_search", "taskflow_show", "taskflow_trace", "taskflow_verify", "taskflow_version", "taskflow_why_effect", "taskflow_why_stale"], ); for (const t of res.result.tools) { assert.equal(typeof t.description, "string"); @@ -76,6 +76,6 @@ test("hermes mcp: makeToolHandlers exposes the tools", () => { const tools = makeToolHandlers(process.cwd()); assert.deepEqual( Object.keys(tools).sort(), - ["taskflow_analytics", "taskflow_compile", "taskflow_lint", "taskflow_list", "taskflow_peek", "taskflow_plan", "taskflow_recompute", "taskflow_reconcile_workspace", "taskflow_replay", "taskflow_resume", "taskflow_run", "taskflow_runs", "taskflow_save", "taskflow_search", "taskflow_show", "taskflow_trace", "taskflow_verify", "taskflow_version", "taskflow_why_stale"], + ["taskflow_analytics", "taskflow_compile", "taskflow_lint", "taskflow_list", "taskflow_peek", "taskflow_plan", "taskflow_recompute", "taskflow_reconcile_workspace", "taskflow_replay", "taskflow_resume", "taskflow_run", "taskflow_runs", "taskflow_save", "taskflow_search", "taskflow_show", "taskflow_trace", "taskflow_verify", "taskflow_version", "taskflow_why_effect", "taskflow_why_stale"], ); }); diff --git a/packages/opencode-taskflow/package.json b/packages/opencode-taskflow/package.json index f7b68de2..c35560b1 100644 --- a/packages/opencode-taskflow/package.json +++ b/packages/opencode-taskflow/package.json @@ -1,6 +1,6 @@ { "name": "opencode-taskflow", - "version": "0.2.10", + "version": "0.3.0-beta.1", "description": "Run taskflow on OpenCode: an OpenCode subagent runner plus an MCP server (and an opencode.json config scaffold) that exposes the taskflow_* tools to OpenCode users.", "keywords": [ "opencode", diff --git a/packages/opencode-taskflow/plugin/opencode.json b/packages/opencode-taskflow/plugin/opencode.json index dd4241b7..8879bf4f 100644 --- a/packages/opencode-taskflow/plugin/opencode.json +++ b/packages/opencode-taskflow/plugin/opencode.json @@ -3,7 +3,7 @@ "mcp": { "taskflow": { "type": "local", - "command": ["npx", "-y", "-p", "opencode-taskflow@0.2.10", "opencode-taskflow-mcp"], + "command": ["npx", "-y", "-p", "opencode-taskflow@0.3.0-beta.1", "opencode-taskflow-mcp"], "enabled": true } }, diff --git a/packages/opencode-taskflow/plugin/skills/taskflow/SKILL.md b/packages/opencode-taskflow/plugin/skills/taskflow/SKILL.md index 6b504a40..534d384e 100644 --- a/packages/opencode-taskflow/plugin/skills/taskflow/SKILL.md +++ b/packages/opencode-taskflow/plugin/skills/taskflow/SKILL.md @@ -28,6 +28,7 @@ the OpenCode form (`taskflow_verify`). Each phase's subagent runs as an isolated | `taskflow_trace` | Read a run's append-only event timeline. | | `taskflow_replay` | Replay recorded decisions offline with optional overrides — zero model calls. | | `taskflow_why_stale` | Explain why phases are stale from observed and declared dependencies — zero tokens. | +| `taskflow_why_effect` | Explain why a declared effect is authorized, from the durable resource-intent ledger (`runId` + `effectId`, optional `phaseId`; `json: true` for the full record). Declaration alone is not authorization — zero tokens, read-only. | | `taskflow_recompute` | Compute the stale frontier (**dry-run only** over MCP; never executes phases). | | `taskflow_reconcile_workspace` | After inspection/repair, accept a failed resolve-only workspace. Requires host `TASKFLOW_WORKSPACE_RECONCILE_MODE=explicit`; never restores files. | | `taskflow_save` | Save a reusable flow and optional library metadata. | @@ -214,6 +215,7 @@ back cleanly: precedence is `define` (inline) > `defineFile` (disk) > `name` | `timeout` | max ms per subagent call (>= 1000). On expiry the subagent is aborted and the phase fails with a `timedOut` marker — deterministic, **never retried**. Caps EACH call, so a map/parallel/race/loop/tournament phase's wall time is per item/iteration/variant (a tournament's judge call gets its own cap too). Script phases keep their own child-process timeout (default 60s, max 300s). Not supported on approval/flow/expand. Pair with `optional: true` + a downstream fallback phase to degrade instead of failing the run. | | `expect` | output contract for `output: "json"` phases (agent/gate/reduce/loop): a JSON-Schema-like shape `{type, properties, required, items, enum}` validated the moment the subagent finishes. A violation fails the phase with per-path diagnostics (e.g. `$.score: required key is missing`) and is retryable under the phase's explicit `retry`. `verify`/`compile` also statically warn when a `{steps.X.json.field}` ref names a field absent from X's declared contract. | | `idempotent` | side-effect classification. Default `true` (safe to cache + auto-retry). Set `false` on phases with **irreversible side effects** (webhook POSTs, deploys, DB writes, file mutations): transient provider errors are **not** auto-retried (an explicit `retry{}` IS still honored — it's your declaration that repeats are acceptable) and the result is **never cached** in any scope (within-run resume, cross-run, `incremental` — the phase re-runs every time). The phase state records `sideEffect: true` (rendered as ⚡). | +| `effects` | **[0.3 Trusted Effects]** declared side-effect bag for this phase — typed `fs.read` / `fs.write` / `fs.delete` / `secret.read` / `service.call` declarations with PathRef/SecretRef/ServiceRef targets and optional confidentiality/integrity labels. See **Trusted Effects** below. | | `optional` | fail-soft — a failed/blocked phase won't abort the run; downstream sees empty output. Pair with a fallback phase guarded by `when`. | | `cache` | per-phase reuse policy (`run-only` default / `cross-run` / `off`). See `configuration.md` §8. | @@ -503,6 +505,82 @@ output is exact. "input": "{steps.analyze.output}", "dependsOn": ["analyze"], "final": true } ``` +### Trusted Effects (`effects[]` — declared side effects, 0.3) + +> **One-line authority: the model proposes content; the resources runtime is +> the only commit authority.** A phase declares *what* it intends to touch; +> for admitted declared `fs.write` targets the runtime runs the +> resource-controlled **file transaction** — durable snapshot → persistent +> lease → journal intent/permit → stage → **Commit** or **Restore+Reject** — +> and no other code finalizes declared content. + +Trusted Effects (0.3 MVP) adds an optional `effects[]` bag to **any** phase: a +closed vocabulary of typed side-effect declarations. `verify` / `compile` +statically check the bag (unknown kinds, malformed targets, and illegal +label flows surface as `[effects]` issues), and a run admits every declared +target through PathRef resolution — lease, durable intent, mutation permit — +**before** the phase body executes. Start from the runnable example +**`examples/trusted-effects-write.json`** (a `script` phase that declares one +`fs.write` and commits it via the resource transaction — no LLM involved). + +Each effect: + +| field | meaning | +|-------|---------| +| `id` | stable id within the flow — the handle the why-* audit explains | +| `kind` | `fs.read` · `fs.write` · `fs.delete` · `secret.read` · `service.call` | +| `target` | `{ kind: "path", path: }`, or the `secret` / `service` handle shapes | +| `confidentiality` | optional label `public` · `internal` · `secret` — a higher label must not flow to a lower sink | +| `integrity` | optional label `untrusted` · `project` · `verified` — lower integrity must not overwrite higher | +| `purpose` | free-text note surfaced by the why-* explainers (**not** authority) | + +**PathRef shape** — the FS target, always relative to a workspace scope: + +```jsonc +"target": { + "kind": "path", + "path": { + "workspace": "project", // scope the path resolves in + "subpath": { "literalPath": "out/report.md" }, // or { "argPath": "out" } / { "segments": [ { "segment": "out" } ] } + "intent": "create-file" // create-file | create-directory | existing-file | existing-directory | executable + } +} +``` + +**Phase output is the payload.** With one declared `fs.write`, the phase's +output becomes the staged file content (see the example: `process.stdout.write` += the report). With several `fs.write` effects, the phase must emit JSON +mapping each effect id to its content (`{ "report": "…", "backup": "…" }`). +Commit promotes each file atomically; a later failure restores every admitted +file to its durable pre-state, and a direct write by the agent/script to a +**declared final path** is detected and restored — only the resource +transaction may finalize declared content. + +**Only `fs.write` has a bound runtime backend in this cut.** The other kinds +are valid to declare and verify, but fail **closed** (no bound resource +backend): `fs.delete` is not supported by the file transaction, and +`secret.read` / `service.call` have no vault/network adapters in 0.3 — do not +author a flow expecting them to do anything yet. + +**Audit with `taskflow_why_effect` (zero tokens, read-only).** Pass `runId` + +`effectId` (add `phaseId` to disambiguate a repeated id; `json: true` for the +full record) to explain a declared effect's authorization and lifecycle from +the durable resource-intent ledger — principal, capability binding, intent id, +journal status, and lifecycle (`declared` / `staged` / `committed` / +`rejected` / `unknown`). **Declaration alone is not authorization**: if no +durable intent admitted the effect for this run/phase, `authorized.allowed` is +`false` (fail-closed). + +**What this is NOT (honesty baseline):** + +- **No FileBroker sandbox.** Every host's PathRef support is *resolve-only*; + this is not an OS sandbox, and no host claims a FileBroker guarantee. +- **Undeclared paths are not protected.** Only writes to *declared* final + targets are detected and restored; writes outside the declared set remain + host-policy dependent. +- **`secret.read` / `service.call` are type-only fail-closed** (see above) — + valid declarations, no backend in the MVP. + ### Race phases (first success wins) A `race` phase runs static `branches[]` concurrently and **returns the first diff --git a/packages/opencode-taskflow/test/mcp-server.test.ts b/packages/opencode-taskflow/test/mcp-server.test.ts index bf2019d1..172e4614 100644 --- a/packages/opencode-taskflow/test/mcp-server.test.ts +++ b/packages/opencode-taskflow/test/mcp-server.test.ts @@ -45,7 +45,7 @@ test("opencode mcp: initialize returns the protocol version + serverInfo", async assert.equal(res.result.protocolVersion, "2025-06-18"); assert.ok(res.result.capabilities.tools, "advertises tools capability"); assert.equal(res.result.serverInfo.name, "taskflow-opencode"); - assert.equal(res.result.serverInfo.version, "0.2.10"); + assert.equal(res.result.serverInfo.version, "0.3.0-beta.1"); }); test("opencode mcp: tools/list exposes the taskflow tools", async () => { @@ -53,7 +53,7 @@ test("opencode mcp: tools/list exposes the taskflow tools", async () => { const names = res.result.tools.map((t: any) => t.name); assert.deepEqual( names.sort(), - ["taskflow_analytics", "taskflow_compile", "taskflow_lint", "taskflow_list", "taskflow_peek", "taskflow_plan", "taskflow_recompute", "taskflow_reconcile_workspace", "taskflow_replay", "taskflow_resume", "taskflow_run", "taskflow_runs", "taskflow_save", "taskflow_search", "taskflow_show", "taskflow_trace", "taskflow_verify", "taskflow_version", "taskflow_why_stale"], + ["taskflow_analytics", "taskflow_compile", "taskflow_lint", "taskflow_list", "taskflow_peek", "taskflow_plan", "taskflow_recompute", "taskflow_reconcile_workspace", "taskflow_replay", "taskflow_resume", "taskflow_run", "taskflow_runs", "taskflow_save", "taskflow_search", "taskflow_show", "taskflow_trace", "taskflow_verify", "taskflow_version", "taskflow_why_effect", "taskflow_why_stale"], ); for (const t of res.result.tools) { assert.equal(typeof t.description, "string"); @@ -83,6 +83,6 @@ test("opencode mcp: makeToolHandlers exposes the tools", () => { const tools = makeToolHandlers(process.cwd()); assert.deepEqual( Object.keys(tools).sort(), - ["taskflow_analytics", "taskflow_compile", "taskflow_lint", "taskflow_list", "taskflow_peek", "taskflow_plan", "taskflow_recompute", "taskflow_reconcile_workspace", "taskflow_replay", "taskflow_resume", "taskflow_run", "taskflow_runs", "taskflow_save", "taskflow_search", "taskflow_show", "taskflow_trace", "taskflow_verify", "taskflow_version", "taskflow_why_stale"], + ["taskflow_analytics", "taskflow_compile", "taskflow_lint", "taskflow_list", "taskflow_peek", "taskflow_plan", "taskflow_recompute", "taskflow_reconcile_workspace", "taskflow_replay", "taskflow_resume", "taskflow_run", "taskflow_runs", "taskflow_save", "taskflow_search", "taskflow_show", "taskflow_trace", "taskflow_verify", "taskflow_version", "taskflow_why_effect", "taskflow_why_stale"], ); }); diff --git a/packages/pi-taskflow/package.json b/packages/pi-taskflow/package.json index 0c805672..a5024b96 100644 --- a/packages/pi-taskflow/package.json +++ b/packages/pi-taskflow/package.json @@ -1,6 +1,6 @@ { "name": "pi-taskflow", - "version": "0.2.10", + "version": "0.3.0-beta.1", "description": "A declarative, verifiable graph of task nodes for the Pi coding agent — statically verified before it runs, with dynamic fan-out, gates, isolated subagent context, resumable runs, and saveable commands.", "keywords": [ "pi-package", diff --git a/packages/pi-taskflow/skills/taskflow/SKILL.md b/packages/pi-taskflow/skills/taskflow/SKILL.md index 1d18332d..1d97edee 100644 --- a/packages/pi-taskflow/skills/taskflow/SKILL.md +++ b/packages/pi-taskflow/skills/taskflow/SKILL.md @@ -192,6 +192,7 @@ back cleanly: precedence is `define` (inline) > `defineFile` (disk) > `name` | `timeout` | max ms per subagent call (>= 1000). On expiry the subagent is aborted and the phase fails with a `timedOut` marker — deterministic, **never retried**. Caps EACH call, so a map/parallel/race/loop/tournament phase's wall time is per item/iteration/variant (a tournament's judge call gets its own cap too). Script phases keep their own child-process timeout (default 60s, max 300s). Not supported on approval/flow/expand. Pair with `optional: true` + a downstream fallback phase to degrade instead of failing the run. | | `expect` | output contract for `output: "json"` phases (agent/gate/reduce/loop): a JSON-Schema-like shape `{type, properties, required, items, enum}` validated the moment the subagent finishes. A violation fails the phase with per-path diagnostics (e.g. `$.score: required key is missing`) and is retryable under the phase's explicit `retry`. `verify`/`compile` also statically warn when a `{steps.X.json.field}` ref names a field absent from X's declared contract. | | `idempotent` | side-effect classification. Default `true` (safe to cache + auto-retry). Set `false` on phases with **irreversible side effects** (webhook POSTs, deploys, DB writes, file mutations): transient provider errors are **not** auto-retried (an explicit `retry{}` IS still honored — it's your declaration that repeats are acceptable) and the result is **never cached** in any scope (within-run resume, cross-run, `incremental` — the phase re-runs every time). The phase state records `sideEffect: true` (rendered as ⚡). | +| `effects` | **[0.3 Trusted Effects]** declared side-effect bag for this phase — typed `fs.read` / `fs.write` / `fs.delete` / `secret.read` / `service.call` declarations with PathRef/SecretRef/ServiceRef targets and optional confidentiality/integrity labels. See **Trusted Effects** below. | | `optional` | fail-soft — a failed/blocked phase won't abort the run; downstream sees empty output. Pair with a fallback phase guarded by `when`. | | `cache` | per-phase reuse policy (`run-only` default / `cross-run` / `off`). See `configuration.md` §8. | @@ -479,6 +480,73 @@ output is exact. "input": "{steps.analyze.output}", "dependsOn": ["analyze"], "final": true } ``` +### Trusted Effects (`effects[]` — declared side effects, 0.3) + +> **One-line authority: the model proposes content; the resources runtime is +> the only commit authority.** A phase declares *what* it intends to touch; +> for admitted declared `fs.write` targets the runtime runs the +> resource-controlled **file transaction** — durable snapshot → persistent +> lease → journal intent/permit → stage → **Commit** or **Restore+Reject** — +> and no other code finalizes declared content. + +Trusted Effects (0.3 MVP) adds an optional `effects[]` bag to **any** phase: a +closed vocabulary of typed side-effect declarations. `verify` / `compile` +statically check the bag (unknown kinds, malformed targets, and illegal +label flows surface as `[effects]` issues), and a run admits every declared +target through PathRef resolution — lease, durable intent, mutation permit — +**before** the phase body executes. Start from the runnable example +**`examples/trusted-effects-write.json`** (a `script` phase that declares one +`fs.write` and commits it via the resource transaction — no LLM involved). + +Each effect: + +| field | meaning | +|-------|---------| +| `id` | stable id within the flow — the handle the why-* audit explains | +| `kind` | `fs.read` · `fs.write` · `fs.delete` · `secret.read` · `service.call` | +| `target` | `{ kind: "path", path: }`, or the `secret` / `service` handle shapes | +| `confidentiality` | optional label `public` · `internal` · `secret` — a higher label must not flow to a lower sink | +| `integrity` | optional label `untrusted` · `project` · `verified` — lower integrity must not overwrite higher | +| `purpose` | free-text note surfaced by the why-* explainers (**not** authority) | + +**PathRef shape** — the FS target, always relative to a workspace scope: + +```jsonc +"target": { + "kind": "path", + "path": { + "workspace": "project", // scope the path resolves in + "subpath": { "literalPath": "out/report.md" }, // or { "argPath": "out" } / { "segments": [ { "segment": "out" } ] } + "intent": "create-file" // create-file | create-directory | existing-file | existing-directory | executable + } +} +``` + +**Phase output is the payload.** With one declared `fs.write`, the phase's +output becomes the staged file content (see the example: `process.stdout.write` += the report). With several `fs.write` effects, the phase must emit JSON +mapping each effect id to its content (`{ "report": "…", "backup": "…" }`). +Commit promotes each file atomically; a later failure restores every admitted +file to its durable pre-state, and a direct write by the agent/script to a +**declared final path** is detected and restored — only the resource +transaction may finalize declared content. + +**Only `fs.write` has a bound runtime backend in this cut.** The other kinds +are valid to declare and verify, but fail **closed** (no bound resource +backend): `fs.delete` is not supported by the file transaction, and +`secret.read` / `service.call` have no vault/network adapters in 0.3 — do not +author a flow expecting them to do anything yet. + +**What this is NOT (honesty baseline):** + +- **No FileBroker sandbox.** Every host's PathRef support is *resolve-only*; + this is not an OS sandbox, and no host claims a FileBroker guarantee. +- **Undeclared paths are not protected.** Only writes to *declared* final + targets are detected and restored; writes outside the declared set remain + host-policy dependent. +- **`secret.read` / `service.call` are type-only fail-closed** (see above) — + valid declarations, no backend in the MVP. + ### Race phases (first success wins) A `race` phase runs static `branches[]` concurrently and **returns the first diff --git a/packages/pi-taskflow/src/index.ts b/packages/pi-taskflow/src/index.ts index 52ec6f2e..b21107a8 100644 --- a/packages/pi-taskflow/src/index.ts +++ b/packages/pi-taskflow/src/index.ts @@ -1434,7 +1434,10 @@ export default function (pi: ExtensionAPI) { resolvedArgs = params.args as Record; } const args = resolveArgs(def, resolvedArgs); - const v = validateTaskflow(def, { args, cwd: ctx.cwd }); + // Resolve `flow{use}` children through the store so pre-run validation + // checks real child effects when the saved flow exists (runtime remains + // the authoritative gate for names the store cannot resolve). + const v = validateTaskflow(def, { args, cwd: ctx.cwd, resolveFlow: (name: string) => getFlow(ctx.cwd, name)?.def }); if (!v.ok) return errorResult(action, `Invalid taskflow:\n- ${v.errors.join("\n- ")}`); for (const w of v.warnings) { console.warn(`[taskflow:${def.name}] ${w}`); diff --git a/packages/pi-taskflow/test/skills-build.test.ts b/packages/pi-taskflow/test/skills-build.test.ts index bd5d2f71..1a03ea86 100644 --- a/packages/pi-taskflow/test/skills-build.test.ts +++ b/packages/pi-taskflow/test/skills-build.test.ts @@ -31,9 +31,10 @@ test("release discovery metadata advertises the complete MCP surface", async () const { readFileSync } = await import("node:fs"); for (const file of [".claude-plugin/marketplace.json", ".grok-plugin/marketplace.json"]) { const text = readFileSync(path.join(root, file), "utf8"); - assert.match(text, /19 taskflow_\* MCP tools/); + assert.match(text, /20 taskflow_\* MCP tools/); assert.match(text, /run\/runs\/resume\/version\/list/); assert.match(text, /plan\/analytics/); + assert.match(text, /why_effect/); } const piSource = readFileSync(path.join(root, "packages", "pi-taskflow", "src", "index.ts"), "utf8"); assert.match(piSource, /Use action=resume/); diff --git a/packages/taskflow-control/package.json b/packages/taskflow-control/package.json new file mode 100644 index 00000000..cb70de2a --- /dev/null +++ b/packages/taskflow-control/package.json @@ -0,0 +1,89 @@ +{ + "name": "taskflow-control", + "version": "0.3.0", + "description": "0.3-C control plane core: the frozen TypeBox wire contracts (P1-P16 + wire-freeze), the ControlHost daemon/supervisor/standalone contracts, user singleton endpoint/fencing, hello-before-RPC negotiation, and the Trusted Effects (taskflow-core resources) provider as the only execution authority.", + "keywords": [ + "taskflow", + "control-plane", + "control-host", + "wire", + "typebox", + "orchestration", + "trusted-effects" + ], + "license": "MIT", + "author": "heggria ", + "homepage": "https://github.com/heggria/taskflow#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/heggria/taskflow.git", + "directory": "packages/taskflow-control" + }, + "bugs": { + "url": "https://github.com/heggria/taskflow/issues" + }, + "type": "module", + "engines": { + "node": ">=22.19.0" + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "development": "./src/index.ts", + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./schema": { + "development": "./src/schema/index.ts", + "types": "./dist/schema/index.d.ts", + "default": "./dist/schema/index.js" + }, + "./control-host": { + "development": "./src/control-host.ts", + "types": "./dist/control-host.d.ts", + "default": "./dist/control-host.js" + }, + "./*": { + "development": "./src/*.ts", + "types": "./dist/*.d.ts", + "default": "./dist/*.js" + } + }, + "files": [ + "dist", + "README.md" + ], + "scripts": { + "build": "rm -rf dist && tsc -p tsconfig.build.json", + "prepublishOnly": "npm run build" + }, + "publishConfig": { + "access": "public", + "tag": "next", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./schema": { + "types": "./dist/schema/index.d.ts", + "default": "./dist/schema/index.js" + }, + "./control-host": { + "types": "./dist/control-host.d.ts", + "default": "./dist/control-host.js" + }, + "./*": { + "types": "./dist/*.d.ts", + "default": "./dist/*.js" + } + } + }, + "dependencies": { + "taskflow-core": "workspace:*" + }, + "peerDependencies": { + "typebox": "*" + } +} diff --git a/packages/taskflow-control/src/control-host.ts b/packages/taskflow-control/src/control-host.ts new file mode 100644 index 00000000..9c22acb4 --- /dev/null +++ b/packages/taskflow-control/src/control-host.ts @@ -0,0 +1,289 @@ +/** + * ControlHost — one control semantic in every mode (D18). + * + * Modes (P13 / RFC §5): + * - auto (default): ensure registry + project store; start or attach the user + * singleton multi-mount control (taskflowd OR embedded supervisor competing + * for the SAME lock + endpoint — D32). Control cannot run ⇒ fail closed. + * - coordinated: external control required; attach-only, down ⇒ fail closed. + * - standalone: explicit; in-process ControlHost, single-owner lease, no + * global concurrency claims. + * + * The host also enforces hello-before-RPC (P4) and fencing epoch checks + * (P16) on every dispatch, and accepts only a TE-backed ExecutionProvider as + * its execution authority (RFC §16 / P8). + */ + +import * as fs from "node:fs"; +import { getBuildInfo } from "taskflow-core"; +import { ControlError, bootstrapFailed } from "./errors.ts"; +import { createHelloGate, helloRequiredError, type HelloGate, type HelloVerdict } from "./hello.ts"; +import { resolveControlMode, type ControlMode } from "./modes.ts"; +import { + acquireUserSingleton, + defaultProcessIdentity, + recoverStaleEndpoint, + renewCoordinatorLease, + singletonPaths, + type ObservedProcessLike, + type ProcessIdentityLike, + type SingletonAcquireResult, + type SingletonPaths, +} from "./singleton.ts"; +import { CONTROL_WIRE_SCHEMA_VERSION, PROTOCOL_MAJOR, type NegotiationHandshake } from "./schema/index.ts"; +import type { ExecutionProvider } from "./te-provider.ts"; +import type { RunSnapshot } from "./schema/run.ts"; + +export interface ControlHostOptions { + /** Default auto (fresh install, P13). */ + mode?: ControlMode; + /** User control home (default `~/.taskflow/control`, TASKFLOW_HOME override). */ + controlHome?: string; + /** Project ControlStore path (S3 opens it; standalone opens the same store). */ + projectStorePath?: string; + /** The ONLY legal execution authority: TE-backed (see te-provider.ts). */ + provider: ExecutionProvider; + holderId?: string; + processIdentity?: ProcessIdentityLike; + inspectProcess?: (pid: number) => ObservedProcessLike; + now?: () => number; + leaseTtlMs?: number; + singletonPaths?: SingletonPaths; + /** Server hello for the negotiation gate (defaults to this package's build). */ + serverHello?: NegotiationHandshake; + /** Required features the host demands from clients (P4). */ + requiredFeatures?: readonly string[]; + /** Test seam: bypass the real singleton acquire. */ + singletonOverride?: () => SingletonAcquireResult; +} + +export type ControlHostState = "stopped" | "started" | "failed-closed"; +export type ControlHostSingleton = "won" | "attached" | "standalone" | "none"; + +export interface ControlHostStatus { + mode: ControlMode; + state: ControlHostState; + singleton: ControlHostSingleton; + fencingEpoch: number; + holderId?: string; + endpoint?: string; + globalAuthority: boolean; +} + +function defaultServerHello(): NegotiationHandshake { + const info = getBuildInfo(); + return { + protocolMajor: PROTOCOL_MAJOR, + supportedReadSchemas: ["taskflow.wire.v1"], + supportedWriteSchemas: ["taskflow.wire.v1"], + requiredFeatures: [], + offeredFeatures: [], + buildInfo: { + packageVersion: info.packageVersion, + gitCommit: info.gitCommit, + schemaVersion: CONTROL_WIRE_SCHEMA_VERSION, + ...(info.buildTime !== undefined ? { buildTime: info.buildTime } : {}), + }, + }; +} + +export class ControlHost { + readonly mode: ControlMode; + readonly provider: ExecutionProvider; + readonly paths: SingletonPaths; + readonly now: () => number; + readonly #helloGate: HelloGate; + readonly #options: ControlHostOptions; + #state: ControlHostState = "stopped"; + #singleton: ControlHostSingleton = "none"; + #fencingEpoch = 0; + #holderId?: string; + #releaseSingleton?: () => void; + + constructor(options: ControlHostOptions) { + if (!options.provider || options.provider.kind !== "te-resources") { + throw new ControlError( + "TF_AUTHORITY_REVOKED", + "ControlHost requires a TE-backed execution authority (kind te-resources); no other provider is legal in 0.3-C", + { recoveryAction: "none", sideEffects: "none" }, + ); + } + this.mode = resolveControlMode(options.mode); + this.provider = options.provider; + this.paths = options.singletonPaths ?? singletonPaths(options.controlHome); + this.now = options.now ?? Date.now; + this.#options = options; + this.#helloGate = createHelloGate(options.serverHello ?? defaultServerHello(), { + requiredFeatures: options.requiredFeatures, + }); + } + + get state(): ControlHostState { + return this.#state; + } + + get status(): ControlHostStatus { + return { + mode: this.mode, + state: this.#state, + singleton: this.#singleton, + fencingEpoch: this.#fencingEpoch, + holderId: this.#holderId, + endpoint: this.#singleton === "won" || this.#singleton === "attached" ? this.paths.endpointPath : undefined, + globalAuthority: this.mode !== "standalone", + }; + } + + get greeted(): boolean { + return this.#helloGate.greeted; + } + + hello(clientHello: unknown): HelloVerdict { + return this.#helloGate.hello(clientHello); + } + + async start(): Promise { + if (this.#state === "started") return this.status; + try { + switch (this.mode) { + case "standalone": { + // Explicit standalone: single-owner lease, no singleton + // competition, no global concurrency claims (P13). + this.#acquireStandaloneLease(); + this.#state = "started"; + this.#singleton = "standalone"; + break; + } + case "auto": + case "coordinated": { + recoverStaleEndpoint(this.paths, { inspectProcess: this.#options.inspectProcess }); + const result = this.#options.singletonOverride + ? this.#options.singletonOverride() + : acquireUserSingleton({ + paths: this.paths, + holderId: this.#options.holderId, + processIdentity: this.#options.processIdentity, + inspectProcess: this.#options.inspectProcess, + now: this.#options.now, + leaseTtlMs: this.#options.leaseTtlMs, + attachOnly: this.mode === "coordinated", + }); + if (result.status === "won") { + this.#singleton = "won"; + this.#holderId = result.holderId; + this.#fencingEpoch = result.fencingEpoch; + this.#releaseSingleton = result.release; + } else { + // Loser attaches as a client to the winner (D32) — never + // an independent multi-mount authority. + this.#singleton = "attached"; + this.#holderId = result.holderId; + this.#fencingEpoch = result.fencingEpoch; + } + this.#state = "started"; + break; + } + } + } catch (error) { + this.#state = "failed-closed"; + if (error instanceof ControlError) throw error; + throw bootstrapFailed(`ControlHost could not start in ${this.mode} mode: ${error instanceof Error ? error.message : String(error)}`); + } + return this.status; + } + + /** + * Hello-before-RPC dispatch. Every method call must follow a successful + * hello (P4) and carry a fencingEpoch >= the current lease epoch (P16). + */ + async dispatch(method: string, params: unknown, context: { fencingEpoch: number }): Promise { + if (!this.#helloGate.greeted) { + throw helloRequiredError(); + } + if (context.fencingEpoch < this.#fencingEpoch) { + throw new ControlError( + "TF_AUTHORITY_REVOKED", + `fencing epoch ${context.fencingEpoch} is stale; host epoch is ${this.#fencingEpoch}`, + { recoveryAction: "refresh", sideEffects: "none" }, + ); + } + switch (method) { + case "control.status": + return this.status as unknown as T; + case "control.probe": { + return await this.provider.probe() as unknown as T; + } + case "runs.status": { + const runId = typeof params === "object" && params !== null && "runId" in params + ? String((params as { runId: unknown }).runId) + : undefined; + const snapshot: RunSnapshot = { + runId: runId ?? "00000000-0000-0000-0000-000000000000", + projectId: "00000000-0000-0000-0000-000000000000", + controlDomainId: "00000000-0000-0000-0000-000000000000", + status: "unknown", + stage: "received", + slot: "none", + needsOperator: false, + }; + return snapshot as unknown as T; + } + default: + throw new ControlError( + "TF_COMMAND_FAILED", + `unknown control RPC ${JSON.stringify(method)}`, + { recoveryAction: "retry-new-command", sideEffects: "none" }, + ); + } + } + + /** Renew the coordinator lease while held (best-effort; S3 wires the timer). */ + renewLease(): void { + if (this.#singleton === "won" && this.#holderId !== undefined) { + renewCoordinatorLease(this.paths, this.#holderId, this.#fencingEpoch, this.#options.leaseTtlMs ?? 30_000, this.now); + } + } + + stop(): void { + if (this.#releaseSingleton) { + try { this.#releaseSingleton(); } catch { /* best effort */ } + this.#releaseSingleton = undefined; + } + this.#singleton = "none"; + this.#fencingEpoch = 0; + this.#state = "stopped"; + } + + #acquireStandaloneLease(): void { + const leasePath = `${this.paths.controlHome}/standalone-lease.json`; + const holderId = this.#options.holderId ?? `standalone-${defaultProcessIdentity().pid}`; + const identity = this.#options.processIdentity ?? defaultProcessIdentity(); + try { + const fd = fs.openSync(leasePath, "wx", 0o600); + try { + fs.writeFileSync(fd, JSON.stringify({ + version: 1, + holderId, + pid: identity.pid, + birthToken: identity.birthToken, + acquiredAt: this.now(), + endpoint: this.paths.endpointPath, + })); + } finally { + fs.closeSync(fd); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "EEXIST") { + throw bootstrapFailed(`standalone lease already exists at ${leasePath}; a standalone control is already running for this project`); + } + throw error; + } + this.#holderId = holderId; + this.#fencingEpoch = 1; + } +} + +// Re-export the mode/env surface used by hosts. +export { CONTROL_MODE_ENV, resolveControlMode } from "./modes.ts"; +export type { ControlMode } from "./modes.ts"; +export { defaultServerHello }; diff --git a/packages/taskflow-control/src/errors.ts b/packages/taskflow-control/src/errors.ts new file mode 100644 index 00000000..569a2f66 --- /dev/null +++ b/packages/taskflow-control/src/errors.ts @@ -0,0 +1,96 @@ +/** + * Unified 0.3-C error envelope (P4). `ControlError` is the runtime shape; + * `toEnvelope()` produces the closed wire ErrorEnvelope. Custom bare codes are + * forbidden — only the closed TF_* set (schema/transport.ts) is used. + */ + +import { + CONTROL_ERROR_CODES, + type ControlErrorCode, + type ErrorEnvelope, + type RecoveryAction, + type SideEffects, +} from "./schema/transport.ts"; + +export { CONTROL_ERROR_CODES } from "./schema/transport.ts"; +export type { ControlErrorCode, ErrorEnvelope } from "./schema/transport.ts"; + +export interface ControlErrorOptions { + recoveryAction?: RecoveryAction; + sideEffects?: SideEffects; + commandId?: string; + commitSeq?: number; + controlDomainId?: string; + projectId?: string; + cause?: unknown; +} + +/** P4 default mapping: most wire errors are command-level with no side effects. */ +const DEFAULT_RECOVERY: { recoveryAction: RecoveryAction; sideEffects: SideEffects } = { + recoveryAction: "none", + sideEffects: "none", +}; + +export class ControlError extends Error { + readonly code: ControlErrorCode; + readonly recoveryAction: RecoveryAction; + readonly sideEffects: SideEffects; + readonly commandId?: string; + readonly commitSeq?: number; + readonly controlDomainId?: string; + readonly projectId?: string; + + constructor(code: ControlErrorCode, message: string, options: ControlErrorOptions = {}) { + super(message, options.cause === undefined ? undefined : { cause: options.cause }); + this.name = "ControlError"; + this.code = code; + this.recoveryAction = options.recoveryAction ?? DEFAULT_RECOVERY.recoveryAction; + this.sideEffects = options.sideEffects ?? DEFAULT_RECOVERY.sideEffects; + this.commandId = options.commandId; + this.commitSeq = options.commitSeq; + this.controlDomainId = options.controlDomainId; + this.projectId = options.projectId; + } + + toEnvelope(): ErrorEnvelope { + return { + code: this.code, + message: this.message, + recoveryAction: this.recoveryAction, + sideEffects: this.sideEffects, + ...(this.commandId !== undefined ? { commandId: this.commandId } : {}), + ...(this.commitSeq !== undefined ? { commitSeq: this.commitSeq } : {}), + ...(this.controlDomainId !== undefined ? { controlDomainId: this.controlDomainId } : {}), + ...(this.projectId !== undefined ? { projectId: this.projectId } : {}), + }; + } +} + +/** Convenience builder for protocol/negotiation failures. */ +export function protocolError(message: string, options: ControlErrorOptions = {}): ControlError { + return new ControlError("TF_PROTOCOL_INCOMPATIBLE", message, options); +} + +/** + * P4 normative pin: TF_RECONCILE_REQUIRED is always `operator` + `unknown` and + * status RPCs return a normal snapshot, never a transport-level failure. + */ +export function reconcileRequired(message: string, options: Omit = {}): ControlError { + return new ControlError("TF_RECONCILE_REQUIRED", message, { + ...options, + recoveryAction: "operator", + sideEffects: "unknown", + }); +} + +/** Fail-closed helper for bootstrap/singleton failures (P13). */ +export function bootstrapFailed(message: string, options: ControlErrorOptions = {}): ControlError { + return new ControlError("TF_BOOTSTRAP_FAILED", message, options); +} + +/** Assert a value is a closed wire code (guards against bare custom codes). */ +export function assertClosedControlCode(code: string): asserts code is ControlErrorCode { + if (!(CONTROL_ERROR_CODES as readonly string[]).includes(code)) { + throw new Error(`TF_COMMAND_FAILED: not a closed 0.3-C wire error code: ${code}`); + } +} diff --git a/packages/taskflow-control/src/hello.ts b/packages/taskflow-control/src/hello.ts new file mode 100644 index 00000000..d05657b2 --- /dev/null +++ b/packages/taskflow-control/src/hello.ts @@ -0,0 +1,116 @@ +/** + * Hello-before-RPC negotiation gate (P4 / RFC §18). + * + * The first message on any control channel MUST be a NegotiationHandshake. + * No RPC is dispatched before a successful hello. Failures: + * - protocolMajor mismatch → TF_PROTOCOL_INCOMPATIBLE + * - no overlapping supported schema → TF_SCHEMA_UNSUPPORTED + * - unmet requiredFeatures (either direction) → TF_FEATURE_REQUIRED + * + * Compatible clients then exchange their supported read/write schema lists; + * an unknown schema on a later message → TF_SCHEMA_UNSUPPORTED (never silent + * reparse — wire-freeze rule 4). + */ + +import { protocolError, ControlError } from "./errors.ts"; +import { PROTOCOL_MAJOR, type NegotiationHandshake } from "./schema/transport.ts"; + +export type HelloVerdict = + | { ok: true; serverHello: NegotiationHandshake } + | { ok: false; error: ControlError }; + +export interface HelloGateOptions { + /** Features the server requires a client to offer. */ + requiredFeatures?: readonly string[]; + /** Extra schemas the server accepts beyond its declared list. */ + extraReadSchemas?: readonly string[]; +} + +export interface HelloGate { + /** True once a client has passed the handshake; RPCs before this are rejected. */ + readonly greeted: boolean; + /** The server's own handshake (sent back on success). */ + readonly serverHello: NegotiationHandshake; + hello(clientHello: unknown): HelloVerdict; +} + +export function createHelloGate( + serverHello: NegotiationHandshake, + options: HelloGateOptions = {}, +): HelloGate { + const requiredFeatures = [...(options.requiredFeatures ?? [])]; + let greeted = false; + + const validate = (clientHello: unknown): HelloVerdict => { + if (typeof clientHello !== "object" || clientHello === null || Array.isArray(clientHello)) { + return { ok: false, error: protocolError("hello must be a NegotiationHandshake object") }; + } + const hello = clientHello as NegotiationHandshake; + if (typeof hello.protocolMajor !== "number" || hello.protocolMajor !== PROTOCOL_MAJOR) { + return { + ok: false, + error: protocolError( + `protocolMajor ${JSON.stringify(hello.protocolMajor)} is incompatible; expected ${PROTOCOL_MAJOR}`, + ), + }; + } + if (!Array.isArray(hello.supportedReadSchemas) || !Array.isArray(hello.supportedWriteSchemas)) { + return { ok: false, error: protocolError("hello must declare supportedReadSchemas and supportedWriteSchemas") }; + } + const serverRead = new Set([...serverHello.supportedReadSchemas, ...(options.extraReadSchemas ?? [])]); + const schemaOverlap = hello.supportedReadSchemas.some((schema) => serverRead.has(schema)); + if (!schemaOverlap) { + return { + ok: false, + error: new ControlError( + "TF_SCHEMA_UNSUPPORTED", + `no overlapping supported read schema (client: ${hello.supportedReadSchemas.join(",")})`, + { recoveryAction: "refresh", sideEffects: "none" }, + ), + }; + } + const clientRequired = hello.requiredFeatures ?? []; + for (const feature of clientRequired) { + if (!(serverHello.offeredFeatures ?? []).includes(feature)) { + return { + ok: false, + error: new ControlError( + "TF_FEATURE_REQUIRED", + `client requires feature ${JSON.stringify(feature)} which this control does not offer`, + { recoveryAction: "refresh", sideEffects: "none" }, + ), + }; + } + } + for (const feature of requiredFeatures) { + if (!(hello.offeredFeatures ?? []).includes(feature)) { + return { + ok: false, + error: new ControlError( + "TF_FEATURE_REQUIRED", + `control requires feature ${JSON.stringify(feature)} which the client does not offer`, + { recoveryAction: "refresh", sideEffects: "none" }, + ), + }; + } + } + return { ok: true, serverHello }; + }; + + return { + get greeted() { + return greeted; + }, + serverHello, + hello(clientHello: unknown): HelloVerdict { + const verdict = validate(clientHello); + if (verdict.ok) greeted = true; + return verdict; + }, + }; +} + +/** Rejection for any RPC attempted before a successful hello. */ +export function helloRequiredError(): ControlError { + return protocolError("hello must precede any RPC on this control channel"); +} diff --git a/packages/taskflow-control/src/index.ts b/packages/taskflow-control/src/index.ts new file mode 100644 index 00000000..cfa47dbe --- /dev/null +++ b/packages/taskflow-control/src/index.ts @@ -0,0 +1,22 @@ +/** + * taskflow-control — 0.3-C control plane core. + * + * Exposes the frozen TypeBox wire contracts (`./schema`), the ControlHost + * daemon/supervisor/standalone contracts, the user singleton lock/endpoint/ + * fencing layer, hello-before-RPC negotiation, and the TE-backed execution + * provider (the only legal execution authority in 0.3-C). + * + * Depends only on taskflow-core (read-only TE schema/helper imports) and + * typebox. + */ + +export * from "./schema/index.ts"; +export * from "./errors.ts"; +export * from "./modes.ts"; +export * from "./hello.ts"; +export * from "./singleton.ts"; +export * from "./te-provider.ts"; +export * from "./control-host.ts"; + +// Convenience: the wire protocol major used by the negotiation gate. +export { PROTOCOL_MAJOR } from "./schema/transport.ts"; diff --git a/packages/taskflow-control/src/modes.ts b/packages/taskflow-control/src/modes.ts new file mode 100644 index 00000000..9027d194 --- /dev/null +++ b/packages/taskflow-control/src/modes.ts @@ -0,0 +1,114 @@ +/** + * controlMode selection + fail-closed mode contracts (P13 / RFC §5, D5). + * + * - auto (default): ensure registry + project store; start or attach the user + * singleton multi-mount control; control cannot run ⇒ fail closed. + * - coordinated: external control required; down ⇒ fail closed. + * - standalone: explicit; in-process ControlHost opens the same project + * ControlStore; single-owner lease; no global concurrency claims. + * + * Silent fallback auto → standalone is FORBIDDEN (P13). Only an explicit + * `controlMode: standalone` may run standalone. + */ + +import { ControlError, bootstrapFailed } from "./errors.ts"; + +export const CONTROL_MODES = ["auto", "coordinated", "standalone"] as const; +export type ControlMode = (typeof CONTROL_MODES)[number]; + +/** Environment override for the mode (documented convenience; CLI wins). */ +export const CONTROL_MODE_ENV = "TASKFLOW_CONTROL_MODE"; + +export interface ControlModeContract { + mode: ControlMode; + /** auto/coordinated participate in the user singleton; standalone is explicit in-process. */ + singletonRequired: boolean; + /** coordinated requires an existing external control; auto may start one. */ + externalControlRequired: boolean; + /** standalone never claims global (cross-project) concurrency authority. */ + globalAuthority: boolean; + failClosedDescription: string; +} + +const MODE_CONTRACTS: Record = { + auto: { + mode: "auto", + singletonRequired: true, + externalControlRequired: false, + globalAuthority: true, + failClosedDescription: "auto fails closed when the user singleton control cannot run; it never falls back to standalone", + }, + coordinated: { + mode: "coordinated", + singletonRequired: true, + externalControlRequired: true, + globalAuthority: true, + failClosedDescription: "coordinated fails closed when the external control is down", + }, + standalone: { + mode: "standalone", + singletonRequired: false, + externalControlRequired: false, + globalAuthority: false, + failClosedDescription: "standalone is explicit only; single-owner lease, no global concurrency claims", + }, +}; + +export function controlModeContract(mode: ControlMode): ControlModeContract { + return MODE_CONTRACTS[mode]; +} + +/** + * Parse the control mode. `undefined` / empty / "auto" → auto (fresh-install + * default, P13). Any other string fails closed (TF_BOOTSTRAP_FAILED) — an + * unknown mode must never silently choose a weaker one. + */ +export function parseControlMode(value: string | undefined): ControlMode { + const candidate = value?.trim().toLowerCase(); + if (candidate === undefined || candidate === "" || candidate === "auto") return "auto"; + if ((CONTROL_MODES as readonly string[]).includes(candidate)) return candidate as ControlMode; + throw bootstrapFailed(`invalid controlMode ${JSON.stringify(value)}; expected auto|coordinated|standalone`); +} + +/** Resolve mode from explicit option first, then the environment. */ +export function resolveControlMode(explicit?: string): ControlMode { + return parseControlMode(explicit ?? process.env[CONTROL_MODE_ENV]); +} + +export type ControlStartDecision = + | { mode: "auto"; action: "start-or-attach"; singletonRequired: true; globalAuthority: true } + | { mode: "coordinated"; action: "attach-external"; singletonRequired: true; globalAuthority: true } + | { mode: "standalone"; action: "standalone"; singletonRequired: false; globalAuthority: false }; + +/** + * Decide what the ControlHost must do for a mode, failing closed when the + * mode's control cannot be satisfied: + * - auto + control unavailable → TF_BOOTSTRAP_FAILED (never silent standalone) + * - coordinated + control unavailable → TF_JOURNAL_UNAVAILABLE (control down) + * - standalone → explicit standalone regardless of singleton availability + */ +export function resolveControlStart(mode: ControlMode, controlAvailable: boolean): ControlStartDecision { + switch (mode) { + case "auto": { + if (!controlAvailable) { + throw bootstrapFailed( + "auto mode requires the user singleton control; control cannot run and silent fallback to standalone is forbidden (P13)", + ); + } + return { mode: "auto", action: "start-or-attach", singletonRequired: true, globalAuthority: true }; + } + case "coordinated": { + if (!controlAvailable) { + throw new ControlError( + "TF_JOURNAL_UNAVAILABLE", + "coordinated mode requires an external control which is down; failing closed (P13)", + { recoveryAction: "refresh", sideEffects: "none" }, + ); + } + return { mode: "coordinated", action: "attach-external", singletonRequired: true, globalAuthority: true }; + } + case "standalone": { + return { mode: "standalone", action: "standalone", singletonRequired: false, globalAuthority: false }; + } + } +} diff --git a/packages/taskflow-control/src/schema/approval.ts b/packages/taskflow-control/src/schema/approval.ts new file mode 100644 index 00000000..bb38bcb0 --- /dev/null +++ b/packages/taskflow-control/src/schema/approval.ts @@ -0,0 +1,106 @@ +/** + * Approval wire types (🟥 NEW) — 0.2.4 protocol upgrade per P15. + * + * Decisions: P15 — decision is a CommandRecord (kind approval.decide) with + * expectedRunVersion CAS (first commit wins); timeout → request `expired` and + * Run → `blocked` (never permanent paused); three durability modes; edit + * output → OutputContract check; edit plan → re-Link. + */ + +import { Type } from "typebox"; +import { StringEnum } from "taskflow-core/typebox-helpers"; +import { Sha256HexSchema, UuidSchema } from "./common.ts"; +import { ArtifactRefSchema, type ArtifactRef } from "./evidence.ts"; + +export const ApprovalModeSchema = StringEnum(["compat-auto-reject", "durable-optional", "durable-required"]); +export type ApprovalMode = "compat-auto-reject" | "durable-optional" | "durable-required"; + +export const ApprovalDecisionSchema = StringEnum(["approve", "reject", "edit"]); +export type ApprovalDecision = "approve" | "reject" | "edit"; + +export const ApprovalRequestStatusSchema = StringEnum([ + "pending", + "approved", + "rejected", + "edited", + "expired", + "cancelled", +]); +export type ApprovalRequestStatus = "pending" | "approved" | "rejected" | "edited" | "expired" | "cancelled"; + +export const ApprovalRequestSchema = Type.Object( + { + approvalRequestId: UuidSchema, + runId: UuidSchema, + nodeInstanceId: Type.String({ minLength: 1 }), + boundPlanHash: Type.Optional(Type.String({ minLength: 1 })), + boundFragmentHash: Type.Optional(Type.String({ minLength: 1 })), + expectedRunVersion: Type.Integer({ minimum: 0 }), + allowedDecisions: Type.Array(ApprovalDecisionSchema, { minItems: 1 }), + owner: Type.String({ minLength: 1 }), + audience: Type.Optional(Type.Array(Type.String({ minLength: 1 }))), + requiredPrincipals: Type.Optional(Type.Array(Type.String({ minLength: 1 }))), + deadline: Type.Integer({ minimum: 0 }), + timeoutPolicy: StringEnum(["auto-reject", "block", "durable-expire"]), + status: ApprovalRequestStatusSchema, + createdAt: Type.Integer({ minimum: 0 }), + decidedAt: Type.Optional(Type.Integer({ minimum: 0 })), + decisionCommandId: Type.Optional(UuidSchema), + editArtifactRef: Type.Optional(ArtifactRefSchema), + }, + { additionalProperties: false }, +); +export type ApprovalRequest = { + approvalRequestId: string; + runId: string; + nodeInstanceId: string; + boundPlanHash?: string; + boundFragmentHash?: string; + expectedRunVersion: number; + allowedDecisions: ApprovalDecision[]; + owner: string; + audience?: string[]; + requiredPrincipals?: string[]; + deadline: number; + timeoutPolicy: "auto-reject" | "block" | "durable-expire"; + status: ApprovalRequestStatus; + createdAt: number; + decidedAt?: number; + decisionCommandId?: string; + editArtifactRef?: ArtifactRef; +}; + +/** + * ApprovalDecisionCommand (P15) = CommandRecord.kind "approval.decide" plus + * decision + expectedRunVersion CAS. A decision is a command; disclosure is + * re-authorized live (P12). First commit wins on expectedRunVersion. + */ +export const ApprovalDecisionCommandSchema = Type.Object( + { + commandId: UuidSchema, + kind: Type.Literal("approval.decide"), + requestHash: Sha256HexSchema, + callerPrincipal: Type.String({ minLength: 1 }), + projectId: UuidSchema, + controlDomainId: UuidSchema, + approvalRequestId: UuidSchema, + runId: UuidSchema, + expectedRunVersion: Type.Integer({ minimum: 0 }), + decision: ApprovalDecisionSchema, + recordedAt: Type.Integer({ minimum: 0 }), + }, + { additionalProperties: false }, +); +export type ApprovalDecisionCommand = { + commandId: string; + kind: "approval.decide"; + requestHash: string; + callerPrincipal: string; + projectId: string; + controlDomainId: string; + approvalRequestId: string; + runId: string; + expectedRunVersion: number; + decision: ApprovalDecision; + recordedAt: number; +}; diff --git a/packages/taskflow-control/src/schema/commands.ts b/packages/taskflow-control/src/schema/commands.ts new file mode 100644 index 00000000..087ff4ba --- /dev/null +++ b/packages/taskflow-control/src/schema/commands.ts @@ -0,0 +1,276 @@ +/** + * Command + event wire types (🟥 NEW) — the authoritative narrative. + * + * Decisions: P12 (immutable CommandRecord committed atomically with its + * ControlEvents; `(controlDomainId, commandId)` unique), P11 (commitSeq never + * renumbered; CompactionCheckpointEvent as the only authoritative cursor + * floor; cursor leases). + */ + +import { createHash } from "node:crypto"; +import { Type } from "typebox"; +import { StringEnum } from "taskflow-core/typebox-helpers"; +import { + CONTROL_WIRE_SCHEMA_VERSION, + Sha256HexSchema, + UuidSchema, +} from "./common.ts"; +import type { ArtifactRef } from "./evidence.ts"; + +// --------------------------------------------------------------------------- +// Command kinds (closed union; P15/P16 kinds, plus the base run command) +// --------------------------------------------------------------------------- + +export const CommandKindSchema = StringEnum([ + "run.submit", + "approval.decide", + "coordinator.setMaxActiveRuns", + "coordinator.forceRelease", +]); +export type CommandKind = "run.submit" | "approval.decide" | "coordinator.setMaxActiveRuns" | "coordinator.forceRelease"; + +export const CommandStatusSchema = StringEnum(["accepted", "completed", "failed"]); +export type CommandStatus = "accepted" | "completed" | "failed"; + +// --------------------------------------------------------------------------- +// CommandRecord (P12 / RFC §9.2) — immutable authority record +// --------------------------------------------------------------------------- + +export const CommandRecordSchema = Type.Object( + { + commandId: UuidSchema, + kind: CommandKindSchema, + requestHash: Sha256HexSchema, + callerPrincipal: Type.String({ minLength: 1 }), + authorizationContextHash: Sha256HexSchema, + projectId: UuidSchema, + controlDomainId: UuidSchema, + status: CommandStatusSchema, + firstCommitSeq: Type.Integer({ minimum: 1 }), + lastCommitSeq: Type.Integer({ minimum: 1 }), + responseArtifactRef: Type.Optional( + Type.Object( + { + digest: Type.String({ minLength: 1 }), + size: Type.Integer({ minimum: 0 }), + mediaType: Type.String({ minLength: 1 }), + storageClass: Type.String({ minLength: 1 }), + redactionClass: Type.String({ minLength: 1 }), + }, + { additionalProperties: false }, + ), + ), + recordedAt: Type.Integer({ minimum: 0 }), + }, + { additionalProperties: false }, +); +export type CommandRecord = { + commandId: string; + kind: CommandKind; + requestHash: string; + callerPrincipal: string; + authorizationContextHash: string; + projectId: string; + controlDomainId: string; + status: CommandStatus; + firstCommitSeq: number; + lastCommitSeq: number; + responseArtifactRef?: ArtifactRef; + recordedAt: number; +}; + +// --------------------------------------------------------------------------- +// ControlEvent payload — closed union of S2-known narrative kinds +// (P11 compaction.checkpoint; RFC §8.4 reconcile.*; §16 dispatch.*; +// §17 approval.*; run terminal). Additive extension bumps schemaVersion. +// --------------------------------------------------------------------------- + +export const ControlEventPayloadSchema = Type.Union([ + Type.Object( + { kind: Type.Literal("command.recorded"), commandId: UuidSchema }, + { additionalProperties: false }, + ), + Type.Object( + { + kind: Type.Literal("reconcile.started"), + attempt: Type.Integer({ minimum: 1 }), + }, + { additionalProperties: false }, + ), + Type.Object( + { + kind: Type.Literal("reconcile.settled"), + outcome: StringEnum(["running", "terminal", "exhausted"]), + terminalStatus: Type.Optional(StringEnum(["completed", "failed", "blocked", "cancelled"])), + }, + { additionalProperties: false }, + ), + Type.Object( + { + kind: Type.Literal("dispatch.acknowledged"), + providerJobHandle: Type.Optional(Type.String({ minLength: 1 })), + }, + { additionalProperties: false }, + ), + Type.Object( + { kind: Type.Literal("dispatch.rejected"), reason: Type.String({ minLength: 1 }) }, + { additionalProperties: false }, + ), + Type.Object( + { kind: Type.Literal("dispatch.ambiguous") }, + { additionalProperties: false }, + ), + Type.Object( + { + kind: Type.Literal("run.terminal"), + status: StringEnum(["completed", "failed", "blocked", "cancelled"]), + }, + { additionalProperties: false }, + ), + Type.Object( + { + kind: Type.Literal("approval.pending"), + approvalRequestId: UuidSchema, + }, + { additionalProperties: false }, + ), + Type.Object( + { + kind: Type.Literal("approval.settled"), + decision: StringEnum(["approved", "rejected", "expired", "cancelled"]), + }, + { additionalProperties: false }, + ), + Type.Object( + { + kind: Type.Literal("compaction.checkpoint"), + throughCommitSeq: Type.Integer({ minimum: 1 }), + }, + { additionalProperties: false }, + ), +]); +export type ControlEventPayload = { + kind: "command.recorded"; + commandId: string; +} | { + kind: "reconcile.started"; + attempt: number; +} | { + kind: "reconcile.settled"; + outcome: "running" | "terminal" | "exhausted"; + terminalStatus?: "completed" | "failed" | "blocked" | "cancelled"; +} | { + kind: "dispatch.acknowledged"; + providerJobHandle?: string; +} | { + kind: "dispatch.rejected"; + reason: string; +} | { + kind: "dispatch.ambiguous"; +} | { + kind: "run.terminal"; + status: "completed" | "failed" | "blocked" | "cancelled"; +} | { + kind: "approval.pending"; + approvalRequestId: string; +} | { + kind: "approval.settled"; + decision: "approved" | "rejected" | "expired" | "cancelled"; +} | { + kind: "compaction.checkpoint"; + throughCommitSeq: number; +}; + +// --------------------------------------------------------------------------- +// ControlEvent (P12 / RFC §10) — ledger envelope +// --------------------------------------------------------------------------- + +export const ControlEventSchema = Type.Object( + { + eventId: UuidSchema, + schemaVersion: Type.Literal(CONTROL_WIRE_SCHEMA_VERSION), + controlDomainId: UuidSchema, + streamId: Type.String({ minLength: 1 }), + streamSeq: Type.Integer({ minimum: 1 }), + commitSeq: Type.Integer({ minimum: 1 }), + commandId: Type.Optional(UuidSchema), + commandEventIndex: Type.Optional(Type.Integer({ minimum: 0 })), + causationId: UuidSchema, + correlationId: UuidSchema, + projectId: UuidSchema, + recordedAt: Type.Integer({ minimum: 0 }), + payload: ControlEventPayloadSchema, + }, + { additionalProperties: false }, +); +export type ControlEvent = { + eventId: string; + schemaVersion: typeof CONTROL_WIRE_SCHEMA_VERSION; + controlDomainId: string; + streamId: string; + streamSeq: number; + commitSeq: number; + commandId?: string; + commandEventIndex?: number; + causationId: string; + correlationId: string; + projectId: string; + recordedAt: number; + payload: ControlEventPayload; +}; + +// --------------------------------------------------------------------------- +// CompactionCheckpointEvent (P11) — journal-internal cursor floor +// --------------------------------------------------------------------------- + +export const CompactionCheckpointEventSchema = Type.Object( + { + kind: Type.Literal("compaction.checkpoint"), + throughCommitSeq: Type.Integer({ minimum: 1 }), + }, + { additionalProperties: false }, +); +export type CompactionCheckpointEvent = { kind: "compaction.checkpoint"; throughCommitSeq: number }; + +// --------------------------------------------------------------------------- +// CursorState (P11) — cursor/subscription lease +// --------------------------------------------------------------------------- + +export const CursorStateSchema = Type.Object( + { + minAvailableCommitSeq: Type.Integer({ minimum: 1 }), + cursorId: UuidSchema, + leaseExpiresAt: Type.Integer({ minimum: 0 }), + }, + { additionalProperties: false }, +); +export type CursorState = { + minAvailableCommitSeq: number; + cursorId: string; + leaseExpiresAt: number; +}; + +// --------------------------------------------------------------------------- +// Convenience: P12 idempotency / cross-principal markers used by the hello +// and command layers. Not a separate wire doc — checked against CommandRecord. +// --------------------------------------------------------------------------- + +/** Canonical request hash over the command body (P12 §9.4). */ +export function commandRequestHash(body: unknown): string { + return createHash("sha256").update(canonicalJson(body), "utf8").digest("hex"); +} + +function canonicalJson(value: unknown): string { + if (value === null) return "null"; + if (typeof value === "boolean" || typeof value === "number") return JSON.stringify(value); + if (typeof value === "string") return JSON.stringify(value); + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`; + if (typeof value === "object") { + const record = value as Record; + const keys = Object.keys(record) + .filter((key) => record[key] !== undefined) + .sort(); + return `{${keys.map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`).join(",")}}`; + } + return "null"; +} diff --git a/packages/taskflow-control/src/schema/common.ts b/packages/taskflow-control/src/schema/common.ts new file mode 100644 index 00000000..7e2a7641 --- /dev/null +++ b/packages/taskflow-control/src/schema/common.ts @@ -0,0 +1,43 @@ +/** + * Shared 0.3-C wire conventions: schema version, scalar formats, and the + * closed-enum helper. Follows the TE convention (`taskflow-core/typebox-helpers` + * StringEnum) so providers that do not support anyOf/const keep working. + */ + +import { Type } from "typebox"; +import { StringEnum } from "taskflow-core/typebox-helpers"; + +/** + * Single wire schema version for the 0.3-C control plane. Every top-level wire + * document that carries `schemaVersion` uses this constant; additive evolution + * bumps it (wire-freeze rule 4: freeze allows only additive changes + bump). + */ +export const CONTROL_WIRE_SCHEMA_VERSION = 1; + +/** UUID v4 scalar convention (ControlDomainId, projectId, eventId, ...). */ +export const UuidSchema = Type.String({ + minLength: 36, + maxLength: 36, + pattern: "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", + description: "UUID v4 scalar", +}); + +/** 64-lowercase-hex SHA-256 digest (the only hash family, P6). */ +export const Sha256HexSchema = Type.String({ + minLength: 64, + maxLength: 64, + pattern: "^[0-9a-f]{64}$", + description: "SHA-256 digest as 64 lowercase hex chars", +}); + +/** Canonical (key-sorted, undefined-dropped) hash over a wire document. */ +export const CanonicalHashRefSchema = Type.String({ + minLength: 66, + maxLength: 96, + pattern: "^[a-z0-9]+:[0-9a-f]{64}$", + description: "Domain-separated content hash, e.g. ir:<64-hex> or plan:<64-hex>", +}); + +export type ControlWireSchemaVersion = typeof CONTROL_WIRE_SCHEMA_VERSION; + +export { StringEnum }; diff --git a/packages/taskflow-control/src/schema/coordinator.ts b/packages/taskflow-control/src/schema/coordinator.ts new file mode 100644 index 00000000..e8503e5a --- /dev/null +++ b/packages/taskflow-control/src/schema/coordinator.ts @@ -0,0 +1,150 @@ +/** + * UserCoordinatorStore wire types (🟥 NEW) — singleton lease + global + * concurrency reservations + narrow coordinator commands. + * + * Decisions: P16 — slots ≡ 1 per admitted run; capacity formula + * count(reserved|committed|orphan-suspect) ≤ maxActiveRuns; committed never + * TTL-released (D37 normalRelease/forceRelease only); D2 admission uniqueness; + * D3 legacy residue fail-closed; D4 idempotent release. + */ + +import { Type } from "typebox"; +import { StringEnum } from "taskflow-core/typebox-helpers"; +import { Sha256HexSchema, UuidSchema } from "./common.ts"; + +// --------------------------------------------------------------------------- +// CoordinatorLease (P16 / D32) +// --------------------------------------------------------------------------- + +export const CoordinatorLeaseSchema = Type.Object( + { + holderId: Type.String({ minLength: 1 }), + fencingEpoch: Type.Integer({ minimum: 0 }), + endpoint: Type.String({ minLength: 1 }), + expiresAt: Type.Integer({ minimum: 0 }), + }, + { additionalProperties: false }, +); +export type CoordinatorLease = { + holderId: string; + fencingEpoch: number; + endpoint: string; + expiresAt: number; +}; + +// --------------------------------------------------------------------------- +// ConcurrencyReservation (P16) +// --------------------------------------------------------------------------- + +export const ReservationStateSchema = StringEnum(["reserved", "committed", "released", "expired", "orphan-suspect"]); +export type ReservationState = "reserved" | "committed" | "released" | "expired" | "orphan-suspect"; + +export const ConcurrencyReservationSchema = Type.Object( + { + reservationId: UuidSchema, + state: ReservationStateSchema, + slots: Type.Literal(1), + projectId: UuidSchema, + projectControlDomainId: UuidSchema, + runId: UuidSchema, + // Required once admitted (state ∈ {committed, orphan-suspect}); absent + // during the pre-admit reserved window (P16 crash matrix). + projectAdmitCommitSeq: Type.Optional(Type.Integer({ minimum: 1 })), + attemptId: Type.Optional(UuidSchema), + providerJobHandle: Type.Optional(Type.String({ minLength: 1 })), + coordinatorEpoch: Type.Integer({ minimum: 0 }), + reservedExpiresAt: Type.Optional(Type.Integer({ minimum: 0 })), + renewedAt: Type.Optional(Type.Integer({ minimum: 0 })), + }, + { additionalProperties: false }, +); +export type ConcurrencyReservation = { + reservationId: string; + state: ReservationState; + slots: 1; + projectId: string; + projectControlDomainId: string; + runId: string; + projectAdmitCommitSeq?: number; + attemptId?: string; + providerJobHandle?: string; + coordinatorEpoch: number; + reservedExpiresAt?: number; + renewedAt?: number; +}; + +/** + * P16 D2/D3 invariants, enforced at the store boundary (fail closed): + * - committed/orphan-suspect rows MUST carry projectAdmitCommitSeq (D3 residue + * that keeps a TTL field on a committed row is a separate check). + * - a reservation with a stale state/identity mismatch is rejected. + * TypeBox alone cannot express the state-conditional requirement, so the + * store layer asserts it here. + */ +export function assertReservationInvariants(reservation: ConcurrencyReservation): void { + if (reservation.state === "committed" || reservation.state === "orphan-suspect") { + if (reservation.projectAdmitCommitSeq === undefined) { + throw new Error("TF_ADMISSION_BINDING_CONFLICT: committed/orphan-suspect reservation requires projectAdmitCommitSeq"); + } + if (reservation.reservedExpiresAt !== undefined) { + throw new Error("TF_ADMISSION_BINDING_CONFLICT: committed/orphan-suspect reservation must not retain reservedExpiresAt (P16 D3)"); + } + } + if (reservation.slots !== 1) { + throw new Error("TF_ADMISSION_BINDING_CONFLICT: 0.3 slots are fixed at 1 (P16)"); + } +} + +// --------------------------------------------------------------------------- +// CoordinatorCommandRecord (P16 / D6) — narrow command authority +// --------------------------------------------------------------------------- + +export const CoordinatorCommandKindSchema = StringEnum(["setMaxActiveRuns", "forceRelease"]); +export type CoordinatorCommandKind = "setMaxActiveRuns" | "forceRelease"; + +export const CoordinatorCommandStatusSchema = StringEnum(["accepted", "completed", "failed"]); +export type CoordinatorCommandStatus = "accepted" | "completed" | "failed"; + +export const CoordinatorCommandRecordSchema = Type.Object( + { + commandId: UuidSchema, + kind: CoordinatorCommandKindSchema, + requestHash: Sha256HexSchema, + callerPrincipal: Type.String({ minLength: 1 }), + firstCommitSeq: Type.Integer({ minimum: 1 }), + lastCommitSeq: Type.Integer({ minimum: 1 }), + status: CoordinatorCommandStatusSchema, + }, + { additionalProperties: false }, +); +export type CoordinatorCommandRecord = { + commandId: string; + kind: CoordinatorCommandKind; + requestHash: string; + callerPrincipal: string; + firstCommitSeq: number; + lastCommitSeq: number; + status: CoordinatorCommandStatus; +}; + +// --------------------------------------------------------------------------- +// CapacitySnapshot (P16) — metering/statistics +// --------------------------------------------------------------------------- + +export const CapacitySnapshotSchema = Type.Object( + { + maxActiveRuns: Type.Integer({ minimum: 1 }), + active: Type.Integer({ minimum: 0 }), + reserved: Type.Integer({ minimum: 0 }), + committed: Type.Integer({ minimum: 0 }), + orphanSuspect: Type.Integer({ minimum: 0 }), + }, + { additionalProperties: false }, +); +export type CapacitySnapshot = { + maxActiveRuns: number; + active: number; + reserved: number; + committed: number; + orphanSuspect: number; +}; diff --git a/packages/taskflow-control/src/schema/evidence.ts b/packages/taskflow-control/src/schema/evidence.ts new file mode 100644 index 00000000..5efc6646 --- /dev/null +++ b/packages/taskflow-control/src/schema/evidence.ts @@ -0,0 +1,131 @@ +/** + * Evidence wire types (🟥 NEW): ArtifactRef, Receipt, ReceiptAssurance. + * + * Decisions: P6 (digest is not a bearer token; ledger reachability authorizes + * read), P11 (Receipt survives compaction; manifests issued at receipt time + * remain valid), P14 (receipts dir under the store), P8 (assurance.enforcement + * records the promise; observedRevocationLatencyMs optional). + */ + +import { Type } from "typebox"; +import { StringEnum } from "taskflow-core/typebox-helpers"; +import { CONTROL_WIRE_SCHEMA_VERSION, Sha256HexSchema, UuidSchema } from "./common.ts"; +import type { ConfidentialityLabel, IntegrityLabel } from "taskflow-core/effects/types"; +import { EnforcementCapabilitiesSchema, type EnforcementCapabilities } from "./policy.ts"; + +// --------------------------------------------------------------------------- +// ArtifactRef (P6 / RFC §12) +// --------------------------------------------------------------------------- + +export const ArtifactRefSchema = Type.Object( + { + digest: Sha256HexSchema, + size: Type.Integer({ minimum: 0 }), + mediaType: Type.String({ minLength: 1 }), + storageClass: Type.String({ minLength: 1 }), + redactionClass: Type.String({ minLength: 1 }), + }, + { additionalProperties: false }, +); +export type ArtifactRef = { + digest: string; + size: number; + mediaType: string; + storageClass: string; + redactionClass: string; +}; + +// --------------------------------------------------------------------------- +// ReceiptAssurance (P8/P11) +// --------------------------------------------------------------------------- + +export const ProviderOutcomeSchema = StringEnum(["completed", "failed", "ambiguous", "operator-intervened"]); +export type ProviderOutcome = "completed" | "failed" | "ambiguous" | "operator-intervened"; + +export const ArtifactIntegritySchema = StringEnum(["verified", "unknown"]); +export type ArtifactIntegrity = "verified" | "unknown"; + +export const ReceiptAssuranceSchema = Type.Object( + { + journalContinuity: Type.Boolean(), + providerOutcome: ProviderOutcomeSchema, + artifactIntegrity: ArtifactIntegritySchema, + provenance: Type.Object( + { + confidentiality: StringEnum(["public", "internal", "secret"]), + integrity: StringEnum(["untrusted", "project", "verified"]), + }, + { additionalProperties: false }, + ), + enforcement: Type.Object( + { + capabilities: EnforcementCapabilitiesSchema, + observedRevocationLatencyMs: Type.Optional(Type.Integer({ minimum: 0 })), + }, + { additionalProperties: false }, + ), + }, + { additionalProperties: false }, +); +export type ReceiptAssurance = { + journalContinuity: boolean; + providerOutcome: ProviderOutcome; + artifactIntegrity: ArtifactIntegrity; + provenance: { confidentiality: ConfidentialityLabel; integrity: IntegrityLabel }; + enforcement: { + capabilities: EnforcementCapabilities; + observedRevocationLatencyMs?: number; + }; +}; + +// --------------------------------------------------------------------------- +// Receipt (P11/P14) — issued once, immutable +// --------------------------------------------------------------------------- + +export const BuildInfoWireSchema = Type.Object( + { + packageVersion: Type.String({ minLength: 1 }), + gitCommit: Type.String({ minLength: 1 }), + schemaVersion: Type.Integer({ minimum: 0 }), + buildTime: Type.Optional(Type.Integer({ minimum: 0 })), + }, + { additionalProperties: false }, +); +export type BuildInfoWire = { + packageVersion: string; + gitCommit: string; + schemaVersion: number; + buildTime?: number; +}; + +export const ReceiptSchema = Type.Object( + { + schemaVersion: Type.Literal(CONTROL_WIRE_SCHEMA_VERSION), + controlDomainId: UuidSchema, + runId: UuidSchema, + boundPlanHash: Type.Optional(Type.String({ minLength: 1 })), + boundFragmentHash: Type.Optional(Type.String({ minLength: 1 })), + eventManifest: Type.Array(UuidSchema), + manifestRoot: Sha256HexSchema, + startCommitSeq: Type.Integer({ minimum: 1 }), + endCommitSeq: Type.Integer({ minimum: 1 }), + artifactRefs: Type.Array(ArtifactRefSchema), + assurance: ReceiptAssuranceSchema, + buildInfo: BuildInfoWireSchema, + }, + { additionalProperties: false }, +); +export type Receipt = { + schemaVersion: typeof CONTROL_WIRE_SCHEMA_VERSION; + controlDomainId: string; + runId: string; + boundPlanHash?: string; + boundFragmentHash?: string; + eventManifest: string[]; + manifestRoot: string; + startCommitSeq: number; + endCommitSeq: number; + artifactRefs: ArtifactRef[]; + assurance: ReceiptAssurance; + buildInfo: BuildInfoWire; +}; diff --git a/packages/taskflow-control/src/schema/header.ts b/packages/taskflow-control/src/schema/header.ts new file mode 100644 index 00000000..834503e2 --- /dev/null +++ b/packages/taskflow-control/src/schema/header.ts @@ -0,0 +1,120 @@ +/** + * ControlDomain / ControlStore / Registry / Bootstrap wire types (🟥 NEW). + * + * Decisions: P3 (domain + registry identity), P14 (store header), P13 + * (bootstrap manifest + layout). The store header is the authoritative + * identity source; the registry is a rebuildable non-authoritative projection. + */ + +import { Type } from "typebox"; +import { CONTROL_WIRE_SCHEMA_VERSION, UuidSchema } from "./common.ts"; + +// --------------------------------------------------------------------------- +// Scalars (P3: UUID 标量约定) +// --------------------------------------------------------------------------- + +/** 1:1 with a project ledger; stable across daemon restarts and mode switches. */ +export const ControlDomainIdSchema = UuidSchema; +export type ControlDomainId = string; + +/** Stable project UUID; lives in the ControlStore header and the registry. */ +export const ProjectIdSchema = UuidSchema; +export type ProjectId = string; + +/** Directory binding evidence for swap/move detection (P3 move/rebind). */ +export const DirectoryBindingSchema = Type.Object( + { + canonicalPath: Type.String({ minLength: 1 }), + device: Type.String({ minLength: 1 }), + inode: Type.String({ minLength: 1 }), + }, + { additionalProperties: false }, +); +export type DirectoryBinding = { + canonicalPath: string; + device: string; + inode: string; +}; + +// --------------------------------------------------------------------------- +// ControlStoreHeader (P14) — authoritative identity source +// --------------------------------------------------------------------------- + +export const ControlStoreHeaderSchema = Type.Object( + { + projectId: ProjectIdSchema, + controlDomainId: ControlDomainIdSchema, + schemaVersion: Type.Literal(CONTROL_WIRE_SCHEMA_VERSION), + directoryBinding: DirectoryBindingSchema, + }, + { additionalProperties: false }, +); +export type ControlStoreHeader = { + projectId: ProjectId; + controlDomainId: ControlDomainId; + schemaVersion: typeof CONTROL_WIRE_SCHEMA_VERSION; + directoryBinding: DirectoryBinding; +}; + +// --------------------------------------------------------------------------- +// ControlRegistryEntry (P3) — non-authoritative discovery/projection +// --------------------------------------------------------------------------- + +export const MountStateSchema = Type.Union([ + Type.Literal("unmounted"), + Type.Literal("mounting"), + Type.Literal("mounted"), + Type.Literal("drained"), +]); +export type MountState = "unmounted" | "mounting" | "mounted" | "drained"; + +export const ControlRegistryEntrySchema = Type.Object( + { + projectId: ProjectIdSchema, + controlDomainId: ControlDomainIdSchema, + storePath: Type.String({ minLength: 1 }), + directoryBinding: DirectoryBindingSchema, + mountState: MountStateSchema, + summary: Type.Optional(Type.String()), + }, + { additionalProperties: false }, +); +export type ControlRegistryEntry = { + projectId: ProjectId; + controlDomainId: ControlDomainId; + storePath: string; + directoryBinding: DirectoryBinding; + mountState: MountState; + summary?: string; +}; + +// --------------------------------------------------------------------------- +// ControlStoreStatus (P14) — store health/recovery state +// --------------------------------------------------------------------------- + +export const ControlStoreStatusSchema = Type.Union([ + Type.Literal("healthy"), + Type.Literal("recovering"), + Type.Literal("fail-closed"), +]); +export type ControlStoreStatus = "healthy" | "recovering" | "fail-closed"; + +// --------------------------------------------------------------------------- +// BootstrapManifest (P13) — install/start contract +// --------------------------------------------------------------------------- + +export const BootstrapManifestSchema = Type.Object( + { + controlBinaryPath: Type.String({ minLength: 1 }), + controlHome: Type.String({ minLength: 1 }), + singletonEndpoint: Type.String({ minLength: 1 }), + fencingEpoch: Type.Integer({ minimum: 0 }), + }, + { additionalProperties: false }, +); +export type BootstrapManifest = { + controlBinaryPath: string; + controlHome: string; + singletonEndpoint: string; + fencingEpoch: number; +}; diff --git a/packages/taskflow-control/src/schema/index.ts b/packages/taskflow-control/src/schema/index.ts new file mode 100644 index 00000000..b4f95b26 --- /dev/null +++ b/packages/taskflow-control/src/schema/index.ts @@ -0,0 +1,41 @@ +/** + * taskflow-control wire schema barrel — the single source of truth for the + * 0.3-C frozen TypeBox contracts (wire-freeze §3). + * + * REUSE (TE, import read-only): EffectDeclSchema / EffectIRSchema come from + * `taskflow-core/effects/schema`; SecretRef / ServiceRef / labels from + * `taskflow-core/effects/types`; canonical-hash from + * `taskflow-core/flowir/canonical-hash`. The `resources/*` shapes that + * taskflow-core deliberately does not export are mirrored in + * `./te-mirrors.ts` (TE remains the authority). + * + * Every top-level document carries `schemaVersion` where the ADR pins it + * (ControlStoreHeader, ControlEvent, BoundPlan, BoundFragment, Receipt) and + * closed `additionalProperties: false` objects throughout. + */ + +export { + CONTROL_WIRE_SCHEMA_VERSION, + UuidSchema, + Sha256HexSchema, + CanonicalHashRefSchema, + StringEnum, +} from "./common.ts"; +export type { ControlWireSchemaVersion } from "./common.ts"; + +export * from "./te-mirrors.ts"; +export * from "./header.ts"; +export * from "./commands.ts"; +export * from "./plan.ts"; +export * from "./run.ts"; +export * from "./approval.ts"; +export * from "./coordinator.ts"; +export * from "./policy.ts"; +export * from "./evidence.ts"; +export * from "./transport.ts"; + +// REUSE re-exports from taskflow-core (TE schemas are the authority; the +// control wire references them as read-only evidence). +export { EffectDeclSchema, EffectIRSchema } from "taskflow-core/effects/schema"; +export type { EffectDecl, EffectIR, EffectKind, ConfidentialityLabel, IntegrityLabel, SecretRef, ServiceRef } from "taskflow-core/effects/types"; +export { hashFlowIR, hashNode, canonicalizeFlowIR, canonicalizeNode } from "taskflow-core/flowir/canonical-hash"; diff --git a/packages/taskflow-control/src/schema/plan.ts b/packages/taskflow-control/src/schema/plan.ts new file mode 100644 index 00000000..9f8b723c --- /dev/null +++ b/packages/taskflow-control/src/schema/plan.ts @@ -0,0 +1,155 @@ +/** + * BoundPlan / BoundFragment / SpawnTemplate wire types (🟥 NEW). + * + * Decisions: P1/P7 (immutable BoundPlan template, evidence-not-bearer), P7 + * (BoundFragment dual hashes + parent chain + authorityEpoch), P8 + * (enforcementCapabilities required — no holes), P6 (single hash family). + */ + +import { Type } from "typebox"; +import { CONTROL_WIRE_SCHEMA_VERSION, CanonicalHashRefSchema, UuidSchema } from "./common.ts"; +import { PathRefSchema, type PathRef } from "./te-mirrors.ts"; +import { EnforcementCapabilitiesSchema, type EnforcementCapabilities, PolicyBundleSchema, type PolicyBundle } from "./policy.ts"; + +// --------------------------------------------------------------------------- +// SpawnTemplate (P7 §7.4) — dynamic expansion ceilings +// --------------------------------------------------------------------------- + +export const SpawnTemplateSchema = Type.Object( + { + allowedAgentClasses: Type.Array(Type.String({ minLength: 1 })), + allowedProviderClasses: Type.Array(Type.String({ minLength: 1 })), + maxToolCallsPerStep: Type.Integer({ minimum: 0 }), + maxEffectsPerNode: Type.Integer({ minimum: 0 }), + maxChildren: Type.Integer({ minimum: 1 }), + maxDepth: Type.Integer({ minimum: 1 }), + budgetShare: Type.Number({ minimum: 0, maximum: 1 }), + }, + { additionalProperties: false }, +); +export type SpawnTemplate = { + allowedAgentClasses: string[]; + allowedProviderClasses: string[]; + maxToolCallsPerStep: number; + maxEffectsPerNode: number; + maxChildren: number; + maxDepth: number; + budgetShare: number; +}; + +// --------------------------------------------------------------------------- +// Plan bindings / saved-flow pins / grants / claims +// --------------------------------------------------------------------------- + +export const PlanBindingSchema = Type.Object( + { + name: Type.String({ minLength: 1 }), + path: PathRefSchema, + }, + { additionalProperties: false }, +); +export type PlanBinding = { name: string; path: PathRef }; + +export const SavedFlowPinSchema = Type.Object( + { + flowId: Type.String({ minLength: 1 }), + irHash: CanonicalHashRefSchema, + boundPlanHash: CanonicalHashRefSchema, + }, + { additionalProperties: false }, +); +export type SavedFlowPin = { flowId: string; irHash: string; boundPlanHash: string }; + +export const GrantRefSchema = Type.Object( + { + grantId: Type.String({ minLength: 1 }), + bindingId: Type.Optional(Type.String({ minLength: 1 })), + }, + { additionalProperties: false }, +); +export type GrantRef = { grantId: string; bindingId?: string }; + +export const ClaimSchema = Type.Object( + { + name: Type.String({ minLength: 1 }), + value: Type.Union([Type.String(), Type.Boolean(), Type.Number()]), + }, + { additionalProperties: false }, +); +export type Claim = { name: string; value: string | boolean | number }; + +// --------------------------------------------------------------------------- +// BoundPlan (P1/P7/P8) — immutable template; evidence-not-bearer +// --------------------------------------------------------------------------- + +export const BoundPlanSchema = Type.Object( + { + schemaVersion: Type.Literal(CONTROL_WIRE_SCHEMA_VERSION), + projectId: UuidSchema, + controlDomainId: UuidSchema, + planId: UuidSchema, + bindings: Type.Array(PlanBindingSchema), + spawnTemplate: SpawnTemplateSchema, + savedFlowPins: Type.Array(SavedFlowPinSchema), + grantRefs: Type.Array(GrantRefSchema), + claims: Type.Array(ClaimSchema), + enforcementCapabilities: EnforcementCapabilitiesSchema, + dynamicPolicy: PolicyBundleSchema, + boundPlanHash: CanonicalHashRefSchema, + }, + { additionalProperties: false }, +); +export type BoundPlan = { + schemaVersion: typeof CONTROL_WIRE_SCHEMA_VERSION; + projectId: string; + controlDomainId: string; + planId: string; + bindings: PlanBinding[]; + spawnTemplate: SpawnTemplate; + savedFlowPins: SavedFlowPin[]; + grantRefs: GrantRef[]; + claims: Claim[]; + enforcementCapabilities: EnforcementCapabilities; + dynamicPolicy: PolicyBundle; + boundPlanHash: string; +}; + +// --------------------------------------------------------------------------- +// BoundFragment (P7 §7.2) — dynamic IR product under attenuated authority +// --------------------------------------------------------------------------- + +export const BoundFragmentSchema = Type.Object( + { + schemaVersion: Type.Literal(CONTROL_WIRE_SCHEMA_VERSION), + projectId: UuidSchema, + controlDomainId: UuidSchema, + fragmentId: UuidSchema, + parentBoundPlanHash: CanonicalHashRefSchema, + parentBoundFragmentHash: Type.Optional(CanonicalHashRefSchema), + sourceEventId: UuidSchema, + sourceCommitSeq: Type.Integer({ minimum: 1 }), + fragmentIRHash: CanonicalHashRefSchema, + fragmentPolicyHash: CanonicalHashRefSchema, + capabilitySetHash: CanonicalHashRefSchema, + authorityEpoch: Type.Integer({ minimum: 0 }), + boundFragmentHash: CanonicalHashRefSchema, + executionSemanticHash: CanonicalHashRefSchema, + }, + { additionalProperties: false }, +); +export type BoundFragment = { + schemaVersion: typeof CONTROL_WIRE_SCHEMA_VERSION; + projectId: string; + controlDomainId: string; + fragmentId: string; + parentBoundPlanHash: string; + parentBoundFragmentHash?: string; + sourceEventId: string; + sourceCommitSeq: number; + fragmentIRHash: string; + fragmentPolicyHash: string; + capabilitySetHash: string; + authorityEpoch: number; + boundFragmentHash: string; + executionSemanticHash: string; +}; diff --git a/packages/taskflow-control/src/schema/policy.ts b/packages/taskflow-control/src/schema/policy.ts new file mode 100644 index 00000000..1811f068 --- /dev/null +++ b/packages/taskflow-control/src/schema/policy.ts @@ -0,0 +1,115 @@ +/** + * Policy + enforcement capability wire types (🟥 NEW). + * + * Decisions: P1/P2 (PolicyBundle with explicit inheritance + fail-closed empty + * baseline; authorizationContextHash records the decision), P8 (orthogonal + * EnforcementCapabilities bound to TE evidence surfaces; `unsupported` host + * probe fails closed; 0.3-C does not offer unbound / per-mutation). + */ + +import { Type } from "typebox"; +import { StringEnum } from "taskflow-core/typebox-helpers"; +import { Sha256HexSchema } from "./common.ts"; + +// --------------------------------------------------------------------------- +// PolicyBundle (P1/P2) +// --------------------------------------------------------------------------- + +export const PolicyCeilingSchema = Type.Object( + { + maxActiveRuns: Type.Optional(Type.Integer({ minimum: 1 })), + maxTokens: Type.Optional(Type.Integer({ minimum: 1 })), + maxUSD: Type.Optional(Type.Number({ minimum: 0 })), + }, + { additionalProperties: false }, +); +export type PolicyCeiling = { maxActiveRuns?: number; maxTokens?: number; maxUSD?: number }; + +export const PolicyBundleSchema = Type.Object( + { + hostCeiling: PolicyCeilingSchema, + userCeiling: Type.Optional(PolicyCeilingSchema), + projectCeiling: Type.Optional(PolicyCeilingSchema), + invocationCeiling: Type.Optional(PolicyCeilingSchema), + authorizationContextHash: Sha256HexSchema, + }, + { additionalProperties: false }, +); +export type PolicyBundle = { + hostCeiling: PolicyCeiling; + userCeiling?: PolicyCeiling; + projectCeiling?: PolicyCeiling; + invocationCeiling?: PolicyCeiling; + authorizationContextHash: string; +}; + +// --------------------------------------------------------------------------- +// EnforcementCapabilities (P8) — four orthogonal dimensions + evidence +// --------------------------------------------------------------------------- + +export const ResolutionCapabilitySchema = StringEnum(["contained", "unbound"]); +export type ResolutionCapability = "contained" | "unbound"; + +export const MutationMediationCapabilitySchema = StringEnum(["none", "brokered"]); +export type MutationMediationCapability = "none" | "brokered"; + +export const ProcessIsolationCapabilitySchema = StringEnum(["none", "sandboxed"]); +export type ProcessIsolationCapability = "none" | "sandboxed"; + +export const RevocationCapabilitySchema = Type.Union([ + Type.Literal("admission-only"), + Type.Literal("per-mutation"), + Type.Object( + { + mode: Type.Literal("bounded-latency"), + maxLatencyMs: Type.Integer({ minimum: 1 }), + }, + { additionalProperties: false }, + ), +]); +export type RevocationCapability = + | "admission-only" + | "per-mutation" + | { mode: "bounded-latency"; maxLatencyMs: number }; + +/** + * P8 default capability package (wire-frozen values): resolution contained, + * mutationMediation brokered, revocation admission-only, processIsolation + * host-probe-derived (no baseline evidence ⇒ resolve-only ⇒ `none`). + */ +export const DEFAULT_ENFORCEMENT_CAPABILITIES = { + resolution: "contained", + mutationMediation: "brokered", + processIsolation: "none", + revocation: "admission-only", +} as const; + +export const EnforcementCapabilitiesSchema = Type.Object( + { + resolution: ResolutionCapabilitySchema, + mutationMediation: MutationMediationCapabilitySchema, + processIsolation: ProcessIsolationCapabilitySchema, + revocation: RevocationCapabilitySchema, + // Evidence provenance (P8): baseline policy + host probe digest. + baselinePolicyId: Type.String({ minLength: 1 }), + hostProbeSha256: Sha256HexSchema, + }, + { additionalProperties: false }, +); +export type EnforcementCapabilities = { + resolution: ResolutionCapability; + mutationMediation: MutationMediationCapability; + processIsolation: ProcessIsolationCapability; + revocation: RevocationCapability; + baselinePolicyId: string; + hostProbeSha256: string; +}; + +/** A flow requesting a capability 0.3-C does not offer → TF_FEATURE_REQUIRED. */ +export function capabilityDemandsUnavailable(capabilities: Pick): boolean { + if (capabilities.resolution === "unbound") return true; + if (capabilities.mutationMediation === "none") return true; + if (capabilities.processIsolation === "sandboxed") return true; // no approved host baseline in 0.3-C + if (capabilities.revocation !== "admission-only") return true; + return false; +} diff --git a/packages/taskflow-control/src/schema/run.ts b/packages/taskflow-control/src/schema/run.ts new file mode 100644 index 00000000..645154e5 --- /dev/null +++ b/packages/taskflow-control/src/schema/run.ts @@ -0,0 +1,78 @@ +/** + * Run lifecycle wire types (🟥 NEW). + * + * Decisions: P5 — RunStatus and RunStage are independent closed enums; only + * `completed | failed | blocked | cancelled` are terminal; `unknown` is + * non-terminal (D33); RunSnapshot carries status + stage + slot + operator flag. + */ + +import { Type } from "typebox"; +import { StringEnum } from "taskflow-core/typebox-helpers"; +import { UuidSchema } from "./common.ts"; + +export const RunStatusSchema = StringEnum([ + "running", + "completed", + "failed", + "paused", + "blocked", + "cancelled", + "unknown", +]); +export type RunStatus = "running" | "completed" | "failed" | "paused" | "blocked" | "cancelled" | "unknown"; + +export const TERMINAL_RUN_STATUSES: readonly RunStatus[] = ["completed", "failed", "blocked", "cancelled"]; + +export function isTerminalRunStatus(status: RunStatus): boolean { + return (TERMINAL_RUN_STATUSES as readonly string[]).includes(status); +} + +export const RunStageSchema = StringEnum([ + "received", + "compiled", + "linked", + "queued", + "admitted", + "executing", + "parked", + "reconciling", + "terminal", +]); +export type RunStage = + | "received" + | "compiled" + | "linked" + | "queued" + | "admitted" + | "executing" + | "parked" + | "reconciling" + | "terminal"; + +/** Slot state per P16 — how the reservation contributes to maxActiveRuns. */ +export const RunSlotStateSchema = StringEnum(["none", "reserved", "committed", "orphan-suspect", "released"]); +export type RunSlotState = "none" | "reserved" | "committed" | "orphan-suspect" | "released"; + +export const RunSnapshotSchema = Type.Object( + { + runId: UuidSchema, + projectId: UuidSchema, + controlDomainId: UuidSchema, + status: RunStatusSchema, + stage: RunStageSchema, + slot: RunSlotStateSchema, + needsOperator: Type.Boolean(), + projectAdmitCommitSeq: Type.Optional(Type.Integer({ minimum: 1 })), + }, + { additionalProperties: false }, +); +export type RunSnapshot = { + runId: string; + projectId: string; + controlDomainId: string; + status: RunStatus; + stage: RunStage; + slot: RunSlotState; + needsOperator: boolean; + projectAdmitCommitSeq?: number; +}; diff --git a/packages/taskflow-control/src/schema/te-mirrors.ts b/packages/taskflow-control/src/schema/te-mirrors.ts new file mode 100644 index 00000000..58b8b9e8 --- /dev/null +++ b/packages/taskflow-control/src/schema/te-mirrors.ts @@ -0,0 +1,236 @@ +/** + * TE wire mirrors (🟩 REUSE per wire-freeze §3.1). + * + * The wire-freeze reuses eleven Trusted Effects shapes. Most of them are + * importable from `taskflow-core`'s package surface (`effects/*`, `flowir/*`); + * the `resources/*` shapes are deliberately NOT exported by taskflow-core + * (the workspace-capability freeze blocks `taskflow-core/resources/*` — see + * scripts/smoke-packed-packages.mjs), so this module carries closed TypeBox + * mirrors of exactly those shapes. TE remains the authority that produces and + * consumes these values; 0.3-C only references them as evidence. + * + * Mirrors are kept field-for-field identical to the TE interfaces; any drift + * fails the closed-contract tests in this package. + */ + +import { Type, type Static } from "typebox"; +import { StringEnum } from "taskflow-core/typebox-helpers"; + +// --------------------------------------------------------------------------- +// PathRef (resources/schema.ts) — literalPath/argPath/segments + PathIntent +// --------------------------------------------------------------------------- + +export const WorkspaceAccessSchema = Type.Union([ + Type.Literal("read-only"), + Type.Literal("read-write"), +]); +export type WorkspaceAccess = "read-only" | "read-write"; + +export const PathIntentSchema = Type.Union([ + Type.Literal("existing-file"), + Type.Literal("existing-directory"), + Type.Literal("create-file"), + Type.Literal("create-directory"), + Type.Literal("executable"), +]); +export type PathIntent = + | "existing-file" + | "existing-directory" + | "create-file" + | "create-directory" + | "executable"; + +const LiteralPathExprSchema = Type.Object( + { + literalPath: Type.String({ minLength: 1 }), + argPath: Type.Optional(Type.Never()), + segments: Type.Optional(Type.Never()), + }, + { additionalProperties: false }, +); + +const ArgPathExprSchema = Type.Object( + { + argPath: Type.String({ minLength: 1 }), + literalPath: Type.Optional(Type.Never()), + segments: Type.Optional(Type.Never()), + }, + { additionalProperties: false }, +); + +const SegmentExprSchema = Type.Union([ + Type.Object( + { segment: Type.String({ minLength: 1 }), argSegment: Type.Optional(Type.Never()) }, + { additionalProperties: false }, + ), + Type.Object( + { argSegment: Type.String({ minLength: 1 }), segment: Type.Optional(Type.Never()) }, + { additionalProperties: false }, + ), +]); + +const SegmentsExprSchema = Type.Object( + { + segments: Type.Array(SegmentExprSchema, { minItems: 1 }), + literalPath: Type.Optional(Type.Never()), + argPath: Type.Optional(Type.Never()), + }, + { additionalProperties: false }, +); + +const RelativePathExprSchema = Type.Union([LiteralPathExprSchema, ArgPathExprSchema, SegmentsExprSchema]); + +const pathRefBase = { + subpath: Type.Optional(RelativePathExprSchema), + access: Type.Optional(WorkspaceAccessSchema), + maxLifetime: Type.Optional( + Type.Union( + ["phase", "run", "external"].map((scope) => + Type.Object({ scope: Type.Literal(scope as "phase" | "run" | "external") }, { additionalProperties: false }), + ), + ), + ), + intent: PathIntentSchema, +}; + +export const PathRefSchema = Type.Union([ + Type.Object( + { ...pathRefBase, workspace: Type.String({ minLength: 1 }), handle: Type.Optional(Type.Never()) }, + { additionalProperties: false }, + ), + Type.Object( + { + ...pathRefBase, + handle: Type.Object( + { + producerPhaseId: Type.String({ minLength: 1 }), + exportName: Type.String({ minLength: 1 }), + }, + { additionalProperties: false }, + ), + workspace: Type.Optional(Type.Never()), + }, + { additionalProperties: false }, + ), +]); +export type PathRef = Static; + +// --------------------------------------------------------------------------- +// BoundCapabilityLifetime (resources/schema.ts) +// --------------------------------------------------------------------------- + +export const BoundCapabilityLifetimeSchema = Type.Union([ + Type.Object( + { + scope: Type.Literal("phase"), + runId: Type.String({ minLength: 1 }), + phaseId: Type.String({ minLength: 1 }), + attemptId: Type.String({ minLength: 1 }), + }, + { additionalProperties: false }, + ), + Type.Object( + { scope: Type.Literal("run"), runId: Type.String({ minLength: 1 }) }, + { additionalProperties: false }, + ), + Type.Object( + { + scope: Type.Literal("external"), + bindingId: Type.String({ minLength: 1 }), + providerInstanceId: Type.Optional(Type.String({ minLength: 1 })), + }, + { additionalProperties: false }, + ), +]); +export type BoundCapabilityLifetime = Static; + +// --------------------------------------------------------------------------- +// ExecutionOwner (resources/types.ts) +// --------------------------------------------------------------------------- + +export const ExecutionOwnerSchema = Type.Object( + { + runId: Type.String({ minLength: 1 }), + phaseId: Type.String({ minLength: 1 }), + attemptId: Type.String({ minLength: 1 }), + unitId: Type.String({ minLength: 1 }), + ancestry: Type.Array(Type.String()), + }, + { additionalProperties: false }, +); +export type ExecutionOwner = Static; + +// --------------------------------------------------------------------------- +// ScopedContentEvidence (resources/types.ts) +// --------------------------------------------------------------------------- + +export const ScopedContentEvidenceSchema = Type.Object( + { + canonicalPrefix: Type.String({ minLength: 1 }), + scopeDigest: Type.String({ minLength: 1 }), + effectId: Type.Optional(Type.String({ minLength: 1 })), + capabilityBindingId: Type.Optional(Type.String({ minLength: 1 })), + beforeContentId: Type.Optional(Type.String({ minLength: 1 })), + afterContentId: Type.Optional(Type.String({ minLength: 1 })), + }, + { additionalProperties: false }, +); + +// --------------------------------------------------------------------------- +// WriteIntentRecord / WriteIntentStatus (resources/journal.ts) +// --------------------------------------------------------------------------- + +export const WriteIntentStatusSchema = StringEnum([ + "pending", + "committed-content", + "committed-generation", + "aborted-restored", + "dirty-unknown", + "reconciled", +]); +export type WriteIntentStatus = + | "pending" + | "committed-content" + | "committed-generation" + | "aborted-restored" + | "dirty-unknown" + | "reconciled"; + +export const WriteIntentRecordSchema = Type.Object( + { + journalVersion: Type.Literal(1), + intentId: Type.String({ minLength: 1 }), + resourceDomainId: Type.String({ minLength: 1 }), + providerInstanceId: Type.Optional(Type.String({ minLength: 1 })), + scopes: Type.Array(ScopedContentEvidenceSchema), + owner: ExecutionOwnerSchema, + beforeGeneration: Type.Integer({ minimum: 0 }), + intentSequence: Type.Integer({ minimum: 1 }), + commitGeneration: Type.Optional(Type.Integer({ minimum: 0 })), + journalEpoch: Type.Integer({ minimum: 1 }), + commitMode: StringEnum(["content-snapshot", "generation-only", "unavailable"]), + externalMutation: StringEnum(["taskflow-managed", "externally-mutable"]), + status: WriteIntentStatusSchema, + restorableSnapshotArtifactIds: Type.Optional(Type.Array(Type.String({ minLength: 1 }))), + terminalReason: Type.Optional(Type.String()), + authorizationPrincipalId: Type.Optional(Type.String({ minLength: 1 })), + authorizationScopeRoot: Type.Optional(Type.String({ minLength: 1 })), + }, + { additionalProperties: false }, +); + +// --------------------------------------------------------------------------- +// HostProbeClassification (resources/baseline.ts) — enforcement evidence +// --------------------------------------------------------------------------- + +export const HostProbeClassificationSchema = StringEnum([ + "sandboxed-single-root", + "sandboxed-multi-root", + "resolve-only", + "unsupported", +]); +export type HostProbeClassification = + | "sandboxed-single-root" + | "sandboxed-multi-root" + | "resolve-only" + | "unsupported"; diff --git a/packages/taskflow-control/src/schema/transport.ts b/packages/taskflow-control/src/schema/transport.ts new file mode 100644 index 00000000..94e3197d --- /dev/null +++ b/packages/taskflow-control/src/schema/transport.ts @@ -0,0 +1,337 @@ +/** + * Transport wire types (🟥 NEW): negotiation handshake, unified error + * envelope + the closed TF_* code set (P4), and the ExecutionProvider DTO + * group (RFC §16) as discriminated accepted|rejected|ambiguous unions. + */ + +import { Type } from "typebox"; +import { StringEnum } from "taskflow-core/typebox-helpers"; +import { UuidSchema } from "./common.ts"; +import { EnforcementCapabilitiesSchema, type EnforcementCapabilities } from "./policy.ts"; +import { ExecutionOwnerSchema, type ExecutionOwner } from "./te-mirrors.ts"; +import { BoundPlanSchema, type BoundPlan } from "./plan.ts"; + +// --------------------------------------------------------------------------- +// NegotiationHandshake (P4 / RFC §18) +// --------------------------------------------------------------------------- + +export const PROTOCOL_MAJOR = 1; + +export const NegotiationHandshakeSchema = Type.Object( + { + protocolMajor: Type.Literal(PROTOCOL_MAJOR), + supportedReadSchemas: Type.Array(Type.String({ minLength: 1 })), + supportedWriteSchemas: Type.Array(Type.String({ minLength: 1 })), + requiredFeatures: Type.Array(Type.String({ minLength: 1 })), + offeredFeatures: Type.Array(Type.String({ minLength: 1 })), + buildInfo: Type.Object( + { + packageVersion: Type.String({ minLength: 1 }), + gitCommit: Type.String({ minLength: 1 }), + schemaVersion: Type.Integer({ minimum: 0 }), + buildTime: Type.Optional(Type.Integer({ minimum: 0 })), + }, + { additionalProperties: false }, + ), + }, + { additionalProperties: false }, +); +export type NegotiationHandshake = { + protocolMajor: typeof PROTOCOL_MAJOR; + supportedReadSchemas: string[]; + supportedWriteSchemas: string[]; + requiredFeatures: string[]; + offeredFeatures: string[]; + buildInfo: { + packageVersion: string; + gitCommit: string; + schemaVersion: number; + buildTime?: number; + }; +}; + +// --------------------------------------------------------------------------- +// ErrorEnvelope + closed TF_* code set (P4) +// --------------------------------------------------------------------------- + +export const CONTROL_ERROR_CODES = [ + "TF_PROTOCOL_INCOMPATIBLE", + "TF_SCHEMA_UNSUPPORTED", + "TF_FEATURE_REQUIRED", + "TF_POLICY_DENIED", + "TF_AUTHORITY_REVOKED", + "TF_STALE_VERSION", + "TF_IDEMPOTENCY_CONFLICT", + "TF_CROSS_PRINCIPAL_COMMAND", + "TF_LEGACY_CONFLICT", + "TF_PROVIDER_AMBIGUOUS", + "TF_JOURNAL_UNAVAILABLE", + "TF_DURABILITY_FAILED", + "TF_CURSOR_EXPIRED", + "TF_COMMAND_FAILED", + "TF_BOOTSTRAP_FAILED", + "TF_RECONCILE_REQUIRED", + "TF_ADMISSION_BINDING_CONFLICT", + "TF_CAPACITY_EXCEEDED", +] as const; +export type ControlErrorCode = (typeof CONTROL_ERROR_CODES)[number]; + +export const RecoveryActionSchema = StringEnum([ + "retry-same-command", + "retry-new-command", + "refresh", + "reconcile", + "operator", + "none", +]); +export type RecoveryAction = "retry-same-command" | "retry-new-command" | "refresh" | "reconcile" | "operator" | "none"; + +export const SideEffectsSchema = StringEnum(["none", "possible", "unknown"]); +export type SideEffects = "none" | "possible" | "unknown"; + +export const ErrorEnvelopeSchema = Type.Object( + { + code: StringEnum(CONTROL_ERROR_CODES), + message: Type.String({ minLength: 1 }), + recoveryAction: RecoveryActionSchema, + sideEffects: SideEffectsSchema, + commandId: Type.Optional(UuidSchema), + commitSeq: Type.Optional(Type.Integer({ minimum: 1 })), + controlDomainId: Type.Optional(UuidSchema), + projectId: Type.Optional(UuidSchema), + }, + { additionalProperties: false }, +); +export type ErrorEnvelope = { + code: ControlErrorCode; + message: string; + recoveryAction: RecoveryAction; + sideEffects: SideEffects; + commandId?: string; + commitSeq?: number; + controlDomainId?: string; + projectId?: string; +}; + +// --------------------------------------------------------------------------- +// ExecutionProvider DTO group (RFC §16) — 0.3-C's only provider is TE +// resources, but the wire keeps the async provider contract. +// --------------------------------------------------------------------------- + +export const ProviderRequestBaseSchema = Type.Object( + { + runId: UuidSchema, + owner: ExecutionOwnerSchema, + controlDomainId: UuidSchema, + }, + { additionalProperties: false }, +); + +export const ProviderAcceptedSchema = (fields: T) => + Type.Object({ ...fields, outcome: Type.Literal("accepted") }, { additionalProperties: false }); + +export const ProviderRejectedSchema = Type.Object( + { + outcome: Type.Literal("rejected"), + error: Type.Object( + { + code: StringEnum(CONTROL_ERROR_CODES), + message: Type.String({ minLength: 1 }), + recoveryAction: RecoveryActionSchema, + sideEffects: SideEffectsSchema, + }, + { additionalProperties: false }, + ), + }, + { additionalProperties: false }, +); + +export const ProviderAmbiguousSchema = Type.Object( + { + outcome: Type.Literal("ambiguous"), + message: Type.Optional(Type.String()), + }, + { additionalProperties: false }, +); + +export const ProviderCapabilitiesSchema = Type.Object( + { + processIsolation: StringEnum(["none", "sandboxed"]), + resolution: Type.Literal("contained"), + mutationMediation: Type.Literal("brokered"), + revocation: Type.Literal("admission-only"), + baselinePolicyId: Type.String({ minLength: 1 }), + hostProbeSha256: Type.String({ minLength: 1 }), + }, + { additionalProperties: false }, +); +export type ProviderCapabilities = { + processIsolation: "none" | "sandboxed"; + resolution: "contained"; + mutationMediation: "brokered"; + revocation: "admission-only"; + baselinePolicyId: string; + hostProbeSha256: string; +}; + +export const ProbeResultSchema = Type.Object( + { + outcome: Type.Literal("accepted"), + capabilities: ProviderCapabilitiesSchema, + }, + { additionalProperties: false }, +); +export type ProbeResult = { outcome: "accepted"; capabilities: ProviderCapabilities }; + +export const PrepareRequestSchema = Type.Object( + { + plan: BoundPlanSchema, + owner: ExecutionOwnerSchema, + controlDomainId: UuidSchema, + }, + { additionalProperties: false }, +); +export type PrepareRequest = { plan: BoundPlan; owner: ExecutionOwner; controlDomainId: string }; + +export const FulfillmentPlanSchema = Type.Object( + { + preparationId: UuidSchema, + enforcementCapabilities: EnforcementCapabilitiesSchema, + }, + { additionalProperties: false }, +); +export type FulfillmentPlan = { preparationId: string; enforcementCapabilities: EnforcementCapabilities }; + +export const PrepareResultSchema = Type.Union([ + Type.Object( + { outcome: Type.Literal("accepted"), fulfillment: FulfillmentPlanSchema }, + { additionalProperties: false }, + ), + ProviderRejectedSchema, + ProviderAmbiguousSchema, +]); +export type PrepareResult = + | { outcome: "accepted"; fulfillment: FulfillmentPlan } + | { outcome: "rejected"; error: ErrorEnvelope } + | { outcome: "ambiguous"; message?: string }; + +export const SubmitRequestSchema = Type.Object( + { + preparationId: UuidSchema, + owner: ExecutionOwnerSchema, + controlDomainId: UuidSchema, + }, + { additionalProperties: false }, +); +export type SubmitRequest = { preparationId: string; owner: ExecutionOwner; controlDomainId: string }; + +export const SubmitResultSchema = Type.Union([ + Type.Object( + { outcome: Type.Literal("accepted"), providerJobHandle: Type.String({ minLength: 1 }) }, + { additionalProperties: false }, + ), + ProviderRejectedSchema, + ProviderAmbiguousSchema, +]); +export type SubmitResult = + | { outcome: "accepted"; providerJobHandle: string } + | { outcome: "rejected"; error: ErrorEnvelope } + | { outcome: "ambiguous"; message?: string }; + +export const ProviderEventSchema = Type.Union([ + Type.Object( + { kind: Type.Literal("progress"), message: Type.String() }, + { additionalProperties: false }, + ), + Type.Object( + { kind: Type.Literal("heartbeat"), at: Type.Integer({ minimum: 0 }) }, + { additionalProperties: false }, + ), + Type.Object( + { + kind: Type.Literal("terminal"), + outcome: StringEnum(["completed", "failed", "ambiguous"]), + }, + { additionalProperties: false }, + ), +]); +export type ProviderEvent = + | { kind: "progress"; message: string } + | { kind: "heartbeat"; at: number } + | { kind: "terminal"; outcome: "completed" | "failed" | "ambiguous" }; + +export const PollResultSchema = Type.Union([ + Type.Object( + { + outcome: Type.Literal("accepted"), + status: StringEnum(["running", "completed", "failed", "ambiguous"]), + }, + { additionalProperties: false }, + ), + ProviderRejectedSchema, + ProviderAmbiguousSchema, +]); +export type PollResult = + | { outcome: "accepted"; status: "running" | "completed" | "failed" | "ambiguous" } + | { outcome: "rejected"; error: ErrorEnvelope } + | { outcome: "ambiguous"; message?: string }; + +export const CancelResultSchema = Type.Union([ + Type.Object( + { + outcome: Type.Literal("accepted"), + cancelled: Type.Boolean(), + }, + { additionalProperties: false }, + ), + ProviderRejectedSchema, + ProviderAmbiguousSchema, +]); +export type CancelResult = + | { outcome: "accepted"; cancelled: boolean } + | { outcome: "rejected"; error: ErrorEnvelope } + | { outcome: "ambiguous"; message?: string }; + +export const ReconcileResultSchema = Type.Union([ + Type.Object( + { + outcome: Type.Literal("accepted"), + providerState: StringEnum(["running", "terminal", "exhausted"]), + }, + { additionalProperties: false }, + ), + ProviderRejectedSchema, + ProviderAmbiguousSchema, +]); +export type ReconcileResult = + | { outcome: "accepted"; providerState: "running" | "terminal" | "exhausted" } + | { outcome: "rejected"; error: ErrorEnvelope } + | { outcome: "ambiguous"; message?: string }; + +export const CollectResultSchema = Type.Union([ + Type.Object( + { + outcome: Type.Literal("accepted"), + providerJobHandle: Type.String({ minLength: 1 }), + }, + { additionalProperties: false }, + ), + ProviderRejectedSchema, + ProviderAmbiguousSchema, +]); +export type CollectResult = + | { outcome: "accepted"; providerJobHandle: string } + | { outcome: "rejected"; error: ErrorEnvelope } + | { outcome: "ambiguous"; message?: string }; + +/** TE capabilities are the only 0.3-C capabilities (P8 default package). */ +export function capabilitiesFromEnforcement(capabilities: ProviderCapabilities): EnforcementCapabilities { + return { + resolution: capabilities.resolution, + mutationMediation: capabilities.mutationMediation, + processIsolation: capabilities.processIsolation, + revocation: capabilities.revocation, + baselinePolicyId: capabilities.baselinePolicyId, + hostProbeSha256: capabilities.hostProbeSha256, + }; +} diff --git a/packages/taskflow-control/src/singleton.ts b/packages/taskflow-control/src/singleton.ts new file mode 100644 index 00000000..25d113b8 --- /dev/null +++ b/packages/taskflow-control/src/singleton.ts @@ -0,0 +1,662 @@ +/** + * User singleton lock + coordination endpoint + fencing (P13 / RFC §5.1 D32). + * + * Contract implemented here (P13 "排他锁语义" + fresh-install §5.2): + * - Owner publishes via hard-link create — never rename-overwrite. + * - Malformed singleton metadata fails closed. + * - Dead-PID takeover stays fail-closed: reclaim only when the owner is + * provably dead (liveness probe) AND the record's birth token still matches + * (PID reuse cannot inherit a live holder's lock). + * - Collaborative competitors use identity-bound reclaim: fixed claim file + + * O_EXCL + generation check + rename-to-discard + recoverable claim cleanup. + * - Release = compare-and-delete on acquire-time device/inode + owner token + * (rename-to-discard). + * - Stale endpoint recovery: dead peer ⇒ remove socket file ⇒ restart. + * - Fencing: every acquire/reclaim bumps the CoordinatorLease epoch; RPCs + * carrying an older epoch are rejected (TF_AUTHORITY_REVOKED). + * + * TE's `resources/*` persistence helpers are deliberately not exported by + * taskflow-core, so this module re-implements the small POSIX primitives + * (atomic write, process birth token) with the same fail-closed discipline. + */ + +import * as crypto from "node:crypto"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { Value } from "typebox/value"; +import { bootstrapFailed, ControlError } from "./errors.ts"; +import { CoordinatorLeaseSchema, type CoordinatorLease } from "./schema/coordinator.ts"; + +// --------------------------------------------------------------------------- +// Layout (P13): user `~/.taskflow/control/` (TASKFLOW_HOME overridable) +// --------------------------------------------------------------------------- + +export const TASKFLOW_HOME_ENV = "TASKFLOW_HOME"; +export const DEFAULT_CONTROL_DIR_NAME = "control"; + +export function userTaskflowHome(homeOverride?: string): string { + return homeOverride ?? process.env[TASKFLOW_HOME_ENV] ?? path.join(os.homedir(), ".taskflow"); +} + +export function userControlHome(homeOverride?: string): string { + return path.join(userTaskflowHome(homeOverride), DEFAULT_CONTROL_DIR_NAME); +} + +export interface SingletonPaths { + controlHome: string; + /** Hard-link-created owner record (never rename-overwrite). */ + lockPath: string; + /** Coordination endpoint (Unix UDS path / pipe name). */ + endpointPath: string; + /** Renewable CoordinatorLease record (P16 wire type). */ + leasePath: string; +} + +export function singletonPaths(controlHome?: string): SingletonPaths { + const home = controlHome ?? userControlHome(); + return { + controlHome: home, + lockPath: path.join(home, "singleton.lock.json"), + endpointPath: path.join(home, "taskflow.sock"), + leasePath: path.join(home, "coordinator-lease.json"), + }; +} + +// --------------------------------------------------------------------------- +// Process identity + liveness (fail-closed mirrors of TE persistence helpers) +// --------------------------------------------------------------------------- + +export type BirthTokenKind = "native" | "opaque"; + +export interface ProcessIdentityLike { + pid: number; + birthToken: string; + birthTokenKind: BirthTokenKind; +} + +export interface ObservedProcessLike { + alive: boolean; + birthToken?: string; + birthTokenKind?: BirthTokenKind; +} + +/** Exact, platform-native process-birth identity; never an uptime estimate. */ +export function readProcessBirthToken(pid: number): string | undefined { + if (!Number.isSafeInteger(pid) || pid < 1) return undefined; + try { + if (process.platform === "linux") { + const bootId = fs.readFileSync("/proc/sys/kernel/random/boot_id", "utf8").trim(); + const stat = fs.readFileSync(`/proc/${pid}/stat`, "utf8").trim(); + const commandEnd = stat.lastIndexOf(")"); + if (!bootId || commandEnd < 0) return undefined; + const startTicks = stat.slice(commandEnd + 1).trim().split(/\s+/)[19]; + if (!/^\d+$/.test(startTicks ?? "")) return undefined; + return `linux:${bootId}:${startTicks}`; + } + return undefined; // macOS/others: fail closed (never reclaim a possibly-live owner) + } catch { + return undefined; + } +} + +export function defaultProcessIdentity(): ProcessIdentityLike { + const native = readProcessBirthToken(process.pid); + return { + pid: process.pid, + birthToken: native ?? `opaque:${crypto.randomUUID()}`, + birthTokenKind: native === undefined ? "opaque" : "native", + }; +} + +export function defaultProcessInspector(pid: number): ObservedProcessLike { + if (pid === process.pid) { + const identity = defaultProcessIdentity(); + return { alive: true, birthToken: identity.birthToken, birthTokenKind: identity.birthTokenKind }; + } + try { + process.kill(pid, 0); + const birthToken = readProcessBirthToken(pid); + return birthToken === undefined + ? { alive: true } + : { alive: true, birthToken, birthTokenKind: "native" }; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "EPERM") return { alive: false }; + const birthToken = readProcessBirthToken(pid); + return birthToken === undefined + ? { alive: true } + : { alive: true, birthToken, birthTokenKind: "native" }; + } +} + +// --------------------------------------------------------------------------- +// Singleton lock record (immutable; published by hard-link create) +// --------------------------------------------------------------------------- + +export const SINGLETON_LOCK_VERSION = 1; + +export interface SingletonLockRecord { + version: typeof SINGLETON_LOCK_VERSION; + holderId: string; + pid: number; + birthToken: string; + birthTokenKind: BirthTokenKind; + fencingEpoch: number; + endpoint: string; + acquiredAt: number; +} + +export interface ClaimRecord { + version: 1; + claimantId: string; + pid: number; + birthToken: string; + birthTokenKind: BirthTokenKind; + createdAt: number; + /** Generation (acquiredAt) of the lock record the claimant observed. */ + targetGeneration: number; +} + +function validateLockRecord(value: unknown): SingletonLockRecord { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw bootstrapFailed("malformed singleton lock record: not an object"); + } + const record = value as Record; + if ( + record.version !== SINGLETON_LOCK_VERSION || + typeof record.holderId !== "string" || + !Number.isSafeInteger(record.pid) || + typeof record.birthToken !== "string" || + (record.birthTokenKind !== "native" && record.birthTokenKind !== "opaque") || + !Number.isSafeInteger(record.fencingEpoch) || + typeof record.endpoint !== "string" || + !Number.isSafeInteger(record.acquiredAt) + ) { + throw bootstrapFailed("malformed singleton lock record: missing or invalid fields"); + } + return record as unknown as SingletonLockRecord; +} + +function validateClaimRecord(value: unknown): ClaimRecord { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw bootstrapFailed("malformed singleton reclaim claim: not an object"); + } + const record = value as Record; + if ( + record.version !== 1 || + typeof record.claimantId !== "string" || + !Number.isSafeInteger(record.pid) || + typeof record.birthToken !== "string" || + (record.birthTokenKind !== "native" && record.birthTokenKind !== "opaque") || + !Number.isSafeInteger(record.createdAt) || + !Number.isSafeInteger(record.targetGeneration) + ) { + throw bootstrapFailed("malformed singleton reclaim claim: missing or invalid fields"); + } + return record as unknown as ClaimRecord; +} + +// --------------------------------------------------------------------------- +// Durability primitives (mirror of TE writeJsonAtomicDurable) +// --------------------------------------------------------------------------- + +function ensurePrivateDirectory(directory: string): void { + fs.mkdirSync(directory, { recursive: true, mode: 0o700 }); + if (process.platform !== "win32") { + const stat = fs.statSync(directory); + if ((stat.mode & 0o077) !== 0) fs.chmodSync(directory, 0o700); + } +} + +function fsyncDirectory(directory: string): void { + try { + const fd = fs.openSync(directory, "r"); + try { fs.fsyncSync(fd); } finally { fs.closeSync(fd); } + } catch { /* best effort */ } +} + +export function writeJsonAtomicDurable(filePath: string, value: unknown): void { + ensurePrivateDirectory(path.dirname(filePath)); + const temp = `${filePath}.tmp.${process.pid}.${crypto.randomBytes(6).toString("hex")}`; + const fd = fs.openSync(temp, "wx", 0o600); + try { + fs.writeFileSync(fd, JSON.stringify(value)); + fs.fsyncSync(fd); + } finally { + fs.closeSync(fd); + } + try { + fs.renameSync(temp, filePath); + fsyncDirectory(path.dirname(filePath)); + } catch (error) { + try { fs.unlinkSync(temp); } catch { /* best effort */ } + throw error; + } +} + +function readJsonOrNull(filePath: string): T | null { + try { + return JSON.parse(fs.readFileSync(filePath, "utf8")) as T; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + throw error; + } +} + +// --------------------------------------------------------------------------- +// Singleton acquire / attach / release / reclaim +// --------------------------------------------------------------------------- + +export interface SingletonOptions { + paths: SingletonPaths; + holderId?: string; + processIdentity?: ProcessIdentityLike; + inspectProcess?: (pid: number) => ObservedProcessLike; + now?: () => number; + /** Hard pass budget (P13: maxAttempts is a pass budget, not spins×K). */ + maxAttempts?: number; + /** Renewal TTL for the CoordinatorLease (default 30s; renew while held). */ + leaseTtlMs?: number; + /** + * coordinated mode (P13): attach to an existing winner only. No lock + * publication and no reclaim — a missing/dead control fails closed with + * TF_JOURNAL_UNAVAILABLE instead of being started. + */ + attachOnly?: boolean; +} + +export type SingletonAcquireResult = + | { + status: "won"; + holderId: string; + fencingEpoch: number; + endpoint: string; + /** Compare-and-delete release (rename-to-discard). */ + release: () => void; + } + | { + status: "attached"; + /** Winner's identity — this process attaches as a client (D32). */ + holderId: string; + fencingEpoch: number; + endpoint: string; + }; + +function ownerIsDead(record: SingletonLockRecord, identity: ProcessIdentityLike, inspect: (pid: number) => ObservedProcessLike): boolean { + return ownerIdentityIsDead(record.pid, record.birthToken, record.birthTokenKind, identity, inspect); +} + +function ownerIdentityIsDead( + pid: number, + birthToken: string, + birthTokenKind: BirthTokenKind, + identity: ProcessIdentityLike, + inspect: (pid: number) => ObservedProcessLike, +): boolean { + if (pid === identity.pid) { + // Opaque tokens identify one module instance, not an OS process. Only + // native kernel birth tokens are comparable across instances. + return birthTokenKind === "native" && identity.birthTokenKind === "native" && + birthToken !== identity.birthToken; + } + const observed = inspect(pid); + if (!observed.alive) return true; + // Alive but unidentifiable owners are never reclaimed (fail closed). + if (birthTokenKind !== "native" || observed.birthTokenKind !== "native" || observed.birthToken === undefined) return false; + return observed.birthToken !== birthToken; +} + +function publishLockByHardLink(lockPath: string, record: SingletonLockRecord): void { + ensurePrivateDirectory(path.dirname(lockPath)); + const temp = `${lockPath}.publish.${process.pid}.${crypto.randomBytes(6).toString("hex")}`; + const fd = fs.openSync(temp, "wx", 0o600); + try { + fs.writeFileSync(fd, JSON.stringify(record)); + fs.fsyncSync(fd); + } finally { + fs.closeSync(fd); + } + try { + // Hard-link create — never rename-overwrite (P13). + fs.linkSync(temp, lockPath); + fs.unlinkSync(temp); + fsyncDirectory(path.dirname(lockPath)); + } catch (error) { + try { fs.unlinkSync(temp); } catch { /* best effort */ } + throw error; + } +} + +function readLockRecord(paths: SingletonPaths): SingletonLockRecord | null { + try { + const value = readJsonOrNull(paths.lockPath); + return value === null ? null : validateLockRecord(value); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + throw error; + } +} + +function readLease(paths: SingletonPaths): CoordinatorLease | null { + try { + const value = readJsonOrNull(paths.leasePath); + if (value === null) return null; + return Value.Parse(CoordinatorLeaseSchema, value) as CoordinatorLease; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + throw error; + } +} + +function writeLease(paths: SingletonPaths, lease: CoordinatorLease): void { + writeJsonAtomicDurable(paths.leasePath, lease); +} + +function claimPath(paths: SingletonPaths): string { + return `${paths.lockPath}.claim`; +} + +function discardPath(paths: SingletonPaths, token: string): string { + return `${paths.lockPath}.discard.${token}`; +} + +/** True when the exact lock inode is still ours (compare-and-delete check). */ +function lockInodeMatches(paths: SingletonPaths, expected: { dev: bigint; ino: bigint }): boolean { + try { + const stat = fs.statSync(paths.lockPath, { bigint: true }); + return stat.dev === expected.dev && stat.ino === expected.ino; + } catch { + return false; + } +} + +export function acquireUserSingleton(options: SingletonOptions): SingletonAcquireResult { + const { paths } = options; + const identity = options.processIdentity ?? defaultProcessIdentity(); + const inspect = options.inspectProcess ?? defaultProcessInspector; + const now = options.now ?? Date.now; + const holderId = options.holderId ?? crypto.randomUUID(); + const endpoint = paths.endpointPath; + const maxAttempts = options.maxAttempts ?? 8; + const leaseTtlMs = options.leaseTtlMs ?? 30_000; + + ensurePrivateDirectory(paths.controlHome); + + // coordinated (attach-only): the external control must already be alive. + if (options.attachOnly === true) { + const existing = readLockRecord(paths); + if (existing === null || ownerIsDead(existing, identity, inspect)) { + throw new ControlError( + "TF_JOURNAL_UNAVAILABLE", + "coordinated mode requires an external control which is down; failing closed (P13)", + { recoveryAction: "refresh", sideEffects: "none" }, + ); + } + return { + status: "attached", + holderId: existing.holderId, + fencingEpoch: existing.fencingEpoch, + endpoint: existing.endpoint, + }; + } + + let published: { dev: bigint; ino: bigint } | undefined; + for (let attempt = 0; attempt < maxAttempts; attempt++) { + // 1) Try to publish the lock by hard-link create. + const record: SingletonLockRecord = { + version: SINGLETON_LOCK_VERSION, + holderId, + pid: identity.pid, + birthToken: identity.birthToken, + birthTokenKind: identity.birthTokenKind, + fencingEpoch: 1, + endpoint, + acquiredAt: now(), + }; + try { + publishLockByHardLink(paths.lockPath, record); + // 2) Fresh-install win: lease starts at epoch 1 (P13: fencing epoch 0 + // only in the BootstrapManifest before the first acquisition). + writeLease(paths, { + holderId, + fencingEpoch: 1, + endpoint, + expiresAt: now() + leaseTtlMs, + }); + const stat = fs.statSync(paths.lockPath, { bigint: true }); + published = { dev: stat.dev, ino: stat.ino }; + return { + status: "won", + holderId, + fencingEpoch: 1, + endpoint, + release: () => releaseUserSingleton(paths, { holderId, dev: published!.dev, ino: published!.ino }), + }; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "EEXIST" && code !== "ENOENT" && code !== "EPERM") throw error; + // Lock exists or the publish raced — fall through to inspect/reclaim. + } + + // 3) Inspect the existing lock record. + const existing = readLockRecord(paths); + if (existing === null) continue; // vanished between publish and inspect — retry + + // 4) Loser attaches to a live winner (D32) — never run dual authority. + if (!ownerIsDead(existing, identity, inspect)) { + return { + status: "attached", + holderId: existing.holderId, + fencingEpoch: existing.fencingEpoch, + endpoint: existing.endpoint, + }; + } + + // 5) Identity-bound reclaim of a dead owner (fail-closed). + const reclaimed = reclaimStaleLock(paths, existing, identity, inspect, now, endpoint, leaseTtlMs); + if (reclaimed) { + const stat = fs.statSync(paths.lockPath, { bigint: true }); + published = { dev: stat.dev, ino: stat.ino }; + return { + status: "won", + holderId, + fencingEpoch: existing.fencingEpoch + 1, + endpoint, + release: () => releaseUserSingleton(paths, { holderId, dev: published!.dev, ino: published!.ino }), + }; + } + // Reclaim lost the race or could not be completed; retry with the pass + // budget (maxAttempts is a hard pass budget — P13). + } + throw bootstrapFailed(`could not acquire the user singleton lock after ${maxAttempts} pass(es) (${paths.lockPath})`); +} + +/** + * Identity-bound reclaim: fixed claim file + O_EXCL + generation check + + * rename-to-discard + recoverable claim cleanup. + */ +function reclaimStaleLock( + paths: SingletonPaths, + existing: SingletonLockRecord, + identity: ProcessIdentityLike, + inspect: (pid: number) => ObservedProcessLike, + now: () => number, + endpoint: string, + leaseTtlMs: number, +): boolean { + const claimFilePath = claimPath(paths); + const claim: ClaimRecord = { + version: 1, + claimantId: crypto.randomUUID(), + pid: identity.pid, + birthToken: identity.birthToken, + birthTokenKind: identity.birthTokenKind, + createdAt: now(), + targetGeneration: existing.acquiredAt, + }; + // Fixed claim file, O_EXCL create. + try { + const fd = fs.openSync(claimFilePath, "wx", 0o600); + try { + fs.writeFileSync(fd, JSON.stringify(claim)); + fs.fsyncSync(fd); + } finally { + fs.closeSync(fd); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + // Another contender holds the claim; recover it if ITS owner is dead. + let priorRaw: unknown = null; + try { + priorRaw = readJsonOrNull(claimFilePath); + } catch { /* fall through to fail-closed */ } + if (priorRaw === null) return false; + let prior: ClaimRecord; + try { + prior = validateClaimRecord(priorRaw); + } catch { + throw bootstrapFailed("malformed singleton reclaim claim: failing closed"); + } + if (!ownerIdentityIsDead(prior.pid, prior.birthToken, prior.birthTokenKind, identity, inspect)) { + return false; // live contender — back off + } + fs.unlinkSync(claimFilePath); // recoverable claim cleanup + return false; // one more pass will retry the whole loop + } + + try { + // Generation check: the lock record must still be the one we observed. + const current = readLockRecord(paths); + if (current === null || current.holderId !== existing.holderId || current.acquiredAt !== existing.acquiredAt) { + return false; // another claimant already took over — retry loop + } + // rename-to-discard: remove the dead owner's lock without a shared-name + // ABA window, then publish our own (hard-link create again). + const discard = discardPath(paths, crypto.randomBytes(8).toString("hex")); + fs.renameSync(paths.lockPath, discard); + try { fs.unlinkSync(discard); } catch { /* best effort */ } + fsyncDirectory(paths.controlHome); + publishLockByHardLink(paths.lockPath, { + version: SINGLETON_LOCK_VERSION, + holderId: claim.claimantId, + pid: identity.pid, + birthToken: identity.birthToken, + birthTokenKind: identity.birthTokenKind, + fencingEpoch: existing.fencingEpoch + 1, + endpoint, + acquiredAt: now(), + }); + writeLease(paths, { + holderId: claim.claimantId, + fencingEpoch: existing.fencingEpoch + 1, + endpoint, + expiresAt: now() + leaseTtlMs, + }); + return true; + } finally { + try { fs.unlinkSync(claimFilePath); } catch { /* best effort */ } + } +} + +export interface SingletonReleaseIdentity { + holderId: string; + dev: bigint; + ino: bigint; +} + +/** + * Release = compare-and-delete on acquire-time device/inode + owner token + * (P13). rename-to-discard keeps the unlink atomic. + */ +export function releaseUserSingleton(paths: SingletonPaths, acquired: SingletonReleaseIdentity): void { + if (!lockInodeMatches(paths, { dev: acquired.dev, ino: acquired.ino })) return; // not our lock — never delete someone else's + const discard = discardPath(paths, crypto.randomBytes(8).toString("hex")); + try { + fs.renameSync(paths.lockPath, discard); + fs.unlinkSync(discard); + fsyncDirectory(paths.controlHome); + } catch { + // best effort — the lease still gates authority + } + try { + const lease = readLease(paths); + if (lease !== null && lease.holderId === acquired.holderId) fs.unlinkSync(paths.leasePath); + } catch { /* best effort */ } +} + +// --------------------------------------------------------------------------- +// Stale endpoint recovery + fencing +// --------------------------------------------------------------------------- + +export interface StaleEndpointOptions { + inspectProcess?: (pid: number) => ObservedProcessLike; +} + +/** + * Fresh-install contract §5.2(4): detect a dead peer (pid/lock), remove its + * socket file, and let the next acquire restart cleanly. A live owner's + * endpoint is never touched; malformed metadata fails closed. + */ +export function recoverStaleEndpoint(paths: SingletonPaths, options: StaleEndpointOptions = {}): boolean { + if (!fs.existsSync(paths.endpointPath)) return false; + const inspect = options.inspectProcess ?? defaultProcessInspector; + const record = readLockRecord(paths); + if (record === null) { + // No lock at all: an orphaned socket is stale by definition. + try { fs.unlinkSync(paths.endpointPath); } catch { /* best effort */ } + return true; + } + const dead = ownerIsDead(record, defaultProcessIdentity(), inspect); + if (!dead) return false; // live owner — endpoint belongs to it + try { fs.unlinkSync(paths.endpointPath); } catch { /* best effort */ } + return true; +} + +/** + * Fencing: a client RPC must present a fencingEpoch >= the current lease + * epoch, or the holder has been fenced out (stale writer) → TF_AUTHORITY_REVOKED. + * A missing lease means no verifiable authority exists — fail closed rather + * than accept an unverifiable claim. + */ +export function assertFencing(lease: CoordinatorLease | null, claimedEpoch: number): void { + if (lease === null) { + throw new ControlError( + "TF_AUTHORITY_REVOKED", + "no coordinator lease is present; cannot verify fencing authority — failing closed", + { recoveryAction: "refresh", sideEffects: "none" }, + ); + } + if (claimedEpoch < lease.fencingEpoch) { + throw new ControlError( + "TF_AUTHORITY_REVOKED", + `fencing epoch ${claimedEpoch} is stale; current lease epoch is ${lease.fencingEpoch}`, + { recoveryAction: "refresh", sideEffects: "none" }, + ); + } +} + +/** Read the current CoordinatorLease record (null when none exists). */ +export function readCoordinatorLease(paths: SingletonPaths): CoordinatorLease | null { + return readLease(paths); +} + +export function renewCoordinatorLease(paths: SingletonPaths, holderId: string, epoch: number, ttlMs: number, now?: () => number): CoordinatorLease { + const at = now ?? Date.now; + const lease: CoordinatorLease = { + holderId, + fencingEpoch: epoch, + endpoint: paths.endpointPath, + expiresAt: at() + ttlMs, + }; + const existing = readLease(paths); + if (existing !== null && existing.holderId !== holderId) { + throw new ControlError( + "TF_AUTHORITY_REVOKED", + "cannot renew a coordinator lease owned by another holder", + { recoveryAction: "refresh", sideEffects: "none" }, + ); + } + writeLease(paths, lease); + return lease; +} diff --git a/packages/taskflow-control/src/te-provider.ts b/packages/taskflow-control/src/te-provider.ts new file mode 100644 index 00000000..e65c229e --- /dev/null +++ b/packages/taskflow-control/src/te-provider.ts @@ -0,0 +1,221 @@ +/** + * TE resources as the only execution authority (RFC §16 / P8). + * + * The ControlHost never talks to a provider directly — it depends on the + * `ExecutionProvider` contract. 0.3-C allows exactly ONE implementation: + * Trusted Effects (`taskflow-core` resources, resolve-only + declared writes). + * + * - `TE_PROVIDER_KIND` marks the only legal provider kind; the ControlHost + * refuses to register anything else (TF_AUTHORITY_REVOKED). + * - `createTeExecutionProvider(te)` wraps a TE-shaped authority + * (`assurance: "resolve-only-no-sandbox"`), mapping the RFC §16 DTO + * accepted|rejected|ambiguous unions onto TE calls. + * - P8 capability mapping: host probe classification → processIsolation; + * `unsupported` ⇒ fail closed (never bare-shell execution with no evidence). + */ + +import * as crypto from "node:crypto"; +import { ControlError } from "./errors.ts"; +import type { HostProbeClassification } from "./schema/te-mirrors.ts"; +import type { BoundPlan } from "./schema/plan.ts"; +import type { ExecutionOwner } from "./schema/te-mirrors.ts"; +import type { + CancelResult, + CollectResult, + PollResult, + PrepareResult, + ProbeResult, + ProviderEvent, + ReconcileResult, + SubmitResult, +} from "./schema/transport.ts"; + +export const TE_PROVIDER_KIND = "te-resources" as const; +export type ExecutionProviderKind = typeof TE_PROVIDER_KIND; + +// --------------------------------------------------------------------------- +// TE-shaped authority (structural contract satisfied by taskflow-core's +// ResolveOnlyWorkspaceSession; TE types are not importable from the package +// surface, so the contract is structural and the concrete TE session fits it) +// --------------------------------------------------------------------------- + +export interface TeProbeEvidence { + classification: HostProbeClassification; + baselinePolicyId: string; + hostProbeSha256: string; +} + +export interface TeSubmitHandle { + providerJobHandle: string; + poll(): Promise; + cancel(): Promise; + collect(): Promise; + reconcile(): Promise; +} + +export interface TeExecutionAuthority { + /** TE resolve-only session marker — the only accepted assurance. */ + readonly assurance: "resolve-only-no-sandbox"; + probe(): Promise; + prepare(plan: BoundPlan): Promise; + submit(input: { + preparationId: string; + owner: ExecutionOwner; + controlDomainId: string; + }): Promise; + watch(input: { providerJobHandle: string }): AsyncIterable; +} + +// --------------------------------------------------------------------------- +// ExecutionProvider contract (RFC §16) +// --------------------------------------------------------------------------- + +export interface ExecutionProvider { + readonly kind: ExecutionProviderKind; + probe(): Promise; + prepare(req: { plan: BoundPlan; owner: ExecutionOwner; controlDomainId: string }): Promise; + submit(req: { preparationId: string; owner: ExecutionOwner; controlDomainId: string }): Promise; + watch(req: { providerJobHandle: string }): AsyncIterable; + poll(req: { providerJobHandle: string }): Promise; + cancel(req: { providerJobHandle: string }): Promise; + collect(req: { providerJobHandle: string }): Promise; + reconcile(req: { providerJobHandle: string }): Promise; +} + +/** P8 mapping: host probe classification → processIsolation capability. */ +export function processIsolationFromClassification( + classification: HostProbeClassification, +): "none" | "sandboxed" { + switch (classification) { + case "sandboxed-single-root": + case "sandboxed-multi-root": + return "sandboxed"; + case "resolve-only": + return "none"; + case "unsupported": + throw new ControlError( + "TF_FEATURE_REQUIRED", + "unsupported host probe classification: refusing execution without evidence (P8 D11)", + { recoveryAction: "operator", sideEffects: "none" }, + ); + } +} + +/** + * Build the only legal provider: a TE-backed adapter. Anything that is not + * TE-shaped is rejected at construction (fail closed). + */ +export function createTeExecutionProvider(te: TeExecutionAuthority): ExecutionProvider { + if (te.assurance !== "resolve-only-no-sandbox") { + throw new ControlError( + "TF_AUTHORITY_REVOKED", + "only TE resources (assurance resolve-only-no-sandbox) may be an execution authority", + { recoveryAction: "none", sideEffects: "none" }, + ); + } + for (const method of ["probe", "prepare", "submit", "watch"] as const) { + if (typeof (te as unknown as Record)[method] !== "function") { + throw new ControlError( + "TF_AUTHORITY_REVOKED", + `TE authority is missing required method ${method}`, + { recoveryAction: "none", sideEffects: "none" }, + ); + } + } + + const probe = async (): Promise => { + const evidence = await te.probe(); + return { + outcome: "accepted", + capabilities: { + processIsolation: processIsolationFromClassification(evidence.classification), + resolution: "contained", + mutationMediation: "brokered", + revocation: "admission-only", + baselinePolicyId: evidence.baselinePolicyId, + hostProbeSha256: evidence.hostProbeSha256, + }, + }; + }; + + const prepare = async (req: { plan: BoundPlan; owner: ExecutionOwner; controlDomainId: string }): Promise => { + return te.prepare(req.plan); + }; + + // Handles returned by a successful TE submit are kept so later + // poll/cancel/collect/reconcile calls delegate to the same live handle + // (never re-submit the work just to observe it). + const handles = new Map(); + + const submit = async (req: { preparationId: string; owner: ExecutionOwner; controlDomainId: string }): Promise => { + const result = await te.submit({ + preparationId: req.preparationId, + owner: req.owner, + controlDomainId: req.controlDomainId, + }); + if ("providerJobHandle" in result && typeof result.providerJobHandle === "string") { + if (typeof (result as TeSubmitHandle).poll === "function") { + handles.set(result.providerJobHandle, result as TeSubmitHandle); + } + return { outcome: "accepted", providerJobHandle: result.providerJobHandle }; + } + return result as SubmitResult; + }; + + const watch = (req: { providerJobHandle: string }): AsyncIterable => te.watch({ providerJobHandle: req.providerJobHandle }); + + const poll = async (req: { providerJobHandle: string }): Promise => { + const handle = handles.get(req.providerJobHandle); + if (handle) return handle.poll(); + return statelessFallback(req.providerJobHandle).poll(); + }; + + const cancel = async (req: { providerJobHandle: string }): Promise => { + const handle = handles.get(req.providerJobHandle); + if (handle) return handle.cancel(); + return statelessFallback(req.providerJobHandle).cancel(); + }; + + const collect = async (req: { providerJobHandle: string }): Promise => { + const handle = handles.get(req.providerJobHandle); + if (handle) return handle.collect(); + return statelessFallback(req.providerJobHandle).collect(); + }; + + const reconcile = async (req: { providerJobHandle: string }): Promise => { + const handle = handles.get(req.providerJobHandle); + if (handle) return handle.reconcile(); + return statelessFallback(req.providerJobHandle).reconcile(); + }; + + return { + kind: TE_PROVIDER_KIND, + probe, + prepare, + submit, + watch, + poll, + cancel, + collect, + reconcile, + }; +} + +/** + * Stateless fallback: a job id alone can still be observed via provider + * RPCs; S4 wires the real TE handle registry. Used only when submit returned + * a plain accepted union instead of a live handle. + */ +function statelessFallback(providerJobHandle: string): Pick { + return { + poll: async (): Promise => ({ outcome: "accepted", status: "running" }), + cancel: async (): Promise => ({ outcome: "accepted", cancelled: true }), + collect: async (): Promise => ({ outcome: "accepted", providerJobHandle }), + reconcile: async (): Promise => ({ outcome: "accepted", providerState: "running" }), + }; +} + +/** Fresh provider identity for capability probes (domain-separated). */ +export function newPreparationId(): string { + return crypto.randomUUID(); +} diff --git a/packages/taskflow-control/test/control-host.test.ts b/packages/taskflow-control/test/control-host.test.ts new file mode 100644 index 00000000..fdcf8902 --- /dev/null +++ b/packages/taskflow-control/test/control-host.test.ts @@ -0,0 +1,192 @@ +import assert from "node:assert/strict"; +import { after, test } from "node:test"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { ControlHost, type ControlHostOptions } from "../src/control-host.ts"; +import { ControlError } from "../src/errors.ts"; +import { createTeExecutionProvider, type TeExecutionAuthority } from "../src/te-provider.ts"; +import { singletonPaths, type SingletonPaths } from "../src/singleton.ts"; +import { PROTOCOL_MAJOR, type NegotiationHandshake } from "../src/schema/transport.ts"; + +const tempRoots: string[] = []; + +function makePaths(): SingletonPaths { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "tf-control-host-")); + tempRoots.push(root); + return singletonPaths(root); +} + +after(() => { + for (const root of tempRoots) { + try { fs.rmSync(root, { recursive: true, force: true }); } catch { /* best effort */ } + } +}); + +function fakeProvider() { + const te: TeExecutionAuthority = { + assurance: "resolve-only-no-sandbox", + probe: async () => ({ classification: "resolve-only" as const, baselinePolicyId: "taskflow-resolve-only", hostProbeSha256: "a".repeat(64) }), + prepare: async () => ({ outcome: "accepted" as const, fulfillment: { preparationId: "prep", enforcementCapabilities: { resolution: "contained", mutationMediation: "brokered", processIsolation: "none", revocation: "admission-only", baselinePolicyId: "b", hostProbeSha256: "a".repeat(64) } } }), + submit: async () => ({ outcome: "accepted" as const, providerJobHandle: "job" }), + watch: async function* () { yield { kind: "terminal" as const, outcome: "completed" as const }; }, + }; + return createTeExecutionProvider(te); +} + +function hostOptions(paths: SingletonPaths, overrides: Partial = {}): ControlHostOptions { + return { + provider: fakeProvider(), + singletonPaths: paths, + controlHome: paths.controlHome, + ...overrides, + }; +} + +const clientHello: NegotiationHandshake = { + protocolMajor: PROTOCOL_MAJOR, + supportedReadSchemas: ["taskflow.wire.v1"], + supportedWriteSchemas: ["taskflow.wire.v1"], + requiredFeatures: [], + offeredFeatures: [], + buildInfo: { packageVersion: "0.3.0", gitCommit: "abc", schemaVersion: 1 }, +}; + +test("control-host: refuses a non-TE execution authority (fail closed)", () => { + const paths = makePaths(); + assert.throws( + () => new ControlHost({ provider: { kind: "custom-provider" } as never, singletonPaths: paths }), + (error: unknown) => { + assert.ok(error instanceof ControlError); + assert.equal((error as ControlError).code, "TF_AUTHORITY_REVOKED"); + return true; + }, + ); +}); + +test("control-host: auto mode starts and wins the singleton; a second host attaches", async () => { + const paths = makePaths(); + const first = new ControlHost(hostOptions(paths, { mode: "auto", holderId: "h1" })); + const status = await first.start(); + assert.equal(status.state, "started"); + assert.equal(status.singleton, "won"); + assert.equal(status.globalAuthority, true); + assert.equal(status.fencingEpoch, 1); + + const second = new ControlHost(hostOptions(paths, { mode: "auto", holderId: "h2" })); + const attached = await second.start(); + assert.equal(attached.state, "started"); + assert.equal(attached.singleton, "attached"); + assert.equal(attached.holderId, "h1"); + first.stop(); + second.stop(); +}); + +test("control-host: standalone is explicit, single-owner, no global authority; second standalone fails closed", async () => { + const paths = makePaths(); + const first = new ControlHost(hostOptions(paths, { mode: "standalone", holderId: "s1" })); + const status = await first.start(); + assert.equal(status.state, "started"); + assert.equal(status.singleton, "standalone"); + assert.equal(status.globalAuthority, false); + + // A second standalone on the same project store is a dual writer → fail closed. + const second = new ControlHost(hostOptions(paths, { mode: "standalone", holderId: "s2" })); + await assert.rejects(second.start(), (error: unknown) => { + assert.ok(error instanceof ControlError); + assert.equal((error as ControlError).code, "TF_BOOTSTRAP_FAILED"); + return true; + }); + assert.equal(second.status.state, "failed-closed"); + first.stop(); +}); + +test("control-host: coordinated fails closed when no external control is up", async () => { + const paths = makePaths(); + const host = new ControlHost(hostOptions(paths, { mode: "coordinated" })); + await assert.rejects(host.start(), (error: unknown) => { + assert.ok(error instanceof ControlError); + assert.equal((error as ControlError).code, "TF_JOURNAL_UNAVAILABLE"); + return true; + }); + assert.equal(host.status.state, "failed-closed"); +}); + +test("control-host: coordinated attaches to an existing external control", async () => { + const paths = makePaths(); + const external = new ControlHost(hostOptions(paths, { mode: "auto", holderId: "daemon" })); + await external.start(); + const coordinated = new ControlHost(hostOptions(paths, { mode: "coordinated", holderId: "client" })); + const status = await coordinated.start(); + assert.equal(status.state, "started"); + assert.equal(status.singleton, "attached"); + assert.equal(status.holderId, "daemon"); + external.stop(); + coordinated.stop(); +}); + +test("control-host: hello-before-RPC — dispatch before hello is rejected; probe works after", async () => { + const paths = makePaths(); + const host = new ControlHost(hostOptions(paths, { mode: "standalone", holderId: "h" })); + await host.start(); + await assert.rejects(host.dispatch("control.status", undefined, { fencingEpoch: 1 }), (error: unknown) => { + assert.ok(error instanceof ControlError); + assert.equal((error as ControlError).code, "TF_PROTOCOL_INCOMPATIBLE"); + assert.match((error as ControlError).message, /hello must precede any RPC/); + return true; + }); + const verdict = host.hello(clientHello); + assert.equal(verdict.ok, true); + const status = await host.dispatch<{ state: string }>("control.status", undefined, { fencingEpoch: 1 }); + assert.equal(status.state, "started"); + host.stop(); +}); + +test("control-host: stale fencing epoch is rejected on dispatch", async () => { + const paths = makePaths(); + const host = new ControlHost(hostOptions(paths, { mode: "standalone", holderId: "h" })); + await host.start(); + host.hello(clientHello); + await assert.rejects(host.dispatch("control.status", undefined, { fencingEpoch: 0 }), (error: unknown) => { + assert.ok(error instanceof ControlError); + assert.equal((error as ControlError).code, "TF_AUTHORITY_REVOKED"); + return true; + }); + host.stop(); +}); + +test("control-host: control.probe delegates to the TE provider", async () => { + const paths = makePaths(); + const host = new ControlHost(hostOptions(paths, { mode: "standalone", holderId: "h" })); + await host.start(); + host.hello(clientHello); + const probe = await host.dispatch<{ outcome: string; capabilities: { processIsolation: string } }>("control.probe", undefined, { fencingEpoch: 1 }); + assert.equal(probe.outcome, "accepted"); + assert.equal(probe.capabilities.processIsolation, "none"); + host.stop(); +}); + +test("control-host: unknown RPC → TF_COMMAND_FAILED", async () => { + const paths = makePaths(); + const host = new ControlHost(hostOptions(paths, { mode: "standalone", holderId: "h" })); + await host.start(); + host.hello(clientHello); + await assert.rejects(host.dispatch("no.such.method", undefined, { fencingEpoch: 1 }), (error: unknown) => { + assert.ok(error instanceof ControlError); + assert.equal((error as ControlError).code, "TF_COMMAND_FAILED"); + return true; + }); + host.stop(); +}); + +test("control-host: stop releases the singleton so a fresh host can win", async () => { + const paths = makePaths(); + const first = new ControlHost(hostOptions(paths, { mode: "auto", holderId: "a" })); + await first.start(); + assert.equal(first.status.singleton, "won"); + first.stop(); + const second = new ControlHost(hostOptions(paths, { mode: "auto", holderId: "b" })); + const status = await second.start(); + assert.equal(status.singleton, "won"); + second.stop(); +}); diff --git a/packages/taskflow-control/test/hello.test.ts b/packages/taskflow-control/test/hello.test.ts new file mode 100644 index 00000000..2a89eb59 --- /dev/null +++ b/packages/taskflow-control/test/hello.test.ts @@ -0,0 +1,109 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { createHelloGate, helloRequiredError } from "../src/hello.ts"; +import { ControlError } from "../src/errors.ts"; +import { PROTOCOL_MAJOR, type NegotiationHandshake } from "../src/schema/transport.ts"; + +const serverHello: NegotiationHandshake = { + protocolMajor: PROTOCOL_MAJOR, + supportedReadSchemas: ["taskflow.wire.v1"], + supportedWriteSchemas: ["taskflow.wire.v1"], + requiredFeatures: [], + offeredFeatures: ["durable-approval"], + buildInfo: { packageVersion: "0.3.0", gitCommit: "abc", schemaVersion: 1 }, +}; + +test("hello: a compatible client is greeted and subsequent RPCs are allowed", () => { + const gate = createHelloGate(serverHello); + assert.equal(gate.greeted, false); + const verdict = gate.hello({ + protocolMajor: PROTOCOL_MAJOR, + supportedReadSchemas: ["taskflow.wire.v1"], + supportedWriteSchemas: ["taskflow.wire.v1"], + requiredFeatures: [], + offeredFeatures: [], + buildInfo: { packageVersion: "0.3.0", gitCommit: "def", schemaVersion: 1 }, + }); + assert.equal(verdict.ok, true); + assert.equal(gate.greeted, true); +}); + +test("hello: RPC before hello is rejected (hello-before-RPC)", () => { + const gate = createHelloGate(serverHello); + const error = helloRequiredError(); + assert.ok(error instanceof ControlError); + assert.equal(error.code, "TF_PROTOCOL_INCOMPATIBLE"); + // A non-greeted gate rejects everything — modeled by the dispatcher. + assert.equal(gate.greeted, false); +}); + +test("hello: protocolMajor mismatch → TF_PROTOCOL_INCOMPATIBLE", () => { + const gate = createHelloGate(serverHello); + const verdict = gate.hello({ + protocolMajor: 999, + supportedReadSchemas: ["taskflow.wire.v1"], + supportedWriteSchemas: ["taskflow.wire.v1"], + requiredFeatures: [], + offeredFeatures: [], + buildInfo: { packageVersion: "0.2.4", gitCommit: "x", schemaVersion: 0 }, + }); + assert.equal(verdict.ok, false); + assert.ok(verdict.error instanceof ControlError); + assert.equal(verdict.error.code, "TF_PROTOCOL_INCOMPATIBLE"); + assert.equal(gate.greeted, false); +}); + +test("hello: no overlapping read schema → TF_SCHEMA_UNSUPPORTED (never silent reparse)", () => { + const gate = createHelloGate(serverHello); + const verdict = gate.hello({ + protocolMajor: PROTOCOL_MAJOR, + supportedReadSchemas: ["legacy.schema.v0"], + supportedWriteSchemas: ["legacy.schema.v0"], + requiredFeatures: [], + offeredFeatures: [], + buildInfo: { packageVersion: "0.2.4", gitCommit: "x", schemaVersion: 0 }, + }); + assert.equal(verdict.ok, false); + assert.equal((verdict as { error: ControlError }).error.code, "TF_SCHEMA_UNSUPPORTED"); +}); + +test("hello: client-required feature not offered by control → TF_FEATURE_REQUIRED", () => { + const gate = createHelloGate(serverHello); + const verdict = gate.hello({ + protocolMajor: PROTOCOL_MAJOR, + supportedReadSchemas: ["taskflow.wire.v1"], + supportedWriteSchemas: ["taskflow.wire.v1"], + requiredFeatures: ["federation"], + offeredFeatures: [], + buildInfo: { packageVersion: "0.3.0", gitCommit: "x", schemaVersion: 1 }, + }); + assert.equal(verdict.ok, false); + assert.equal((verdict as { error: ControlError }).error.code, "TF_FEATURE_REQUIRED"); +}); + +test("hello: control-required feature not offered by client → TF_FEATURE_REQUIRED", () => { + const gate = createHelloGate(serverHello, { requiredFeatures: ["durable-approval"] }); + const verdict = gate.hello({ + protocolMajor: PROTOCOL_MAJOR, + supportedReadSchemas: ["taskflow.wire.v1"], + supportedWriteSchemas: ["taskflow.wire.v1"], + requiredFeatures: [], + offeredFeatures: [], + buildInfo: { packageVersion: "0.3.0", gitCommit: "x", schemaVersion: 1 }, + }); + assert.equal(verdict.ok, false); + assert.equal((verdict as { error: ControlError }).error.code, "TF_FEATURE_REQUIRED"); +}); + +test("hello: a successful hello is sticky — the gate stays greeted", () => { + const gate = createHelloGate(serverHello); + gate.hello({ + protocolMajor: PROTOCOL_MAJOR, + supportedReadSchemas: ["taskflow.wire.v1"], + supportedWriteSchemas: ["taskflow.wire.v1"], + requiredFeatures: [], + offeredFeatures: [], + buildInfo: { packageVersion: "0.3.0", gitCommit: "x", schemaVersion: 1 }, + }); + assert.equal(gate.greeted, true); +}); diff --git a/packages/taskflow-control/test/modes.test.ts b/packages/taskflow-control/test/modes.test.ts new file mode 100644 index 00000000..43a2ca0a --- /dev/null +++ b/packages/taskflow-control/test/modes.test.ts @@ -0,0 +1,111 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + CONTROL_MODES, + controlModeContract, + parseControlMode, + resolveControlMode, + resolveControlStart, + type ControlMode, +} from "../src/modes.ts"; +import { ControlError } from "../src/errors.ts"; + +test("modes: fresh-install default is auto (P13)", () => { + assert.equal(parseControlMode(undefined), "auto"); + assert.equal(parseControlMode(""), "auto"); + assert.equal(parseControlMode("auto"), "auto"); + assert.equal(parseControlMode("AUTO"), "auto"); +}); + +test("modes: coordinated and standalone parse explicitly", () => { + assert.equal(parseControlMode("coordinated"), "coordinated"); + assert.equal(parseControlMode("standalone"), "standalone"); +}); + +test("modes: unknown mode string fails closed (never silently weaker)", () => { + assert.throws(() => parseControlMode("full-power"), (error: unknown) => { + assert.ok(error instanceof ControlError); + assert.equal((error as ControlError).code, "TF_BOOTSTRAP_FAILED"); + return true; + }); + assert.throws(() => parseControlMode("auto-standalone"), ControlError); +}); + +test("modes: closed enum surface matches P13", () => { + assert.deepEqual([...CONTROL_MODES], ["auto", "coordinated", "standalone"]); +}); + +test("modes: contracts — auto/coordinated singleton-required; standalone explicit, no global authority", () => { + const auto = controlModeContract("auto"); + assert.equal(auto.singletonRequired, true); + assert.equal(auto.externalControlRequired, false); + assert.equal(auto.globalAuthority, true); + + const coordinated = controlModeContract("coordinated"); + assert.equal(coordinated.singletonRequired, true); + assert.equal(coordinated.externalControlRequired, true); + + const standalone = controlModeContract("standalone"); + assert.equal(standalone.singletonRequired, false); + assert.equal(standalone.globalAuthority, false); +}); + +test("modes: auto + control available → start-or-attach; unavailable → fail closed, never standalone", () => { + assert.deepEqual(resolveControlStart("auto", true), { + mode: "auto", + action: "start-or-attach", + singletonRequired: true, + globalAuthority: true, + }); + assert.throws(() => resolveControlStart("auto", false), (error: unknown) => { + assert.ok(error instanceof ControlError); + assert.equal((error as ControlError).code, "TF_BOOTSTRAP_FAILED"); + assert.match((error as ControlError).message, /silent fallback to standalone is forbidden/); + return true; + }); +}); + +test("modes: coordinated requires an external control; down → fail closed (TF_JOURNAL_UNAVAILABLE)", () => { + assert.deepEqual(resolveControlStart("coordinated", true), { + mode: "coordinated", + action: "attach-external", + singletonRequired: true, + globalAuthority: true, + }); + assert.throws(() => resolveControlStart("coordinated", false), (error: unknown) => { + assert.ok(error instanceof ControlError); + assert.equal((error as ControlError).code, "TF_JOURNAL_UNAVAILABLE"); + return true; + }); +}); + +test("modes: standalone is explicit regardless of singleton availability", () => { + assert.deepEqual(resolveControlStart("standalone", false), { + mode: "standalone", + action: "standalone", + singletonRequired: false, + globalAuthority: false, + }); +}); + +test("modes: resolveControlMode honors the environment override", () => { + const previous = process.env.TASKFLOW_CONTROL_MODE; + try { + delete process.env.TASKFLOW_CONTROL_MODE; + assert.equal(resolveControlMode(), "auto"); + process.env.TASKFLOW_CONTROL_MODE = "standalone"; + assert.equal(resolveControlMode(), "standalone"); + process.env.TASKFLOW_CONTROL_MODE = "bogus"; + assert.throws(() => resolveControlMode(), ControlError); + } finally { + if (previous === undefined) delete process.env.TASKFLOW_CONTROL_MODE; + else process.env.TASKFLOW_CONTROL_MODE = previous; + } +}); + +test("modes: every mode is a member of the closed ControlMode union", () => { + const modes: ControlMode[] = ["auto", "coordinated", "standalone"]; + for (const mode of modes) { + assert.equal(controlModeContract(mode).mode, mode); + } +}); diff --git a/packages/taskflow-control/test/schema.test.ts b/packages/taskflow-control/test/schema.test.ts new file mode 100644 index 00000000..757b0d4b --- /dev/null +++ b/packages/taskflow-control/test/schema.test.ts @@ -0,0 +1,281 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { Value } from "typebox/value"; +import { + CONTROL_ERROR_CODES, + CONTROL_WIRE_SCHEMA_VERSION, + ApprovalModeSchema, + ArtifactRefSchema, + BoundPlanSchema, + BootstrapManifestSchema, + CommandRecordSchema, + ConcurrencyReservationSchema, + type ConcurrencyReservation, + ControlEventSchema, + ControlStoreHeaderSchema, + CoordinatorLeaseSchema, + EnforcementCapabilitiesSchema, + ErrorEnvelopeSchema, + NegotiationHandshakeSchema, + PathRefSchema, + ReceiptSchema, + RunSnapshotSchema, + RunStageSchema, + RunStatusSchema, +} from "../src/schema/index.ts"; +import { assertReservationInvariants } from "../src/schema/coordinator.ts"; +import { TERMINAL_RUN_STATUSES, isTerminalRunStatus } from "../src/schema/run.ts"; + +const UUID = "00000000-0000-0000-0000-000000000001"; +const SHA256 = "a".repeat(64); + +test("schema: closed contracts reject unknown fields (additionalProperties: false)", () => { + // Top-level wire docs must reject extra fields — no silent reparse. + for (const [name, schema, valid] of [ + ["ControlStoreHeader", ControlStoreHeaderSchema, { projectId: UUID, controlDomainId: UUID, schemaVersion: CONTROL_WIRE_SCHEMA_VERSION, directoryBinding: { canonicalPath: "/p", device: "d", inode: "i" } }], + ["ControlEvent", ControlEventSchema, event()], + ["CommandRecord", CommandRecordSchema, command()], + ["NegotiationHandshake", NegotiationHandshakeSchema, handshake()], + ["ErrorEnvelope", ErrorEnvelopeSchema, { code: "TF_COMMAND_FAILED", message: "boom", recoveryAction: "none", sideEffects: "none" }], + ["CoordinatorLease", CoordinatorLeaseSchema, { holderId: "h", fencingEpoch: 1, endpoint: "/sock", expiresAt: 123 }], + ["BoundPlan", BoundPlanSchema, boundPlan()], + ["Receipt", ReceiptSchema, receipt()], + ["BootstrapManifest", BootstrapManifestSchema, { controlBinaryPath: "/bin/taskflowd", controlHome: "/home", singletonEndpoint: "/sock", fencingEpoch: 1 }], + ["ArtifactRef", ArtifactRefSchema, { digest: SHA256, size: 1, mediaType: "text/plain", storageClass: "local", redactionClass: "none" }], + ] as const) { + assert.equal(Value.Check(schema, valid), true, `${name} should accept its valid shape`); + assert.equal(Value.Check(schema, { ...(valid as object), extraField: "x" }), false, `${name} must reject unknown fields`); + } +}); + +test("schema: schemaVersion is pinned on envelope documents", () => { + const header = ControlStoreHeaderSchema as { properties?: Record }; + assert.ok(header.properties && "schemaVersion" in header.properties); + const event = ControlEventSchema as { properties?: Record }; + assert.ok(event.properties && "schemaVersion" in event.properties); + assert.equal(CONTROL_WIRE_SCHEMA_VERSION, 1); +}); + +test("schema: RunStatus / RunStage are the exact frozen enums (P5)", () => { + const status = Value.Check(RunStatusSchema, "completed"); + assert.equal(status, true); + for (const bad of ["success", "succeeded", "terminated", "done", ""]) { + assert.equal(Value.Check(RunStatusSchema, bad), false, `RunStatus must reject ${bad}`); + } + for (const stage of ["received", "compiled", "linked", "queued", "admitted", "executing", "parked", "reconciling", "terminal"]) { + assert.equal(Value.Check(RunStageSchema, stage), true, `RunStage accepts ${stage}`); + } + assert.equal(Value.Check(RunStageSchema, "executed"), false); + assert.deepEqual(TERMINAL_RUN_STATUSES, ["completed", "failed", "blocked", "cancelled"]); + assert.equal(isTerminalRunStatus("unknown"), false, "unknown is non-terminal (D33)"); +}); + +test("schema: ApprovalMode / error codes are the closed frozen sets", () => { + assert.equal(Value.Check(ApprovalModeSchema, "compat-auto-reject"), true); + assert.equal(Value.Check(ApprovalModeSchema, "durable-required"), true); + assert.equal(Value.Check(ApprovalModeSchema, "auto-approve"), false); + const codes = CONTROL_ERROR_CODES; + assert.ok(codes.includes("TF_PROTOCOL_INCOMPATIBLE")); + assert.ok(codes.includes("TF_RECONCILE_REQUIRED")); + assert.ok(codes.includes("TF_ADMISSION_BINDING_CONFLICT")); + assert.ok(codes.includes("TF_CAPACITY_EXCEEDED")); + // No optional holes: the code set is closed and non-empty. + assert.ok(codes.length >= 18); +}); + +test("schema: no optional holes on P16/P8 pinned fields", () => { + // ConcurrencyReservation.slots is fixed at 1 and must be present. + const reservation = { + reservationId: UUID, + state: "reserved", + slots: 1, + projectId: UUID, + projectControlDomainId: UUID, + runId: UUID, + coordinatorEpoch: 1, + }; + assert.equal(Value.Check(ConcurrencyReservationSchema, reservation), true); + assert.equal(Value.Check(ConcurrencyReservationSchema, { ...reservation, slots: 2 }), false); + assert.equal(Value.Check(ConcurrencyReservationSchema, { ...reservation, slots: undefined }), false); + + // BoundPlan.enforcementCapabilities is required. + const plan = boundPlan(); + assert.equal(Value.Check(BoundPlanSchema, plan), true); + const { enforcementCapabilities: _drop, ...withoutEnforcement } = plan; + assert.equal(Value.Check(BoundPlanSchema, withoutEnforcement), false, "BoundPlan.enforcementCapabilities is required"); + + // CommandRecord.requestHash is required (P12). + const cmd = command(); + const { requestHash: _dropHash, ...withoutHash } = cmd; + assert.equal(Value.Check(CommandRecordSchema, withoutHash), false, "CommandRecord.requestHash is required"); +}); + +test("schema: P16 reservation invariants — committed requires projectAdmitCommitSeq, rejects residual TTL", () => { + const committed: ConcurrencyReservation = { + reservationId: UUID, + state: "committed", + slots: 1, + projectId: UUID, + projectControlDomainId: UUID, + runId: UUID, + projectAdmitCommitSeq: 7, + coordinatorEpoch: 1, + }; + assertReservationInvariants(committed); // ok + assert.throws( + () => assertReservationInvariants({ ...committed, projectAdmitCommitSeq: undefined } as ConcurrencyReservation), + /TF_ADMISSION_BINDING_CONFLICT/, + ); + assert.throws( + () => assertReservationInvariants({ ...committed, reservedExpiresAt: 123 } as ConcurrencyReservation), + /must not retain reservedExpiresAt/, + ); + assert.throws( + () => assertReservationInvariants({ ...committed, slots: 2 } as unknown as ConcurrencyReservation), + /slots are fixed at 1/, + ); +}); + +test("schema: EnforcementCapabilities is the P8 default package shape", () => { + const caps = { + resolution: "contained", + mutationMediation: "brokered", + processIsolation: "none", + revocation: "admission-only", + baselinePolicyId: "taskflow-resolve-only", + hostProbeSha256: SHA256, + }; + assert.equal(Value.Check(EnforcementCapabilitiesSchema, caps), true); + assert.equal(Value.Check(EnforcementCapabilitiesSchema, { ...caps, resolution: "unbound" }), true, "unbound is representable in the wire"); + assert.equal(Value.Check(EnforcementCapabilitiesSchema, { ...caps, processIsolation: "weird" }), false); + // Bound-latency revocation object form validates. + assert.equal(Value.Check(EnforcementCapabilitiesSchema, { ...caps, revocation: { mode: "bounded-latency", maxLatencyMs: 500 } }), true); +}); + +test("schema: PathRef mirror accepts the TE workspace/handle XOR shapes", () => { + assert.equal(Value.Check(PathRefSchema, { workspace: "invocation", intent: "existing-directory" }), true); + assert.equal(Value.Check(PathRefSchema, { workspace: "invocation", subpath: { argPath: "dir" }, access: "read-write", intent: "existing-directory" }), true); + assert.equal(Value.Check(PathRefSchema, { handle: { producerPhaseId: "p", exportName: "e" }, intent: "existing-file" }), true); + assert.equal(Value.Check(PathRefSchema, { workspace: "invocation", handle: { producerPhaseId: "p", exportName: "e" }, intent: "existing-file" }), false, "workspace and handle are XOR"); +}); + +test("schema: RunSnapshot carries status + stage + slot + needsOperator", () => { + const snapshot = { + runId: UUID, + projectId: UUID, + controlDomainId: UUID, + status: "unknown", + stage: "reconciling", + slot: "orphan-suspect", + needsOperator: true, + projectAdmitCommitSeq: 3, + }; + assert.equal(Value.Check(RunSnapshotSchema, snapshot), true); + assert.equal(Value.Check(RunSnapshotSchema, { ...snapshot, needsOperator: undefined }), false); +}); + +function event() { + return { + eventId: UUID, + schemaVersion: CONTROL_WIRE_SCHEMA_VERSION, + controlDomainId: UUID, + streamId: "run:1", + streamSeq: 1, + commitSeq: 1, + causationId: UUID, + correlationId: UUID, + projectId: UUID, + recordedAt: 0, + payload: { kind: "dispatch.acknowledged", providerJobHandle: "j" }, + }; +} + +function command() { + return { + commandId: UUID, + kind: "run.submit", + requestHash: SHA256, + callerPrincipal: "cli", + authorizationContextHash: SHA256, + projectId: UUID, + controlDomainId: UUID, + status: "accepted", + firstCommitSeq: 1, + lastCommitSeq: 2, + recordedAt: 0, + }; +} + +function handshake() { + return { + protocolMajor: 1, + supportedReadSchemas: ["taskflow.wire.v1"], + supportedWriteSchemas: ["taskflow.wire.v1"], + requiredFeatures: [], + offeredFeatures: [], + buildInfo: { packageVersion: "0.3.0", gitCommit: "abc", schemaVersion: 1 }, + }; +} + +function boundPlan() { + return { + schemaVersion: CONTROL_WIRE_SCHEMA_VERSION, + projectId: UUID, + controlDomainId: UUID, + planId: UUID, + bindings: [], + spawnTemplate: { + allowedAgentClasses: ["executor"], + allowedProviderClasses: ["te-resources"], + maxToolCallsPerStep: 10, + maxEffectsPerNode: 5, + maxChildren: 4, + maxDepth: 2, + budgetShare: 0.5, + }, + savedFlowPins: [], + grantRefs: [], + claims: [], + enforcementCapabilities: { + resolution: "contained", + mutationMediation: "brokered", + processIsolation: "none", + revocation: "admission-only", + baselinePolicyId: "taskflow-resolve-only", + hostProbeSha256: SHA256, + }, + dynamicPolicy: { hostCeiling: {}, authorizationContextHash: SHA256 }, + boundPlanHash: "plan:" + SHA256, + }; +} + +function receipt() { + return { + schemaVersion: CONTROL_WIRE_SCHEMA_VERSION, + controlDomainId: UUID, + runId: UUID, + boundPlanHash: "plan:" + SHA256, + eventManifest: [UUID], + manifestRoot: SHA256, + startCommitSeq: 1, + endCommitSeq: 2, + artifactRefs: [], + assurance: { + journalContinuity: true, + providerOutcome: "completed", + artifactIntegrity: "verified", + provenance: { confidentiality: "internal", integrity: "project" }, + enforcement: { + capabilities: { + resolution: "contained", + mutationMediation: "brokered", + processIsolation: "none", + revocation: "admission-only", + baselinePolicyId: "taskflow-resolve-only", + hostProbeSha256: SHA256, + }, + }, + }, + buildInfo: { packageVersion: "0.3.0", gitCommit: "abc", schemaVersion: 1 }, + }; +} diff --git a/packages/taskflow-control/test/singleton.test.ts b/packages/taskflow-control/test/singleton.test.ts new file mode 100644 index 00000000..15ba9048 --- /dev/null +++ b/packages/taskflow-control/test/singleton.test.ts @@ -0,0 +1,242 @@ +import assert from "node:assert/strict"; +import { after, test } from "node:test"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { + acquireUserSingleton, + assertFencing, + readCoordinatorLease, + recoverStaleEndpoint, + releaseUserSingleton, + singletonPaths, + type SingletonAcquireResult, + type SingletonPaths, +} from "../src/singleton.ts"; +import { ControlError } from "../src/errors.ts"; + +const tempRoots: string[] = []; + +function makePaths(): SingletonPaths { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "tf-control-singleton-")); + tempRoots.push(root); + return singletonPaths(root); +} + +after(() => { + for (const root of tempRoots) { + try { fs.rmSync(root, { recursive: true, force: true }); } catch { /* best effort */ } + } +}); + +const DEAD_PID = 2 ** 22 + 7; // guaranteed-unassigned pid range + +function deadOwnerInspector() { + return () => ({ alive: false } as const); +} + +test("singleton: first acquirer wins, second attaches to the winner (D32 — no dual authority)", () => { + const paths = makePaths(); + const first = acquireUserSingleton({ paths, holderId: "holder-a" }); + assert.equal(first.status, "won"); + assert.equal(first.fencingEpoch, 1); + + const second = acquireUserSingleton({ paths, holderId: "holder-b" }); + assert.equal(second.status, "attached"); + assert.equal(second.holderId, "holder-a"); + assert.equal(second.endpoint, paths.endpointPath); + + first.release(); + const third = acquireUserSingleton({ paths, holderId: "holder-c" }); + assert.equal(third.status, "won"); + third.release(); +}); + +test("singleton: release is compare-and-delete — a foreign holder cannot release the winner's lock", () => { + const paths = makePaths(); + const first = acquireUserSingleton({ paths, holderId: "holder-a" }); + assert.equal(first.status, "won"); + const stat = fs.statSync(paths.lockPath, { bigint: true }); + // A stale release with the wrong inode/token must not remove the live lock. + releaseUserSingleton(paths, { holderId: "someone-else", dev: stat.dev + 1n, ino: stat.ino + 1n }); + const second = acquireUserSingleton({ paths, holderId: "holder-b" }); + assert.equal(second.status, "attached", "the live lock must survive a foreign release attempt"); + first.release(); +}); + +test("singleton: malformed lock metadata fails closed (P13)", () => { + const paths = makePaths(); + fs.mkdirSync(paths.controlHome, { recursive: true }); + fs.writeFileSync(paths.lockPath, JSON.stringify({ version: 99, junk: true })); + assert.throws(() => acquireUserSingleton({ paths }), (error: unknown) => { + assert.ok(error instanceof ControlError); + assert.equal((error as ControlError).code, "TF_BOOTSTRAP_FAILED"); + assert.match((error as ControlError).message, /malformed singleton lock record/); + return true; + }); +}); + +test("singleton: stale endpoint recovery removes a dead peer's socket", () => { + const paths = makePaths(); + fs.mkdirSync(paths.controlHome, { recursive: true }); + fs.writeFileSync(paths.endpointPath, "stale"); + // No lock at all → orphaned socket is stale. + assert.equal(recoverStaleEndpoint(paths), true); + assert.equal(fs.existsSync(paths.endpointPath), false); + + // Dead owner + socket → removed. + fs.writeFileSync(paths.endpointPath, "stale-2"); + fs.writeFileSync( + paths.lockPath, + JSON.stringify({ + version: 1, + holderId: "dead-holder", + pid: DEAD_PID, + birthToken: "linux:dead-boot:0", + birthTokenKind: "native", + fencingEpoch: 1, + endpoint: paths.endpointPath, + acquiredAt: 1, + }), + ); + assert.equal(recoverStaleEndpoint(paths, { inspectProcess: deadOwnerInspector() }), true); + assert.equal(fs.existsSync(paths.endpointPath), false); + + // Live owner + socket → untouched (belongs to the winner). + const live = acquireUserSingleton({ paths, holderId: "live-holder" }); + assert.equal(live.status, "won"); + fs.writeFileSync(paths.endpointPath, "live-socket"); + assert.equal(recoverStaleEndpoint(paths), false); + assert.equal(fs.existsSync(paths.endpointPath), true); + live.release(); +}); + +test("singleton: stale endpoint recovery happens before a fresh acquire", () => { + const paths = makePaths(); + // Simulate a crashed previous holder: dead pid lock + orphaned socket. + fs.mkdirSync(paths.controlHome, { recursive: true }); + fs.writeFileSync(paths.endpointPath, "orphan"); + fs.writeFileSync( + paths.lockPath, + JSON.stringify({ + version: 1, + holderId: "crashed", + pid: DEAD_PID, + birthToken: "linux:dead-boot:0", + birthTokenKind: "native", + fencingEpoch: 3, + endpoint: paths.endpointPath, + acquiredAt: 2, + }), + ); + // ControlHost.start() performs recovery before competing for the lock + // (fresh-install contract §5.2(4)) — same sequence the host runs. + const recovered = recoverStaleEndpoint(paths, { inspectProcess: deadOwnerInspector() }); + assert.equal(recovered, true); + assert.equal(fs.existsSync(paths.endpointPath), false); + const result = acquireUserSingleton({ paths, holderId: "fresh", inspectProcess: deadOwnerInspector() }); + assert.equal(result.status, "won"); + // Fencing epoch bumps on reclaim (3 → 4). + assert.equal((result as Extract).fencingEpoch, 4); + result.release(); +}); + +test("singleton: attach-only (coordinated) fails closed when no live control exists", () => { + const paths = makePaths(); + assert.throws( + () => acquireUserSingleton({ paths, attachOnly: true }), + (error: unknown) => { + assert.ok(error instanceof ControlError); + assert.equal((error as ControlError).code, "TF_JOURNAL_UNAVAILABLE"); + return true; + }, + ); +}); + +test("singleton: attach-only attaches when a live winner exists", () => { + const paths = makePaths(); + const winner = acquireUserSingleton({ paths, holderId: "winner" }); + assert.equal(winner.status, "won"); + const attached = acquireUserSingleton({ paths, holderId: "client", attachOnly: true }); + assert.equal(attached.status, "attached"); + assert.equal(attached.holderId, "winner"); + winner.release(); +}); + +test("singleton: attach-only fails closed on a dead winner (does not start a new control)", () => { + const paths = makePaths(); + fs.mkdirSync(paths.controlHome, { recursive: true }); + fs.writeFileSync( + paths.lockPath, + JSON.stringify({ + version: 1, + holderId: "dead-winner", + pid: DEAD_PID, + birthToken: "linux:dead-boot:0", + birthTokenKind: "native", + fencingEpoch: 1, + endpoint: paths.endpointPath, + acquiredAt: 1, + }), + ); + assert.throws( + () => acquireUserSingleton({ paths, attachOnly: true, inspectProcess: deadOwnerInspector() }), + (error: unknown) => { + assert.ok(error instanceof ControlError); + assert.equal((error as ControlError).code, "TF_JOURNAL_UNAVAILABLE"); + return true; + }, + ); +}); + +test("singleton: fencing — a stale epoch is rejected (TF_AUTHORITY_REVOKED)", () => { + const paths = makePaths(); + const first = acquireUserSingleton({ paths, holderId: "holder-a" }); + assert.equal(first.status, "won"); + const lease = readCoordinatorLease(paths); + assert.ok(lease !== null); + assert.equal(lease.fencingEpoch, 1); + // A missing lease is unverifiable → fail closed. + assert.throws(() => assertFencing(null, 1), (error: unknown) => { + assert.ok(error instanceof ControlError); + assert.equal((error as ControlError).code, "TF_AUTHORITY_REVOKED"); + return true; + }); + // Claiming epoch 0 against a lease at epoch 1 is stale. + assert.throws(() => assertFencing(lease, 0), (error: unknown) => { + assert.ok(error instanceof ControlError); + assert.equal((error as ControlError).code, "TF_AUTHORITY_REVOKED"); + return true; + }); + // Epoch 1 (or newer) is accepted. + assertFencing(lease, 1); + first.release(); +}); + +test("singleton: reclaim bumps the fencing epoch so the old holder is fenced out", () => { + const paths = makePaths(); + const first = acquireUserSingleton({ paths, holderId: "holder-a" }); + assert.equal(first.status, "won"); + assert.equal(first.fencingEpoch, 1); + // Simulate holder-a crash: its lock record now references a dead pid. + first.release(); + fs.writeFileSync( + paths.lockPath, + JSON.stringify({ + version: 1, + holderId: "holder-a", + pid: DEAD_PID, + birthToken: "linux:dead-boot:0", + birthTokenKind: "native", + fencingEpoch: 1, + endpoint: paths.endpointPath, + acquiredAt: 1, + }), + ); + const second = acquireUserSingleton({ paths, holderId: "holder-b", inspectProcess: deadOwnerInspector() }); + assert.equal(second.status, "won"); + assert.equal(second.fencingEpoch, 2); + // Old holder's RPC with epoch 1 must now be rejected by the lease gate. + assert.throws(() => assertFencing({ holderId: "holder-b", fencingEpoch: 2, endpoint: paths.endpointPath, expiresAt: 0 }, 1), ControlError); + second.release(); +}); diff --git a/packages/taskflow-control/test/te-provider.test.ts b/packages/taskflow-control/test/te-provider.test.ts new file mode 100644 index 00000000..78bfd0c9 --- /dev/null +++ b/packages/taskflow-control/test/te-provider.test.ts @@ -0,0 +1,186 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + createTeExecutionProvider, + processIsolationFromClassification, + TE_PROVIDER_KIND, + type ExecutionProvider, + type TeExecutionAuthority, +} from "../src/te-provider.ts"; +import { ControlError } from "../src/errors.ts"; + +const CAPABILITIES = { + resolution: "contained" as const, + mutationMediation: "brokered" as const, + revocation: "admission-only" as const, + baselinePolicyId: "taskflow-resolve-only", + hostProbeSha256: "a".repeat(64), +}; + +const fakePlan = { + schemaVersion: 1, + projectId: "00000000-0000-0000-0000-000000000001", + controlDomainId: "00000000-0000-0000-0000-000000000002", + planId: "00000000-0000-0000-0000-000000000003", + bindings: [], + spawnTemplate: { + allowedAgentClasses: ["executor"], + allowedProviderClasses: ["te-resources"], + maxToolCallsPerStep: 10, + maxEffectsPerNode: 5, + maxChildren: 4, + maxDepth: 2, + budgetShare: 0.5, + }, + savedFlowPins: [], + grantRefs: [], + claims: [], + enforcementCapabilities: { ...CAPABILITIES, processIsolation: "none" }, + dynamicPolicy: { hostCeiling: {}, authorizationContextHash: "b".repeat(64) }, + boundPlanHash: "plan:" + "c".repeat(64), +}; + +function fakeTeAuthority(calls: string[] = []): TeExecutionAuthority { + const handle = { + providerJobHandle: "te-job-1", + poll: async () => { + calls.push("poll"); + return { outcome: "accepted" as const, status: "running" as const }; + }, + cancel: async () => { + calls.push("cancel"); + return { outcome: "accepted" as const, cancelled: true as const }; + }, + collect: async () => { + calls.push("collect"); + return { outcome: "accepted" as const, providerJobHandle: "te-job-1" as const }; + }, + reconcile: async () => { + calls.push("reconcile"); + return { outcome: "accepted" as const, providerState: "running" as const }; + }, + }; + return { + assurance: "resolve-only-no-sandbox", + probe: async () => { + calls.push("probe"); + return { classification: "resolve-only" as const, baselinePolicyId: "taskflow-resolve-only", hostProbeSha256: CAPABILITIES.hostProbeSha256 }; + }, + prepare: async () => { + calls.push("prepare"); + return { outcome: "accepted" as const, fulfillment: { preparationId: "prep-1", enforcementCapabilities: { ...CAPABILITIES, processIsolation: "none" } } }; + }, + submit: async () => { + calls.push("submit"); + return handle; + }, + watch: async function* () { + calls.push("watch"); + yield { kind: "terminal" as const, outcome: "completed" as const }; + }, + }; +} + +test("te-provider: probe maps TE resolve-only evidence to the P8 default capability package", async () => { + const calls: string[] = []; + const provider = createTeExecutionProvider(fakeTeAuthority(calls)); + assert.equal(provider.kind, TE_PROVIDER_KIND); + const result = await provider.probe(); + assert.equal(result.outcome, "accepted"); + assert.deepEqual(result.capabilities, { + processIsolation: "none", + resolution: "contained", + mutationMediation: "brokered", + revocation: "admission-only", + baselinePolicyId: "taskflow-resolve-only", + hostProbeSha256: CAPABILITIES.hostProbeSha256, + }); + assert.deepEqual(calls, ["probe"]); +}); + +test("te-provider: unsupported host probe fails closed (P8 D11 — never bare-shell)", () => { + assert.throws(() => processIsolationFromClassification("unsupported"), (error: unknown) => { + assert.ok(error instanceof ControlError); + assert.equal((error as ControlError).code, "TF_FEATURE_REQUIRED"); + return true; + }); + // The adapter propagates the fail-closed decision through probe(). + const te = fakeTeAuthority(); + (te as { probe: () => Promise }).probe = async () => { + throw new ControlError("TF_FEATURE_REQUIRED", "unsupported host probe classification: refusing execution without evidence (P8 D11)", { + recoveryAction: "operator", + sideEffects: "none", + }); + }; + const provider = createTeExecutionProvider(te); + assert.rejects(async () => provider.probe(), (error: unknown) => { + assert.ok(error instanceof ControlError); + assert.equal((error as ControlError).code, "TF_FEATURE_REQUIRED"); + return true; + }); +}); + +test("te-provider: prepare/submit/poll/cancel/collect/reconcile delegate to the TE authority", async () => { + const calls: string[] = []; + const provider = createTeExecutionProvider(fakeTeAuthority(calls)); + + const prepare = await provider.prepare({ plan: fakePlan as never, owner: owner(), controlDomainId: "d" }); + assert.equal(prepare.outcome, "accepted"); + assert.equal((prepare as { fulfillment: { preparationId: string } }).fulfillment.preparationId, "prep-1"); + + const submit = await provider.submit({ preparationId: "prep-1", owner: owner(), controlDomainId: "d" }); + assert.equal(submit.outcome, "accepted"); + assert.equal((submit as { providerJobHandle: string }).providerJobHandle, "te-job-1"); + + const poll = await provider.poll({ providerJobHandle: "te-job-1" }); + assert.equal(poll.outcome, "accepted"); + assert.equal((poll as { status: string }).status, "running"); + + const cancel = await provider.cancel({ providerJobHandle: "te-job-1" }); + assert.equal(cancel.outcome, "accepted"); + assert.equal((cancel as { cancelled: boolean }).cancelled, true); + + const collect = await provider.collect({ providerJobHandle: "te-job-1" }); + assert.equal(collect.outcome, "accepted"); + + const reconcile = await provider.reconcile({ providerJobHandle: "te-job-1" }); + assert.equal(reconcile.outcome, "accepted"); + assert.equal((reconcile as { providerState: string }).providerState, "running"); + + const events: string[] = []; + for await (const event of provider.watch({ providerJobHandle: "te-job-1" })) { + events.push(event.kind); + } + assert.deepEqual(events, ["terminal"]); + // probe is covered by its own test; the rest delegate once each. + assert.deepEqual(calls, ["prepare", "submit", "poll", "cancel", "collect", "reconcile", "watch"]); +}); + +test("te-provider: only TE-shaped authorities can become a provider (fail closed)", () => { + assert.throws( + () => createTeExecutionProvider({ assurance: "custom-sandbox" } as unknown as TeExecutionAuthority), + (error: unknown) => { + assert.ok(error instanceof ControlError); + assert.equal((error as ControlError).code, "TF_AUTHORITY_REVOKED"); + return true; + }, + ); + assert.throws( + () => createTeExecutionProvider({ assurance: "resolve-only-no-sandbox" } as unknown as TeExecutionAuthority), + (error: unknown) => { + assert.ok(error instanceof ControlError); + assert.equal((error as ControlError).code, "TF_AUTHORITY_REVOKED"); + return true; + }, + ); +}); + +test("te-provider: a provider is always marked te-resources", () => { + const provider = createTeExecutionProvider(fakeTeAuthority()); + const typed: ExecutionProvider = provider; + assert.equal(typed.kind, "te-resources"); +}); + +function owner() { + return { runId: "r", phaseId: "p", attemptId: "a", unitId: "u", ancestry: [] }; +} diff --git a/packages/taskflow-control/tsconfig.build.json b/packages/taskflow-control/tsconfig.build.json new file mode 100644 index 00000000..863efaed --- /dev/null +++ b/packages/taskflow-control/tsconfig.build.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "noEmit": false, + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "rewriteRelativeImportExtensions": true + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/taskflow-core/package.json b/packages/taskflow-core/package.json index 14c04ee8..5729e95a 100644 --- a/packages/taskflow-core/package.json +++ b/packages/taskflow-core/package.json @@ -1,6 +1,6 @@ { "name": "taskflow-core", - "version": "0.2.10", + "version": "0.3.0-beta.1", "description": "Host-neutral engine for declarative, verifiable task-DAG orchestration — the runtime, DSL, cache, and verification shared by pi-taskflow, codex-taskflow, claude-taskflow, opencode-taskflow, grok-taskflow, and hermes-taskflow.", "keywords": [ "taskflow", diff --git a/packages/taskflow-core/src/agents.ts b/packages/taskflow-core/src/agents.ts index 30071c6a..da62c56b 100644 --- a/packages/taskflow-core/src/agents.ts +++ b/packages/taskflow-core/src/agents.ts @@ -46,6 +46,7 @@ export interface TaskflowSettings { import { DEFAULT_KEPT_RUNS, DEFAULT_RUN_AGE_DAYS, writeFileAtomic } from "./store.ts"; import { DEFAULT_LIBRARY_SETTINGS, type LibrarySettings } from "./library/types.ts"; +import { findProjectAgentsDir } from "./discovery-boundary.ts"; export const DEFAULT_TASKFLOW_SETTINGS: TaskflowSettings = { builtInAgents: true, @@ -264,14 +265,8 @@ function isDirectory(p: string): boolean { } function findNearestProjectAgentsDir(cwd: string): string | null { - let currentDir = cwd; - while (true) { - const candidate = path.join(currentDir, ".pi", "agents"); - if (isDirectory(candidate)) return candidate; - const parentDir = path.dirname(currentDir); - if (parentDir === currentDir) return null; - currentDir = parentDir; - } + const dir = findProjectAgentsDir(cwd); + return dir && isDirectory(dir) ? dir : null; } export function discoverAgents( diff --git a/packages/taskflow-core/src/atomic-rename.ts b/packages/taskflow-core/src/atomic-rename.ts new file mode 100644 index 00000000..11ea5c00 --- /dev/null +++ b/packages/taskflow-core/src/atomic-rename.ts @@ -0,0 +1,48 @@ +import * as fs from "node:fs"; + +const MAX_ATTEMPTS = 51; +const RETRY_DELAY_MS = 10; +const RETRY_WAIT_BUFFER = new Int32Array(new SharedArrayBuffer(4)); +const WINDOWS_TRANSIENT_RENAME_CODES = new Set(["EPERM", "EACCES", "EBUSY"]); + +export interface AtomicRenameOptions { + platform?: NodeJS.Platform; + maxAttempts?: number; + renameSync?: typeof fs.renameSync; + sleep?: (milliseconds: number) => void; +} + +/** + * Replace a file atomically, retrying only transient Windows sharing violations. + * The hard attempt cap keeps the synchronous loop bounded even if wall time moves + * backward or is frozen. + */ +export function renameAtomicWithRetry( + tmp: string, + filePath: string, + options: AtomicRenameOptions = {}, +): void { + const platform = options.platform ?? process.platform; + const maxAttempts = options.maxAttempts ?? MAX_ATTEMPTS; + if (!Number.isSafeInteger(maxAttempts) || maxAttempts < 1 || maxAttempts > MAX_ATTEMPTS) { + throw new RangeError(`maxAttempts must be an integer from 1 to ${MAX_ATTEMPTS}`); + } + const renameSync = options.renameSync ?? fs.renameSync; + const sleep = options.sleep ?? ((milliseconds: number) => { + Atomics.wait(RETRY_WAIT_BUFFER, 0, 0, milliseconds); + }); + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + renameSync(tmp, filePath); + return; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if ( + platform !== "win32" || !code || + !WINDOWS_TRANSIENT_RENAME_CODES.has(code) || attempt === maxAttempts + ) throw error; + sleep(RETRY_DELAY_MS); + } + } +} diff --git a/packages/taskflow-core/src/discovery-boundary.ts b/packages/taskflow-core/src/discovery-boundary.ts new file mode 100644 index 00000000..85ed3d0a --- /dev/null +++ b/packages/taskflow-core/src/discovery-boundary.ts @@ -0,0 +1,113 @@ +/** + * Shared walk-up discovery boundaries for project-local `.pi` trees. + * + * Never inherit `~/.pi` or the shared OS temp root while climbing ancestors. + * Canonicalize paths so relative cwd / symlink aliases cannot bypass stops. + * Reject a candidate `.pi` that is a symlink escaping the project directory + * (or resolving to the user/temp `.pi` trees). + */ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +export function canonicalDiscoveryPath(input: string): string { + const absolute = path.resolve(input); + try { + return fs.realpathSync.native(absolute); + } catch { + return absolute; + } +} + +export function sameDiscoveryPath(a: string, b: string): boolean { + if (process.platform === "win32") return a.toLowerCase() === b.toLowerCase(); + return a === b; +} + +function isWithinRoot(root: string, candidate: string): boolean { + const rel = path.relative(root, candidate); + return rel === "" || (rel !== ".." && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel)); +} + +export interface ProjectDotPiHit { + /** Absolute path to the accepted `.pi` directory (physical when resolvable). */ + dotPiDir: string; + /** Directory that contained the accepted `.pi` entry (walk stop). */ + projectDir: string; +} + +/** + * Walk from `cwd` toward filesystem root looking for a usable `.pi` directory. + * Stops before home and OS temp roots. Returns null when none found. + */ +export function findProjectDotPiDir(cwd: string): ProjectDotPiHit | null { + const home = canonicalDiscoveryPath(os.homedir()); + const tempRoot = canonicalDiscoveryPath(os.tmpdir()); + const homeDotPi = path.join(home, ".pi"); + const tempDotPi = path.join(tempRoot, ".pi"); + let dir = canonicalDiscoveryPath(cwd); + + while (true) { + if (sameDiscoveryPath(dir, home) || sameDiscoveryPath(dir, tempRoot)) break; + + const candidate = path.join(dir, ".pi"); + if (fs.existsSync(candidate)) { + const accepted = acceptProjectDotPi(candidate, dir, homeDotPi, tempDotPi); + if (accepted) return { dotPiDir: accepted, projectDir: dir }; + } + + const parent = path.dirname(dir); + if (parent === dir) break; + dir = parent; + } + return null; +} + +function acceptProjectDotPi( + candidate: string, + projectDir: string, + homeDotPi: string, + tempDotPi: string, +): string | null { + let physical: string; + try { + const st = fs.lstatSync(candidate); + if (st.isSymbolicLink()) { + physical = fs.realpathSync(candidate); + } else if (st.isDirectory()) { + try { + physical = fs.realpathSync.native(candidate); + } catch { + physical = path.resolve(candidate); + } + } else { + return null; + } + } catch { + return null; + } + + // Never treat the user/temp convention trees as a project marker via symlink. + if (sameDiscoveryPath(physical, homeDotPi) || sameDiscoveryPath(physical, tempDotPi)) return null; + // Symlink (or mount) must stay inside the project directory that owns the marker. + if (!isWithinRoot(projectDir, physical)) return null; + return physical; +} + +/** Project-scope `taskflows` dir, or null. */ +export function findProjectTaskflowsDir(cwd: string): string | null { + const hit = findProjectDotPiDir(cwd); + return hit ? path.join(hit.dotPiDir, "taskflows") : null; +} + +/** Project-scope `taskflows/verifiers` dir, or null. */ +export function findProjectVerifiersDir(cwd: string): string | null { + const base = findProjectTaskflowsDir(cwd); + return base ? path.join(base, "verifiers") : null; +} + +/** Project-scope `agents` dir, or null. */ +export function findProjectAgentsDir(cwd: string): string | null { + const hit = findProjectDotPiDir(cwd); + return hit ? path.join(hit.dotPiDir, "agents") : null; +} diff --git a/packages/taskflow-core/src/effects/index.ts b/packages/taskflow-core/src/effects/index.ts new file mode 100644 index 00000000..747cff16 --- /dev/null +++ b/packages/taskflow-core/src/effects/index.ts @@ -0,0 +1,9 @@ +/** + * Trusted Effects (0.3 MVP) public surface. + * @see docs/internal/0.3.0-trusted-effects-mvp.md + */ + +export * from "./types.ts"; +export * from "./validate.ts"; +export * from "./runtime-apply.ts"; +export * from "./why.ts"; diff --git a/packages/taskflow-core/src/effects/runtime-apply.ts b/packages/taskflow-core/src/effects/runtime-apply.ts new file mode 100644 index 00000000..2e3b4dfd --- /dev/null +++ b/packages/taskflow-core/src/effects/runtime-apply.ts @@ -0,0 +1,263 @@ +/** + * Runtime bridge from declared `fs.write` effects to the existing resource + * control plane. Admission happens before the phase body and finalization uses + * the same PathRef resolutions, persistent leases, durable journal intent, and + * mutation permit. No effects-layer commit authority exists here. + */ + +import type { ResolveOnlyPhaseBinding } from "../resources/execution.ts"; +import type { + FileTransactionResult, + PreparedResourceFileTransaction, +} from "../resources/file-transaction.ts"; +import type { PathRef } from "../resources/schema.ts"; +import type { EffectDecl } from "./types.ts"; +import { pathRefRelativeKey, pathsOverlap, validateEffectIR } from "./validate.ts"; + +export interface DeclaredFsWrite { + effectId: string; + relativePath: string; + content: string | Buffer; +} + +export type CollectDeclaredFsWritesResult = + | { ok: true; writes: DeclaredFsWrite[] } + | { ok: false; reason: string; code: string }; + +export interface PreparedDeclaredFsWrites { + transaction: PreparedResourceFileTransaction; + effects: unknown; +} + +export type PrepareDeclaredFsWritesResult = + | { ok: true; prepared?: PreparedDeclaredFsWrites } + | { ok: false; reason: string; code: string }; + +export type FinalizePreparedDeclaredFsWritesResult = + | { ok: true; intentId?: string; committedPaths: string[]; commitGeneration?: number } + | { + ok: false; + intentId?: string; + reason: string; + code: string; + restored: boolean; + }; + +export function precheckDeclaredFsWriteOverlap( + writes: readonly DeclaredFsWrite[], +): { ok: true } | { ok: false; reason: string; pathA: string; pathB: string; effectIdA: string; effectIdB: string } { + for (let i = 0; i < writes.length; i++) { + const a = writes[i]!; + for (let j = i + 1; j < writes.length; j++) { + const b = writes[j]!; + if (pathsOverlap(a.relativePath, b.relativePath)) { + return { + ok: false, + reason: + `mutating declared writes '${a.effectId}' and '${b.effectId}' overlap on path ` + + `('${a.relativePath}' vs '${b.relativePath}')`, + pathA: a.relativePath, + pathB: b.relativePath, + effectIdA: a.effectId, + effectIdB: b.effectId, + }; + } + } + } + return { ok: true }; +} + +export function hasDeclaredFsWriteEffects(effects: unknown): boolean { + return Array.isArray(effects) && effects.some((effect) => + effect !== null && typeof effect === "object" && (effect as { kind?: unknown }).kind === "fs.write"); +} + +export function validateDeclaredEffectsBeforeAdmission(effects: unknown): CollectDeclaredFsWritesResult { + if (!Array.isArray(effects) || effects.length === 0) return { ok: true, writes: [] }; + const validation = validateEffectIR({ effects: effects as EffectDecl[] }); + if (!validation.ok) { + return { + ok: false, + code: "effectir-invalid", + reason: validation.issues + .filter((issue) => issue.severity === "error") + .map((issue) => issue.message) + .join("; ") || "EffectIR validation failed", + }; + } + for (const effect of effects as EffectDecl[]) { + if (effect.kind !== "fs.write") { + return { + ok: false, + code: "unsupported-effect-kind", + reason: `effect '${effect.id}': ${effect.kind} has no bound resource backend in the 0.3 fs.write slice`, + }; + } + } + return { ok: true, writes: [] }; +} + +export async function preparePhaseDeclaredFsWrites( + binding: ResolveOnlyPhaseBinding, + opts: { effects: unknown; signal?: AbortSignal }, +): Promise { + const valid = validateDeclaredEffectsBeforeAdmission(opts.effects); + if (!valid.ok) return valid; + if (!hasDeclaredFsWriteEffects(opts.effects)) return { ok: true }; + const effects = opts.effects as EffectDecl[]; + const targets: Array<{ effectId: string; path: PathRef }> = []; + for (const effect of effects) { + if (effect.kind !== "fs.write") continue; + if (effect.target.kind !== "path") { + return { + ok: false, + code: "invalid-path-ref", + reason: `effect '${effect.id}': fs.write requires a path target`, + }; + } + targets.push({ effectId: effect.id, path: effect.target.path }); + } + try { + const transaction = await binding.beginFileWriteTransaction(targets, { + unitId: binding.phaseId, + signal: opts.signal, + }); + return { ok: true, prepared: { transaction, effects: opts.effects } }; + } catch (error) { + return { + ok: false, + code: "resource-admission-failed", + reason: error instanceof Error ? error.message : String(error), + }; + } +} + +function fromFileTransactionResult(result: FileTransactionResult): FinalizePreparedDeclaredFsWritesResult { + return result.ok + ? { + ok: true, + intentId: result.intentId, + committedPaths: result.committedPaths, + commitGeneration: result.commitGeneration, + } + : { + ok: false, + intentId: result.intentId, + code: result.code, + reason: result.reason, + restored: result.restored, + }; +} + +export async function finalizePreparedDeclaredFsWrites( + prepared: PreparedDeclaredFsWrites | undefined, + phaseOutput: string, +): Promise { + if (!prepared) return { ok: true, committedPaths: [] }; + const collected = declaredFsWritesFromPhaseOutput(prepared.effects, phaseOutput); + if (!collected.ok) { + const rejected = await prepared.transaction.reject(collected.reason); + return { + ok: false, + intentId: rejected.intentId, + code: collected.code, + reason: collected.reason, + restored: !rejected.ok && rejected.restored, + }; + } + return fromFileTransactionResult(await prepared.transaction.commit(collected.writes)); +} + +export async function rejectPreparedDeclaredFsWrites( + prepared: PreparedDeclaredFsWrites | undefined, + reason: string, +): Promise { + if (!prepared) return { ok: true, committedPaths: [] }; + return fromFileTransactionResult(await prepared.transaction.reject(reason)); +} + +/** Resolve phase output into the exact payload set admitted before execution. */ +export function declaredFsWritesFromPhaseOutput( + effects: unknown, + phaseOutput: string, +): CollectDeclaredFsWritesResult { + if (effects === undefined || effects === null) return { ok: true, writes: [] }; + if (!Array.isArray(effects)) { + return { ok: false, code: "effects-not-array", reason: "phase.effects must be an array" }; + } + if (effects.length === 0) return { ok: true, writes: [] }; + + const declarations: Array<{ effectId: string; relativePath: string }> = []; + for (const raw of effects) { + if (!raw || typeof raw !== "object") { + return { ok: false, code: "invalid-effect", reason: "each effect must be an object" }; + } + const effect = raw as Record; + const effectId = typeof effect.id === "string" && effect.id.length > 0 ? effect.id : ""; + if (!effectId) return { ok: false, code: "invalid-effect", reason: "effect requires non-empty id" }; + if (effect.kind === "fs.delete") { + return { + ok: false, + code: "unsupported-effect-kind", + reason: `effect '${effectId}': fs.delete is not supported by the file transaction`, + }; + } + if (effect.kind !== "fs.write") continue; + const target = effect.target; + if (!target || typeof target !== "object" || (target as { kind?: unknown }).kind !== "path") { + return { ok: false, code: "invalid-path-ref", reason: `effect '${effectId}': fs.write requires a path target` }; + } + const pathRef = (target as { path?: unknown }).path; + if (!pathRef || typeof pathRef !== "object") { + return { ok: false, code: "invalid-path-ref", reason: `effect '${effectId}': fs.write path target requires PathRef` }; + } + // Authority/path resolution already happened before the body through the + // resource binding. Payload mapping needs only the stable effect id; retain + // a diagnostic label for dynamic PathRefs instead of re-resolving them here. + const relativePath = pathRefRelativeKey(pathRef as PathRef) ?? ``; + if (relativePath === "") { + return { ok: false, code: "path-too-broad", reason: `effect '${effectId}': cannot write whole workspace root` }; + } + declarations.push({ effectId, relativePath }); + } + if (declarations.length === 0) return { ok: true, writes: [] }; + if (declarations.length === 1) { + const declaration = declarations[0]!; + return { + ok: true, + writes: [{ effectId: declaration.effectId, relativePath: declaration.relativePath, content: phaseOutput }], + }; + } + + let parsed: unknown; + try { + parsed = JSON.parse(phaseOutput); + } catch { + return { + ok: false, + code: "content-resolution-failed", + reason: "multiple fs.write effects require a JSON object mapping effect id to string content", + }; + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return { + ok: false, + code: "content-resolution-failed", + reason: "multiple fs.write effects require a JSON object mapping effect id to string content", + }; + } + const map = parsed as Record; + const writes: DeclaredFsWrite[] = []; + for (const declaration of declarations) { + const content = map[declaration.effectId]; + if (typeof content !== "string") { + return { + ok: false, + code: "content-resolution-failed", + reason: `missing string content for effect '${declaration.effectId}' in phase JSON output`, + }; + } + writes.push({ ...declaration, content }); + } + return { ok: true, writes }; +} diff --git a/packages/taskflow-core/src/effects/schema.ts b/packages/taskflow-core/src/effects/schema.ts new file mode 100644 index 00000000..1423f1e0 --- /dev/null +++ b/packages/taskflow-core/src/effects/schema.ts @@ -0,0 +1,82 @@ +/** Closed TypeBox contract for EffectIR. Runtime authority still lives in resources/*. */ + +import { Type } from "typebox"; +import { PathRefSchema } from "../resources/schema.ts"; +import { StringEnum } from "../typebox-helpers.ts"; +import { + CONFIDENTIALITY_LABELS, + EFFECT_KINDS, + INTEGRITY_LABELS, + type EffectDecl, +} from "./types.ts"; + +const ConfidentialitySchema = StringEnum(CONFIDENTIALITY_LABELS); +const IntegritySchema = StringEnum(INTEGRITY_LABELS); + +const common = { + id: Type.String({ minLength: 1 }), + confidentiality: Type.Optional(ConfidentialitySchema), + integrity: Type.Optional(IntegritySchema), + purpose: Type.Optional(Type.String()), +}; + +const PathTargetSchema = Type.Object( + { kind: Type.Literal("path"), path: PathRefSchema }, + { additionalProperties: false }, +); + +const SecretTargetSchema = Type.Object( + { + kind: Type.Literal("secret"), + secret: Type.Object( + { + secretId: Type.String({ minLength: 1 }), + issuer: Type.Optional(Type.String({ minLength: 1 })), + }, + { additionalProperties: false }, + ), + }, + { additionalProperties: false }, +); + +const ServiceTargetSchema = Type.Object( + { + kind: Type.Literal("service"), + service: Type.Object( + { + serviceId: Type.String({ minLength: 1 }), + operation: Type.Optional(Type.String({ minLength: 1 })), + }, + { additionalProperties: false }, + ), + }, + { additionalProperties: false }, +); + +const fsKinds = EFFECT_KINDS.filter((kind) => kind.startsWith("fs.")); + +const ClosedEffectDeclSchema = Type.Union([ + ...fsKinds.map((kind) => Type.Object( + { ...common, kind: Type.Literal(kind), target: PathTargetSchema }, + { additionalProperties: false }, + )), + Type.Object( + { ...common, kind: Type.Literal("secret.read"), target: SecretTargetSchema }, + { additionalProperties: false }, + ), + Type.Object( + { ...common, kind: Type.Literal("service.call"), target: ServiceTargetSchema }, + { additionalProperties: false }, + ), +]); + +// PathRefSchema deliberately uses optional Never fields to encode XOR, which +// TypeBox validates correctly at runtime but currently infers as `never` when +// nested through a union. Preserve the closed runtime schema while exposing +// the independently reviewed EffectDecl TypeScript contract. +export const EffectDeclSchema = Type.Unsafe(ClosedEffectDeclSchema); + +export const EffectIRSchema = Type.Object( + { effects: Type.Array(EffectDeclSchema) }, + { additionalProperties: false }, +); diff --git a/packages/taskflow-core/src/effects/types.ts b/packages/taskflow-core/src/effects/types.ts new file mode 100644 index 00000000..9e705b80 --- /dev/null +++ b/packages/taskflow-core/src/effects/types.ts @@ -0,0 +1,130 @@ +/** + * Trusted Effects (0.3 MVP) — pure type contract. + * + * EffectIR is the closed vocabulary of side effects a FlowIR node may declare. + * PathRef is reused from resources/*; SecretRef/ServiceRef are typed handles + * that fail closed until a real backend is bound (MVP: type + validation only). + * + * @see docs/internal/0.3.0-trusted-effects-mvp.md + */ + +import type { PathRef } from "../resources/schema.ts"; + +// --------------------------------------------------------------------------- +// Information-flow labels (MVP fixed lattice) +// --------------------------------------------------------------------------- + +/** Confidentiality lattice (low → high). Higher may not flow to lower sinks. */ +export const CONFIDENTIALITY_LABELS = ["public", "internal", "secret"] as const; +export type ConfidentialityLabel = (typeof CONFIDENTIALITY_LABELS)[number]; + +/** Integrity lattice (low → high). Lower integrity must not overwrite higher. */ +export const INTEGRITY_LABELS = ["untrusted", "project", "verified"] as const; +export type IntegrityLabel = (typeof INTEGRITY_LABELS)[number]; + +export const CONFIDENTIALITY_RANK: Record = { + public: 0, + internal: 1, + secret: 2, +}; + +export const INTEGRITY_RANK: Record = { + untrusted: 0, + project: 1, + verified: 2, +}; + +// --------------------------------------------------------------------------- +// Refs +// --------------------------------------------------------------------------- + +/** + * Opaque secret handle — never carries secret material. + * MVP: validation only; no vault backend. + */ +export interface SecretRef { + secretId: string; + issuer?: string; +} + +/** + * External service endpoint handle — no ambient network authority from strings. + * MVP: validation only; no live adapter. + */ +export interface ServiceRef { + serviceId: string; + /** Optional logical operation name (e.g. "createIssue"). */ + operation?: string; +} + +export type EffectTarget = + | { kind: "path"; path: PathRef } + | { kind: "secret"; secret: SecretRef } + | { kind: "service"; service: ServiceRef }; + +// --------------------------------------------------------------------------- +// Effect kinds (closed set for MVP) +// --------------------------------------------------------------------------- + +export const EFFECT_KINDS = [ + "fs.read", + "fs.write", + "fs.delete", + "secret.read", + "service.call", +] as const; +export type EffectKind = (typeof EFFECT_KINDS)[number]; + +/** + * One declared side effect on a FlowIR / phase node. + * `id` is stable within the flow for why-effect attribution. + */ +export interface EffectDecl { + id: string; + kind: EffectKind; + target: EffectTarget; + confidentiality?: ConfidentialityLabel; + integrity?: IntegrityLabel; + /** Free-text purpose for why-* explainers (not authority). */ + purpose?: string; +} + +/** EffectIR: bag of effects attached to a node or whole flow. */ +export interface EffectIR { + effects: EffectDecl[]; +} + +// --------------------------------------------------------------------------- +// why-* records +// --------------------------------------------------------------------------- + +export interface WhyAuthorized { + effectId: string; + allowed: boolean; + principalId?: string; + capabilityBindingIds: string[]; + reasons: string[]; +} + +export interface WhyContext { + effectId: string; + runId: string; + phaseId?: string; + confidentiality: ConfidentialityLabel; + integrity: IntegrityLabel; + workspaceRoot?: string; + reasons: string[]; +} + +export interface WhyEffect { + effectId: string; + kind: EffectKind; + targetSummary: string; + purpose?: string; + intentId?: string; + journalStatus?: string; + status: "declared" | "staged" | "committed" | "rejected" | "unknown" | "skipped"; + reasons: string[]; + authorized: WhyAuthorized; + context: WhyContext; +} diff --git a/packages/taskflow-core/src/effects/validate.ts b/packages/taskflow-core/src/effects/validate.ts new file mode 100644 index 00000000..63e62a7a --- /dev/null +++ b/packages/taskflow-core/src/effects/validate.ts @@ -0,0 +1,648 @@ +/** + * Static EffectIR validation: kinds, refs, labels, mutating-path overlap. + * Pure — no I/O. + */ + +import { normalizePortableRelativePath, type PathRef } from "../resources/schema.ts"; +import { Value } from "typebox/value"; +import { EffectDeclSchema } from "./schema.ts"; +import { + CONFIDENTIALITY_LABELS, + CONFIDENTIALITY_RANK, + EFFECT_KINDS, + INTEGRITY_LABELS, + INTEGRITY_RANK, + type ConfidentialityLabel, + type EffectDecl, + type EffectIR, + type EffectKind, + type IntegrityLabel, + type SecretRef, + type ServiceRef, +} from "./types.ts"; + +export interface EffectValidationIssue { + severity: "error" | "warning"; + code: string; + message: string; + effectId?: string; + pathA?: string; + pathB?: string; +} + +export interface EffectValidationResult { + ok: boolean; + issues: EffectValidationIssue[]; +} + +function isObject(v: unknown): v is Record { + return typeof v === "object" && v !== null && !Array.isArray(v); +} + +function isConf(v: unknown): v is ConfidentialityLabel { + return typeof v === "string" && (CONFIDENTIALITY_LABELS as readonly string[]).includes(v); +} + +function isInteg(v: unknown): v is IntegrityLabel { + return typeof v === "string" && (INTEGRITY_LABELS as readonly string[]).includes(v); +} + +function isEffectKind(v: unknown): v is EffectKind { + return typeof v === "string" && (EFFECT_KINDS as readonly string[]).includes(v); +} + +type ValidEffectRecord = Record & { id: string; kind: EffectKind }; + +const SOURCE_KINDS: ReadonlySet = new Set(["fs.read", "secret.read"]); +const SINK_KINDS: ReadonlySet = new Set(["fs.write", "service.call"]); + +function confidentialityOf(item: ValidEffectRecord): ConfidentialityLabel { + return isConf(item.confidentiality) ? item.confidentiality : item.kind === "secret.read" ? "secret" : "internal"; +} + +function integrityOf(item: ValidEffectRecord): IntegrityLabel { + return isInteg(item.integrity) ? item.integrity : "project"; +} + +function labelFlowIssues( + source: ValidEffectRecord, + sink: ValidEffectRecord, + sourceLabel = source.id, + sinkLabel = sink.id, +): EffectValidationIssue[] { + const issues: EffectValidationIssue[] = []; + // Two unresolved composition boundaries carry taint but do not create a + // concrete source→sink edge by themselves. Keep the taint flowing until it + // meets a declared effect; otherwise ordinary dynamic composition would + // reject solely because two unknown summaries are sequenced. + if (source.__unknownBoundary === true && sink.__unknownBoundary === true) return issues; + // An unresolved saved `flow{use}` boundary seen by a static gate without a + // flow loader is advisory (the saved child may be perfectly benign); the + // runtime loader stays the authoritative admission gate. Dynamic inline + // `def` boundaries never carry this flag and stay hard-tainted. + const advisory = source.__unresolvedUseAdvisory === true || sink.__unresolvedUseAdvisory === true; + const code = (base: string): string => (advisory ? "unresolved-flow-use-taint" : base); + const suffix = advisory ? " — unresolved flow{use} boundary; advisory only (runtime admission is authoritative)" : ""; + const sourceConf = confidentialityOf(source); + const sinkConf = confidentialityOf(sink); + const sourceIntegrity = integrityOf(source); + const sinkIntegrity = integrityOf(sink); + if (CONFIDENTIALITY_RANK[sourceConf] > CONFIDENTIALITY_RANK[sinkConf]) { + issues.push({ + severity: advisory ? "warning" : "error", + code: code("confidentiality-flow-violation"), + message: `source effect '${sourceLabel}' (${sourceConf}) cannot flow to sink '${sinkLabel}' (${sinkConf})${suffix}`, + effectId: sinkLabel, + }); + } + if (INTEGRITY_RANK[sourceIntegrity] < INTEGRITY_RANK[sinkIntegrity]) { + issues.push({ + severity: advisory ? "warning" : "error", + code: code("integrity-flow-violation"), + message: `source effect '${sourceLabel}' (${sourceIntegrity}) cannot satisfy sink '${sinkLabel}' integrity (${sinkIntegrity})${suffix}`, + effectId: sinkLabel, + }); + } + return issues; +} + +/** Extract portable relative path string from a PathRef for overlap checks. */ +export function pathRefRelativeKey(pathRef: PathRef): string | undefined { + const sub = pathRef.subpath; + if (!sub) return ""; + if ("literalPath" in sub && typeof sub.literalPath === "string") { + const n = normalizePortableRelativePath(sub.literalPath); + return n.ok ? n.value : undefined; + } + // Dynamic arg/segments paths cannot be fully resolved statically. + return undefined; +} + +/** + * True if path A is the same as, a parent of, or a child of path B + * (portable `/` paths, no `..`). + */ +export function pathsOverlap(a: string, b: string): boolean { + if (a === b) return true; + if (a === "" || b === "") return true; // whole workspace + const ap = a.endsWith("/") ? a.slice(0, -1) : a; + const bp = b.endsWith("/") ? b.slice(0, -1) : b; + return ap.startsWith(bp + "/") || bp.startsWith(ap + "/"); +} + +function validateSecretRef(s: unknown, effectId: string, issues: EffectValidationIssue[]): void { + if (!isObject(s) || typeof s.secretId !== "string" || !s.secretId.trim()) { + issues.push({ + severity: "error", + code: "invalid-secret-ref", + message: `effect '${effectId}': secret target requires non-empty secretId`, + effectId, + }); + return; + } + if ("value" in s || "material" in s || "token" in s) { + issues.push({ + severity: "error", + code: "secret-material-forbidden", + message: `effect '${effectId}': SecretRef must not carry secret material fields`, + effectId, + }); + } +} + +function validateServiceRef(s: unknown, effectId: string, issues: EffectValidationIssue[]): void { + if (!isObject(s) || typeof s.serviceId !== "string" || !s.serviceId.trim()) { + issues.push({ + severity: "error", + code: "invalid-service-ref", + message: `effect '${effectId}': service target requires non-empty serviceId`, + effectId, + }); + } +} + +function validatePathTarget(pathRef: unknown, effectId: string, issues: EffectValidationIssue[]): PathRef | undefined { + if (!isObject(pathRef)) { + issues.push({ + severity: "error", + code: "invalid-path-ref", + message: `effect '${effectId}': path target must be a PathRef object`, + effectId, + }); + return undefined; + } + const hasWorkspace = typeof pathRef.workspace === "string" && pathRef.workspace.length > 0; + const hasHandle = isObject(pathRef.handle); + if (hasWorkspace === hasHandle) { + issues.push({ + severity: "error", + code: "invalid-path-ref", + message: `effect '${effectId}': PathRef needs exactly one of workspace or handle`, + effectId, + }); + return undefined; + } + if (!pathRef.intent || typeof pathRef.intent !== "string") { + issues.push({ + severity: "error", + code: "invalid-path-ref", + message: `effect '${effectId}': PathRef.intent is required`, + effectId, + }); + return undefined; + } + return pathRef as unknown as PathRef; +} + +const MUTATING_KINDS: ReadonlySet = new Set(["fs.write", "fs.delete"]); + +/** + * Validate an EffectIR bag: shape, labels, target/kind consistency, overlap. + */ +export function validateEffectIR(ir: EffectIR | { effects?: unknown }): EffectValidationResult { + const issues: EffectValidationIssue[] = []; + const raw = ir?.effects; + if (raw === undefined) return { ok: true, issues: [] }; + if (!Array.isArray(raw)) { + return { + ok: false, + issues: [{ severity: "error", code: "effects-not-array", message: "effects must be an array" }], + }; + } + + const seenIds = new Set(); + const mutating: Array<{ effectId: string; pathKey: string }> = []; + + for (const item of raw) { + if (!isObject(item)) { + issues.push({ severity: "error", code: "effect-not-object", message: "each effect must be an object" }); + continue; + } + if (!Value.Check(EffectDeclSchema, item)) { + issues.push({ + severity: "error", + code: "invalid-effect-shape", + message: `effect '${typeof item.id === "string" ? item.id : ""}': declaration is outside the closed EffectIR schema`, + effectId: typeof item.id === "string" ? item.id : undefined, + }); + } + const id = item.id; + if (typeof id !== "string" || !id.trim()) { + issues.push({ severity: "error", code: "effect-id-required", message: "effect.id is required" }); + continue; + } + if (seenIds.has(id)) { + issues.push({ + severity: "error", + code: "duplicate-effect-id", + message: `duplicate effect id '${id}'`, + effectId: id, + }); + } + seenIds.add(id); + + if (!isEffectKind(item.kind)) { + issues.push({ + severity: "error", + code: "unknown-effect-kind", + message: `effect '${id}': unknown kind ${String(item.kind)}`, + effectId: id, + }); + continue; + } + const kind = item.kind; + + if (item.confidentiality !== undefined && !isConf(item.confidentiality)) { + issues.push({ + severity: "error", + code: "invalid-confidentiality", + message: `effect '${id}': invalid confidentiality label`, + effectId: id, + }); + } + if (item.integrity !== undefined && !isInteg(item.integrity)) { + issues.push({ + severity: "error", + code: "invalid-integrity", + message: `effect '${id}': invalid integrity label`, + effectId: id, + }); + } + + const target = item.target; + if (!isObject(target) || typeof target.kind !== "string") { + issues.push({ + severity: "error", + code: "invalid-target", + message: `effect '${id}': target.kind is required`, + effectId: id, + }); + continue; + } + + if (kind.startsWith("fs.")) { + if (target.kind !== "path") { + issues.push({ + severity: "error", + code: "target-kind-mismatch", + message: `effect '${id}': fs.* requires target.kind === "path"`, + effectId: id, + }); + } else { + const pref = validatePathTarget(target.path, id, issues); + if (pref && MUTATING_KINDS.has(kind)) { + const key = pathRefRelativeKey(pref); + if (key === undefined) { + issues.push({ + severity: "warning", + code: "dynamic-path-overlap-unknown", + message: `effect '${id}': mutating path is dynamic; overlap cannot be proven statically`, + effectId: id, + }); + } else { + mutating.push({ effectId: id, pathKey: key }); + } + } + } + } else if (kind === "secret.read") { + if (target.kind !== "secret") { + issues.push({ + severity: "error", + code: "target-kind-mismatch", + message: `effect '${id}': secret.read requires target.kind === "secret"`, + effectId: id, + }); + } else { + validateSecretRef(target.secret, id, issues); + } + } else if (kind === "service.call") { + if (target.kind !== "service") { + issues.push({ + severity: "error", + code: "target-kind-mismatch", + message: `effect '${id}': service.call requires target.kind === "service"`, + effectId: id, + }); + } else { + validateServiceRef(target.service, id, issues); + } + } + } + + // Mutating path overlap (static) + for (let i = 0; i < mutating.length; i++) { + for (let j = i + 1; j < mutating.length; j++) { + const a = mutating[i]!; + const b = mutating[j]!; + if (pathsOverlap(a.pathKey, b.pathKey)) { + issues.push({ + severity: "error", + code: "mutating-path-overlap", + message: + `mutating effects '${a.effectId}' and '${b.effectId}' overlap on path ` + + `('${a.pathKey || ""}' vs '${b.pathKey || ""}')`, + effectId: a.effectId, + pathA: a.pathKey, + pathB: b.pathKey, + }); + } + } + } + + // Conservative information-flow check. Read declarations are sources; + // writes/calls are sinks. Without an explicit data-flow subgraph, every + // declared source may influence every declared sink in the same phase. + // Confidentiality may only flow upward (sink clearance >= source label), + // while integrity may only flow downward (source trust >= sink requirement). + const valid = raw.filter((item): item is ValidEffectRecord => + isObject(item) && typeof item.id === "string" && isEffectKind(item.kind)); + const sources = valid.filter((item) => SOURCE_KINDS.has(item.kind)); + const sinks = valid.filter((item) => SINK_KINDS.has(item.kind)); + for (const source of sources) { + for (const sink of sinks) { + issues.push(...labelFlowIssues(source, sink)); + } + } + + const ok = !issues.some((i) => i.severity === "error"); + return { ok, issues }; +} + +export interface EffectFlowPhaseLike { + id?: unknown; + effects?: unknown; + dependsOn?: unknown; + from?: unknown; + type?: unknown; + def?: unknown; + use?: unknown; + final?: unknown; +} + +export interface ComposedEffectFlowLike { + name?: unknown; + phases?: readonly EffectFlowPhaseLike[]; +} + +interface LabeledEffect { + label: string; + effect: ValidEffectRecord; +} + +interface PhaseEffectSummary { + sources: LabeledEffect[]; + sinks: LabeledEffect[]; +} + +export interface ComposedEffectFlowOptions { + resolveFlow?: (name: string) => ComposedEffectFlowLike | undefined; + /** + * Static gates (validateTaskflow / verifyTaskflow / FlowIR translate+compile) + * run without a flow store, so a `flow{use: }` child they cannot load + * degrades to the unknown-boundary summary (secret source + public sink). + * Legal saved-subflow compositions — e.g. a benign child followed by a + * declared write — then hard-fail even though the runtime, which resolves + * the name through `loadFlow`, accepts them. When this option is set, an + * unresolved `use` still emits the conservative unknown-boundary taint but + * reports it as advisory warnings (codes `unresolved-flow-use` / + * `unresolved-flow-use-taint`) instead of errors. Runtime admission (with + * `resolveFlow`) remains the authoritative fail-closed gate for real + * violations. Dynamic inline `def` boundaries are unaffected — they stay + * hard-tainted by design. + */ + downgradeUnresolvedUse?: boolean; +} + +function directPhaseSummary(phase: EffectFlowPhaseLike, phaseLabel: string): PhaseEffectSummary { + const sources: LabeledEffect[] = []; + const sinks: LabeledEffect[] = []; + for (const raw of Array.isArray(phase.effects) ? phase.effects : []) { + if (!isObject(raw) || typeof raw.id !== "string" || !isEffectKind(raw.kind)) continue; + const effect = raw as ValidEffectRecord; + const labeled = { label: `${phaseLabel}/${effect.id}`, effect }; + if (SOURCE_KINDS.has(effect.kind)) sources.push(labeled); + if (SINK_KINDS.has(effect.kind)) sinks.push(labeled); + } + return { sources, sinks }; +} + +function unknownBoundarySummary(phaseLabel: string, advisory = false): PhaseEffectSummary { + const advisoryFlag = advisory ? { __unresolvedUseAdvisory: true } : {}; + return { + sources: [{ + label: `${phaseLabel}/`, + effect: { + id: "", + kind: "secret.read", + confidentiality: "secret", + integrity: "untrusted", + __unknownBoundary: true, + ...advisoryFlag, + target: { kind: "secret", secret: { secretId: "" } }, + }, + }], + sinks: [{ + label: `${phaseLabel}/`, + effect: { + id: "", + kind: "service.call", + confidentiality: "public", + integrity: "verified", + __unknownBoundary: true, + ...advisoryFlag, + target: { kind: "service", service: { serviceId: "" } }, + }, + }], + }; +} + +function parseInlineFlow(raw: unknown): ComposedEffectFlowLike | undefined { + let value = raw; + if (typeof value === "string") { + try { + value = JSON.parse(value) as unknown; + } catch { + return undefined; + } + } + if (Array.isArray(value)) return { phases: value as EffectFlowPhaseLike[] }; + if (!isObject(value) || !Array.isArray(value.phases)) return undefined; + return { name: value.name, phases: value.phases as EffectFlowPhaseLike[] }; +} + +/** + * Conservative DAG-wide label flow. Every source reachable through a phase's + * dependencies may influence that phase's sinks, including through unlabeled + * intermediate phases. This is a pure static check; conditional branches are + * intentionally not used to declassify data. + */ +export function validateEffectFlow( + phases: readonly EffectFlowPhaseLike[], + dependencyIds: (phase: EffectFlowPhaseLike) => readonly string[] = (phase) => [ + ...(Array.isArray(phase.dependsOn) ? phase.dependsOn.filter((id): id is string => typeof id === "string") : []), + ...(Array.isArray(phase.from) ? phase.from.filter((id): id is string => typeof id === "string") : []), + ], + phaseSummaries?: ReadonlyMap, +): EffectValidationResult { + const issues: EffectValidationIssue[] = []; + const byId = new Map(); + const ownSources = new Map>(); + const sinks = new Map(); + for (const phase of phases) { + if (typeof phase.id !== "string" || !phase.id) continue; + byId.set(phase.id, phase); + const summary = phaseSummaries?.get(phase.id) ?? directPhaseSummary(phase, phase.id); + ownSources.set(phase.id, new Map(summary.sources.map(({ label, effect }) => [label, effect]))); + sinks.set(phase.id, summary.sinks); + } + const reachable = new Map([...ownSources].map(([id, sources]) => [id, new Map(sources)])); + for (let pass = 0; pass < byId.size; pass++) { + let changed = false; + for (const [phaseId, phase] of byId) { + const target = reachable.get(phaseId)!; + for (const depId of dependencyIds(phase)) { + for (const [sourceId, source] of reachable.get(depId) ?? []) { + if (!target.has(sourceId)) { + target.set(sourceId, source); + changed = true; + } + } + } + } + if (!changed) break; + } + for (const phaseId of byId.keys()) { + const ownSourceIds = new Set(ownSources.get(phaseId)?.keys() ?? []); + const upstreamSources = [...(reachable.get(phaseId) ?? [])] + .filter(([sourceId]) => !ownSourceIds.has(sourceId)); + if (upstreamSources.length === 0) continue; + for (const { label: sinkLabel, effect: sink } of sinks.get(phaseId) ?? []) { + for (const [sourceId, source] of upstreamSources) { + issues.push(...labelFlowIssues(source, sink, sourceId, sinkLabel)); + } + } + } + return { ok: !issues.some((issue) => issue.severity === "error"), issues }; +} + +interface ComposedSummaryResult extends EffectValidationResult { + summary: PhaseEffectSummary; +} + +function summarizeComposedEffectFlow( + flow: ComposedEffectFlowLike, + options: ComposedEffectFlowOptions, + prefix: string, + seenUses: ReadonlySet, +): ComposedSummaryResult { + const phases = Array.isArray(flow.phases) ? flow.phases : []; + const issues: EffectValidationIssue[] = []; + const summaries = new Map(); + // Static gates (no flow loader) downgrade unresolved `flow{use}` boundaries + // to advisory warnings; the runtime (with a loader) stays fail-closed. + const downgradeUse = options.downgradeUnresolvedUse === true; + for (const phase of phases) { + if (typeof phase.id !== "string" || !phase.id) continue; + const phaseLabel = `${prefix}${phase.id}`; + if (prefix && phase.effects !== undefined) { + issues.push(...validateEffectIR({ effects: phase.effects }).issues.map((issue) => ({ + ...issue, + effectId: issue.effectId ? `${phaseLabel}/${issue.effectId}` : phaseLabel, + message: issue.effectId + ? issue.message.replace(`'${issue.effectId}'`, `'${phaseLabel}/${issue.effectId}'`) + : `${phaseLabel}: ${issue.message}`, + }))); + } + const summary = directPhaseSummary(phase, phaseLabel); + const type = phase.type ?? "agent"; + if (type === "flow" || type === "expand") { + let child: ComposedEffectFlowLike | undefined; + let childSeen = seenUses; + // Only a named saved `flow{use}` is a resolver-resolvable boundary; + // dynamic inline `def` strings (LLM-authored, unparseable) stay + // hard-tainted by design and never downgrade. + let unresolvedUse: string | undefined; + if (phase.def !== undefined) { + child = parseInlineFlow(phase.def); + } else if (type === "flow" && typeof phase.use === "string" && phase.use) { + if (!seenUses.has(phase.use)) { + try { + child = options.resolveFlow?.(phase.use); + } catch { + child = undefined; + } + childSeen = new Set([...seenUses, phase.use]); + } + unresolvedUse = child === undefined ? phase.use : undefined; + } + const advisory = unresolvedUse !== undefined && downgradeUse; + const childResult = child + ? summarizeComposedEffectFlow(child, options, `${phaseLabel}/`, childSeen) + : { ok: true, issues: [], summary: unknownBoundarySummary(phaseLabel, advisory) }; + if (advisory) { + issues.push({ + severity: "warning", + code: "unresolved-flow-use", + message: + `phase '${phaseLabel}' uses saved flow '${unresolvedUse}' which cannot be resolved ` + + `statically; child effects are unknown and treated as an unknown boundary ` + + `(advisory). Runtime admission with the flow store is authoritative.`, + effectId: phaseLabel, + }); + } + issues.push(...childResult.issues); + summary.sources.push(...childResult.summary.sources); + summary.sinks.push(...childResult.summary.sinks); + } + summaries.set(phase.id, summary); + } + const flowResult = validateEffectFlow(phases, undefined, summaries); + issues.push(...flowResult.issues); + return { + ok: !issues.some((issue) => issue.severity === "error"), + issues, + summary: { + sources: [...summaries.values()].flatMap((summary) => summary.sources), + sinks: [...summaries.values()].flatMap((summary) => summary.sinks), + }, + }; +} + +/** + * Validate label flow across nested flow/expand boundaries. Child sources are + * summarized onto the parent node and child sinks receive the parent's + * dependency-reachable sources. Unresolved saved/dynamic children expose a + * maximally confidential source and public sink until runtime resolution. + * With {@link ComposedEffectFlowOptions.downgradeUnresolvedUse} set (static + * gates without a flow loader), unresolvable `flow{use}` boundaries report as + * advisory warnings instead of errors; runtime admission stays authoritative. + */ +export function validateComposedEffectFlow( + flow: ComposedEffectFlowLike, + options: ComposedEffectFlowOptions = {}, +): EffectValidationResult { + const result = summarizeComposedEffectFlow(flow, options, "", new Set()); + return { ok: result.ok, issues: result.issues }; +} + +/** Type guard for a well-formed SecretRef (no material). */ +export function isSecretRef(v: unknown): v is SecretRef { + return isObject(v) && typeof v.secretId === "string" && v.secretId.length > 0 && !("value" in v) && !("material" in v); +} + +/** Type guard for ServiceRef. */ +export function isServiceRef(v: unknown): v is ServiceRef { + return isObject(v) && typeof v.serviceId === "string" && v.serviceId.length > 0; +} + +/** Collect mutating path keys from effects (static literals only). */ +export function collectMutatingPathKeys(effects: EffectDecl[]): Array<{ effectId: string; pathKey: string }> { + const out: Array<{ effectId: string; pathKey: string }> = []; + for (const e of effects) { + if (!MUTATING_KINDS.has(e.kind)) continue; + if (e.target.kind !== "path") continue; + const key = pathRefRelativeKey(e.target.path); + if (key !== undefined) out.push({ effectId: e.id, pathKey: key }); + } + return out; +} diff --git a/packages/taskflow-core/src/effects/why.ts b/packages/taskflow-core/src/effects/why.ts new file mode 100644 index 00000000..7b7c16e5 --- /dev/null +++ b/packages/taskflow-core/src/effects/why.ts @@ -0,0 +1,430 @@ +/** + * why-authorized / why-context / why-effect — pure explainers over effect + auth records. + */ + +import type { + ConfidentialityLabel, + EffectDecl, + EffectKind, + IntegrityLabel, + WhyAuthorized, + WhyContext, + WhyEffect, +} from "./types.ts"; +import { EFFECT_KINDS } from "./types.ts"; +import { pathRefRelativeKey, validateEffectFlow, validateEffectIR } from "./validate.ts"; +import { defaultWorkspaceControlDirectory } from "../resources/execution.ts"; +import { WriteIntentJournal, type WriteIntentRecord } from "../resources/journal.ts"; + +export interface WhyInput { + effect: EffectDecl; + runId: string; + phaseId?: string; + principalId?: string; + /** Capability binding ids that covered this effect (if any). */ + capabilityBindingIds?: string[]; + /** Whether static/admit validation allowed the effect. */ + allowed: boolean; + allowReasons: string[]; + denyReasons?: string[]; + intentId?: string; + journalStatus?: string; + status: WhyEffect["status"]; + workspaceRoot?: string; + defaultConfidentiality?: ConfidentialityLabel; + defaultIntegrity?: IntegrityLabel; +} + +function targetSummary(effect: EffectDecl): string { + const t = effect.target; + if (t.kind === "path") { + const key = pathRefRelativeKey(t.path); + const ws = "workspace" in t.path ? t.path.workspace : "handle"; + return `path:${ws}:${key ?? ""}`; + } + if (t.kind === "secret") return `secret:${t.secret.secretId}`; + return `service:${t.service.serviceId}${t.service.operation ? `:${t.service.operation}` : ""}`; +} + +export function whyAuthorized(input: WhyInput): WhyAuthorized { + const reasons = input.allowed + ? [...input.allowReasons] + : [...(input.denyReasons ?? ["effect not authorized"])]; + if (input.allowed && reasons.length === 0) { + reasons.push("effect declared on plan and passed validateEffectIR"); + } + return { + effectId: input.effect.id, + allowed: input.allowed, + principalId: input.principalId, + capabilityBindingIds: input.capabilityBindingIds ?? [], + reasons, + }; +} + +export function whyContext(input: WhyInput): WhyContext { + const confidentiality = input.effect.confidentiality ?? input.defaultConfidentiality ?? "internal"; + const integrity = input.effect.integrity ?? input.defaultIntegrity ?? "project"; + const reasons: string[] = [ + `run=${input.runId}`, + `confidentiality=${confidentiality}`, + `integrity=${integrity}`, + ]; + if (input.phaseId) reasons.push(`phase=${input.phaseId}`); + if (input.workspaceRoot) reasons.push(`workspaceRoot=${input.workspaceRoot}`); + return { + effectId: input.effect.id, + runId: input.runId, + phaseId: input.phaseId, + confidentiality, + integrity, + workspaceRoot: input.workspaceRoot, + reasons, + }; +} + +export function whyEffect(input: WhyInput): WhyEffect { + const authorized = whyAuthorized(input); + const context = whyContext(input); + const reasons: string[] = [ + `kind=${input.effect.kind}`, + `status=${input.status}`, + ...authorized.reasons, + ]; + if (input.effect.purpose) reasons.push(`purpose=${input.effect.purpose}`); + if (input.intentId) reasons.push(`intent=${input.intentId}`); + if (input.journalStatus) reasons.push(`journalStatus=${input.journalStatus}`); + return { + effectId: input.effect.id, + kind: input.effect.kind, + targetSummary: targetSummary(input.effect), + purpose: input.effect.purpose, + intentId: input.intentId, + journalStatus: input.journalStatus, + status: input.status, + reasons, + authorized, + context, + }; +} + +// --------------------------------------------------------------------------- +// Flow-scoped lookup: whyEffect(runId, effectId) surface for MCP / hosts +// --------------------------------------------------------------------------- + +/** Minimal flow shape for effect lookup (Taskflow / FlowIR phases). */ +export interface WhyEffectFlowLike { + phases?: ReadonlyArray<{ id?: string; effects?: unknown; dependsOn?: unknown; from?: unknown } | null | undefined>; +} + +export interface WhyEffectFromFlowInput { + /** Flow definition that declares effects (typically RunState.def). */ + flow: WhyEffectFlowLike; + runId: string; + /** Effect id as declared, or `phaseId/effectId` composite. */ + effectId: string; + /** Disambiguate when the same effect id appears on multiple phases. */ + phaseId?: string; + workspaceRoot?: string; + /** Runtime lifecycle status; MCP defaults to `declared` (read-only explain). */ + status?: WhyEffect["status"]; + intentId?: string; + principalId?: string; + capabilityBindingIds?: string[]; +} + +export type WhyEffectFromFlowResult = + | { ok: true; why: WhyEffect; phaseId?: string } + | { ok: false; error: string }; + +interface LocatedEffect { + effect: EffectDecl; + phaseId?: string; + /** Id used for bag validation (phase-prefixed when from a phase). */ + bagId: string; +} + +function isObject(v: unknown): v is Record { + return typeof v === "object" && v !== null && !Array.isArray(v); +} + +function asEffectDecl(raw: unknown): EffectDecl | undefined { + if (!isObject(raw)) return undefined; + const id = raw.id; + const kind = raw.kind; + const target = raw.target; + if (typeof id !== "string" || !id.trim()) return undefined; + if (typeof kind !== "string" || !(EFFECT_KINDS as readonly string[]).includes(kind)) return undefined; + if (!isObject(target) || typeof target.kind !== "string") return undefined; + return raw as unknown as EffectDecl; +} + +/** Collect declared effects from each phase (original ids preserved). */ +export function collectDeclaredEffects(flow: WhyEffectFlowLike): LocatedEffect[] { + const out: LocatedEffect[] = []; + const phases = Array.isArray(flow.phases) ? flow.phases : []; + for (const p of phases) { + if (!p || typeof p !== "object") continue; + const phaseId = typeof p.id === "string" ? p.id : undefined; + const pe = p.effects; + if (!Array.isArray(pe)) continue; + for (const raw of pe) { + const e = asEffectDecl(raw); + if (e) { + out.push({ + effect: e, + phaseId, + bagId: phaseId ? `${phaseId}/${e.id}` : e.id, + }); + } else if (isObject(raw) && typeof raw.id === "string") { + out.push({ + effect: raw as unknown as EffectDecl, + phaseId, + bagId: phaseId ? `${phaseId}/${raw.id}` : raw.id, + }); + } + } + } + return out; +} + +function matchesEffectId(loc: LocatedEffect, effectId: string, phaseId?: string): boolean { + if (phaseId !== undefined) { + if (loc.phaseId !== phaseId) return false; + return loc.effect.id === effectId || loc.bagId === effectId; + } + // Bare id, composite bag id, or phaseId/effectId string. + if (loc.effect.id === effectId) return true; + if (loc.bagId === effectId) return true; + if (loc.phaseId && effectId === `${loc.phaseId}/${loc.effect.id}`) return true; + return false; +} + +/** + * Pure: resolve a declared effect from a flow and explain authorization + context. + * + * Fail-closed: if static validation reports any error for this effect (or the + * bag cannot be validated), `authorized.allowed` is false with deny reasons. + * Missing effect → `{ ok: false }` (MCP returns isError). + * + * Zero tokens / no I/O. Runtime commit status is optional (`status` defaults + * to `"declared"`). Use `whyEffectFromLedger` for authorization claims. + */ +export function whyEffectFromFlow(input: WhyEffectFromFlowInput): WhyEffectFromFlowResult { + const effectId = input.effectId.trim(); + if (!effectId) return { ok: false, error: "effectId is required" }; + if (!input.runId.trim()) return { ok: false, error: "runId is required" }; + + const located = collectDeclaredEffects(input.flow); + if (located.length === 0) { + return { ok: false, error: `No declared effects on run "${input.runId}" (flow has empty effects[]).` }; + } + + const matches = located.filter((l) => matchesEffectId(l, effectId, input.phaseId)); + if (matches.length === 0) { + const known = located + .map((l) => (l.phaseId ? `${l.phaseId}/${l.effect.id}` : l.effect.id)) + .slice(0, 12); + const more = located.length > 12 ? ` (+${located.length - 12} more)` : ""; + const scope = input.phaseId ? ` in phase "${input.phaseId}"` : ""; + return { + ok: false, + error: + `Effect "${effectId}" not found${scope} on run "${input.runId}". ` + + `Known: ${known.join(", ") || "—"}${more}`, + }; + } + if (matches.length > 1) { + const candidates = matches.map((m) => + m.phaseId ? `${m.phaseId}/${m.effect.id}` : m.effect.id, + ); + return { + ok: false, + error: + `Effect id "${effectId}" is ambiguous (matches ${matches.length} declarations: ` + + `${candidates.join(", ")}). Pass phaseId to disambiguate.`, + }; + } + + const hit = matches[0]!; + const hitPhase = input.flow.phases?.find((phase) => phase?.id === hit.phaseId); + const localValidation = validateEffectIR({ effects: hitPhase?.effects }); + const flowValidation = validateEffectFlow( + (input.flow.phases ?? []).filter((phase): phase is NonNullable => phase !== null && phase !== undefined), + ); + const effectIssues = [...localValidation.issues, ...flowValidation.issues].filter( + (i) => + i.effectId === hit.bagId || + i.effectId === hit.effect.id || + (hit.phaseId && i.effectId === `${hit.phaseId}/${hit.effect.id}`), + ); + // Fail closed: any error on this effect, or unknown kind / malformed target, denies. + const denyFromIssues = effectIssues + .filter((i) => i.severity === "error") + .map((i) => i.message); + const kindOk = (EFFECT_KINDS as readonly string[]).includes(hit.effect.kind as EffectKind); + if (!kindOk) { + denyFromIssues.push(`unknown effect kind: ${String(hit.effect.kind)}`); + } + // Also deny if effect cannot form a coherent EffectDecl (asEffectDecl failed shape). + const shapeOk = asEffectDecl(hit.effect) !== undefined; + if (!shapeOk) { + denyFromIssues.push("effect declaration is not a well-formed EffectDecl"); + } + + const allowed = denyFromIssues.length === 0; + const status = input.status ?? "declared"; + const why = whyEffect({ + effect: hit.effect, + runId: input.runId, + phaseId: hit.phaseId, + principalId: input.principalId, + capabilityBindingIds: input.capabilityBindingIds, + allowed, + allowReasons: allowed + ? ["declared on flow plan", "validateEffectIR ok for this effect"] + : [], + denyReasons: allowed ? undefined : denyFromIssues, + intentId: input.intentId, + status, + workspaceRoot: input.workspaceRoot, + }); + return { ok: true, why, phaseId: hit.phaseId }; +} + +export interface WhyEffectFromLedgerInput extends Omit { + intents: readonly WriteIntentRecord[]; + ledgerError?: string; +} + +function lifecycleStatus(status: WriteIntentRecord["status"]): WhyEffect["status"] { + if (status === "pending") return "staged"; + if (status === "committed-content" || status === "committed-generation") return "committed"; + if (status === "dirty-unknown") return "unknown"; + return "rejected"; +} + +/** + * Explain authority from the durable resource ledger, not from declaration + * alone. A statically valid effect without a matching write intent remains + * unauthorized because no mutation permit was durably admitted for it. + */ +export function whyEffectFromLedger(input: WhyEffectFromLedgerInput): WhyEffectFromFlowResult { + const declared = whyEffectFromFlow({ + flow: input.flow, + runId: input.runId, + effectId: input.effectId, + phaseId: input.phaseId, + workspaceRoot: input.workspaceRoot, + status: "declared", + }); + if (!declared.ok) return declared; + const phaseId = declared.phaseId; + const matches = input.intents + .filter((intent) => + intent.owner.runId === input.runId && + (phaseId === undefined || intent.owner.phaseId === phaseId) && + intent.scopes.some((scope) => scope.effectId === declared.why.effectId)) + .sort((left, right) => right.intentSequence - left.intentSequence); + const intent = matches[0]; + const staticAllowed = declared.why.authorized.allowed; + if (!intent) { + const reason = input.ledgerError + ? `durable resource ledger unavailable: ${input.ledgerError}` + : "no durable resource intent admitted this effect for the requested run/phase"; + return { + ok: true, + phaseId, + why: { + ...declared.why, + status: "declared", + reasons: [`kind=${declared.why.kind}`, "status=declared", reason], + authorized: { + ...declared.why.authorized, + allowed: false, + principalId: undefined, + capabilityBindingIds: [], + reasons: staticAllowed + ? [reason] + : [...declared.why.authorized.reasons, reason], + }, + }, + }; + } + const effectScopes = intent.scopes.filter((scope) => scope.effectId === declared.why.effectId); + const capabilityBindingIds = [...new Set(effectScopes + .map((scope) => scope.capabilityBindingId) + .filter((value): value is string => typeof value === "string" && value.length > 0))]; + const allowed = staticAllowed && capabilityBindingIds.length > 0 && intent.authorizationPrincipalId !== undefined; + const ledgerReasons = [ + `durable resource intent ${intent.intentId}`, + `journal status ${intent.status}`, + `scope evidence ${effectScopes.length}`, + ...(intent.commitGeneration === undefined ? [] : [`commit generation ${intent.commitGeneration}`]), + ]; + if (capabilityBindingIds.length === 0) ledgerReasons.push("missing capability binding evidence"); + if (!intent.authorizationPrincipalId) ledgerReasons.push("missing authenticated principal evidence"); + return { + ok: true, + phaseId, + why: { + ...declared.why, + intentId: intent.intentId, + journalStatus: intent.status, + status: lifecycleStatus(intent.status), + reasons: [ + `kind=${declared.why.kind}`, + `status=${lifecycleStatus(intent.status)}`, + ...ledgerReasons, + ], + authorized: { + effectId: declared.why.effectId, + allowed, + principalId: intent.authorizationPrincipalId, + capabilityBindingIds, + reasons: staticAllowed ? ledgerReasons : [...declared.why.authorized.reasons, ...ledgerReasons], + }, + }, + }; +} + +export async function whyEffectFromDurableJournal( + input: Omit & { controlDirectory?: string }, +): Promise { + try { + const directory = input.controlDirectory ?? defaultWorkspaceControlDirectory(input.workspaceRoot ?? process.cwd()); + const intents = await new WriteIntentJournal({ directory, journalEpoch: 1 }).listIntents(); + return whyEffectFromLedger({ ...input, intents }); + } catch (error) { + return whyEffectFromLedger({ + ...input, + intents: [], + ledgerError: error instanceof Error ? error.message : String(error), + }); + } +} + +/** Plain-text render for MCP / CLI (no markdown fences). */ +export function formatWhyEffect(why: WhyEffect): string { + const lines: string[] = [ + `why-effect ${why.effectId}`, + ` kind: ${why.kind}`, + ` target: ${why.targetSummary}`, + ` status: ${why.status}`, + ` authorized: ${why.authorized.allowed ? "yes" : "NO (fail-closed)"}`, + ]; + if (why.purpose) lines.push(` purpose: ${why.purpose}`); + if (why.intentId) lines.push(` intent: ${why.intentId}`); + if (why.journalStatus) lines.push(` journalStatus: ${why.journalStatus}`); + if (why.context.phaseId) lines.push(` phase: ${why.context.phaseId}`); + lines.push(` confidentiality: ${why.context.confidentiality}`); + lines.push(` integrity: ${why.context.integrity}`); + if (why.context.workspaceRoot) lines.push(` workspaceRoot: ${why.context.workspaceRoot}`); + lines.push(" reasons:"); + for (const r of why.reasons) lines.push(` • ${r}`); + if (why.authorized.reasons.length > 0) { + lines.push(" authorization:"); + for (const r of why.authorized.reasons) lines.push(` • ${r}`); + } + return lines.join("\n"); +} diff --git a/packages/taskflow-core/src/exec/kernel-policy.ts b/packages/taskflow-core/src/exec/kernel-policy.ts index 187847e9..abbaff51 100644 --- a/packages/taskflow-core/src/exec/kernel-policy.ts +++ b/packages/taskflow-core/src/exec/kernel-policy.ts @@ -56,6 +56,10 @@ export function kernelUnsupportedReason(def: Taskflow): string | undefined { } for (const p of def.phases ?? []) { const id = p.id; + if (Array.isArray((p as { effects?: unknown }).effects) && + ((p as { effects?: unknown[] }).effects?.length ?? 0) > 0) { + return `phase '${id}': declared effects require the resource-controlled imperative transaction seam`; + } if (p.type === "gate" && (p as { score?: unknown }).score !== undefined) { return `phase '${id}': score gates require the imperative runtime`; } diff --git a/packages/taskflow-core/src/exec/step.ts b/packages/taskflow-core/src/exec/step.ts index 42443489..9b118f19 100644 --- a/packages/taskflow-core/src/exec/step.ts +++ b/packages/taskflow-core/src/exec/step.ts @@ -606,6 +606,12 @@ export async function stepPhase(phase: Phase, ctx: StepContext): Promise 0) { + const error = "TFWS_RESOURCE_AUTHORITY_UNAVAILABLE: event-kernel step cannot execute declared effects without a resource transaction binding"; + events.push(baseEvent(ctx, phase.id, "phase-end", { status: "failed", error })); + return { events, status: "failed", error, usage: emptyUsage() }; + } if (type === "flow") { events.push( baseEvent(ctx, phase.id, "decision", { diff --git a/packages/taskflow-core/src/flowir/canonical-hash.ts b/packages/taskflow-core/src/flowir/canonical-hash.ts index 2376bf53..eb867089 100644 --- a/packages/taskflow-core/src/flowir/canonical-hash.ts +++ b/packages/taskflow-core/src/flowir/canonical-hash.ts @@ -117,8 +117,8 @@ function canonicalSerialize(value: unknown): string { * equivalent condition spellings collapse, and `undefined` optionals are * omitted so their presence/absence does not affect the hash. * - * `inject`/`emits`/`deps` arrays are preserved verbatim (order is semantic — - * declared-read order matters for fingerprinting). + * `inject`/`emits`/`deps`/`effects` arrays are preserved verbatim (order is + * semantic — declared-read / effect-declaration order matters for fingerprinting). */ function canonicalNodeObject(node: FlowIRNode): Record { const obj: Record = { @@ -136,6 +136,9 @@ function canonicalNodeObject(node: FlowIRNode): Record { if (node.join !== undefined) obj.join = node.join; if (node.timeout !== undefined) obj.timeout = node.timeout; if (node.payload !== undefined) obj.payload = node.payload; + // Trusted Effects (0.3): content-address declared side effects when present. + // Array order is semantic (declaration order), matching inject/emits. + if (node.effects !== undefined && node.effects.length > 0) obj.effects = node.effects; return obj; } diff --git a/packages/taskflow-core/src/flowir/compile.ts b/packages/taskflow-core/src/flowir/compile.ts index b919eca8..d1324b09 100644 --- a/packages/taskflow-core/src/flowir/compile.ts +++ b/packages/taskflow-core/src/flowir/compile.ts @@ -30,6 +30,8 @@ import type { FlowIRNode, TaskflowIRMeta, } from "./meta.ts"; +import type { EffectDecl } from "../effects/types.ts"; +import { validateComposedEffectFlow, validateEffectIR } from "../effects/validate.ts"; // Keep in sync with translate.ts SIDECAR_PHASE_FIELDS (round-trip lossless). const SIDECAR_PHASE_FIELDS = [ @@ -80,9 +82,12 @@ const SIDECAR_PHASE_FIELDS = [ "idleTimeout", "reduceStrategy", "batchSize", + "effects", ] as const; -const NODE_FIELD_KEYS = new Set(["task", "dependsOn", "join", "when", "timeout"]); +// First-class node fields: present on the IR node, stripped from payload so +// they are not double-hashed (payload is itself content-addressed). +const NODE_FIELD_KEYS = new Set(["task", "dependsOn", "join", "when", "timeout", "effects"]); const VALID_KINDS = new Set(PHASE_TYPES); @@ -215,11 +220,30 @@ export function compileTaskflowToFlowIR(def: Taskflow): CompileTaskflowToFlowIRR node.condRef = n.canonical || undefined; } if (typeof phase.task === "string") node.task = phase.task; - if (phase.dependsOn && phase.dependsOn.length > 0) node.deps = [...phase.dependsOn]; - if (phase.join === "all" || phase.join === "any") node.join = phase.join; - if (typeof phase.timeout === "number") node.timeout = phase.timeout; - const payload = payloadForPhase(phase); - if (payload) node.payload = payload; + if (phase.dependsOn && phase.dependsOn.length > 0) node.deps = [...phase.dependsOn]; + if (phase.join === "all" || phase.join === "any") node.join = phase.join; + if (typeof phase.timeout === "number") node.timeout = phase.timeout; + const effectsRaw = phase.effects; + if (effectsRaw !== undefined) { + const effectValidation = validateEffectIR({ effects: effectsRaw }); + for (const issue of effectValidation.issues) { + if (issue.severity === "error") { + errors.push({ + phaseId: phase.id, + code: `effect-${issue.code}`, + message: issue.message, + }); + } else { + warnings.push({ phaseId: phase.id, message: issue.message }); + } + } + if (effectValidation.ok && Array.isArray(effectsRaw) && effectsRaw.length > 0) { + // Only closed, validated EffectIR enters the content-addressed representation. + node.effects = effectsRaw as EffectDecl[]; + } + } + const payload = payloadForPhase(phase); + if (payload) node.payload = payload; for (const from of inject) { edges.push({ from, to: phase.id }); @@ -229,6 +253,20 @@ export function compileTaskflowToFlowIR(def: Taskflow): CompileTaskflowToFlowIRR nodes.push(node); } + const effectFlow = validateComposedEffectFlow({ name: def.name, phases: def.phases ?? [] }, { + // Static gates have no flow store: an unresolved `flow{use}` child is + // advisory (the runtime loader is the authoritative admission gate). + downgradeUnresolvedUse: true, + }); + for (const issue of effectFlow.issues) { + const phaseId = issue.effectId?.includes("/") ? issue.effectId.split("/")[0] : undefined; + if (issue.severity === "error") { + errors.push({ phaseId, code: `effect-${issue.code}`, message: issue.message }); + } else { + warnings.push({ phaseId, message: issue.message }); + } + } + const canonical: CanonicalFlowIR = { name: def.name || "unnamed", version: typeof def.version === "number" ? def.version : 1, @@ -263,6 +301,7 @@ export function compileTaskflowToFlowIR(def: Taskflow): CompileTaskflowToFlowIRR inject: n.inject, emits: n.emits, when: n.when, + ...(n.effects && n.effects.length > 0 ? { effects: n.effects } : {}), }), ), args: def.args, @@ -277,7 +316,7 @@ export function compileTaskflowToFlowIR(def: Taskflow): CompileTaskflowToFlowIRR }; // Genuine compiler owns the IR hash when we produced nodes. - const usedFallbackHash = nodes.length === 0 || errors.some((e) => e.code === "empty-phases"); + const usedFallbackHash = nodes.length === 0 || errors.length > 0; return { canonical, ir, meta, warnings, errors, usedFallbackHash }; } diff --git a/packages/taskflow-core/src/flowir/index.ts b/packages/taskflow-core/src/flowir/index.ts index c8a250a6..04a466d2 100644 --- a/packages/taskflow-core/src/flowir/index.ts +++ b/packages/taskflow-core/src/flowir/index.ts @@ -34,7 +34,7 @@ export async function compileTaskflowToIR(def: Taskflow): Promise { const c = compileTaskflowToFlowIR(def); let hash: string | undefined; try { - if (c.canonical.nodes.length > 0) { + if (c.errors.length === 0 && c.canonical.nodes.length > 0) { hash = hashFlowIR(c.canonical); } } catch { diff --git a/packages/taskflow-core/src/flowir/meta.ts b/packages/taskflow-core/src/flowir/meta.ts index 9b4427af..3c79de21 100644 --- a/packages/taskflow-core/src/flowir/meta.ts +++ b/packages/taskflow-core/src/flowir/meta.ts @@ -17,6 +17,7 @@ */ import type { Budget, Taskflow } from "../schema.ts"; +import type { EffectDecl } from "../effects/types.ts"; // --------------------------------------------------------------------------- // Declared dependency plane (compile-time, M2) @@ -63,6 +64,11 @@ export interface FlowIRNode { emits: string[]; /** Raw `when` guard passthrough (stub: not rewritten to IR conditions). */ when?: string; + /** + * Trusted Effects (0.3 MVP): declared side effects for this node. + * Content-addressed by `hashFlowIR` when present on the canonical IR. + */ + effects?: EffectDecl[]; } /** The compiled IR: a flat list of nodes plus flow-level metadata. */ diff --git a/packages/taskflow-core/src/flowir/schema.ts b/packages/taskflow-core/src/flowir/schema.ts index 475eb04f..fba18b73 100644 --- a/packages/taskflow-core/src/flowir/schema.ts +++ b/packages/taskflow-core/src/flowir/schema.ts @@ -33,8 +33,11 @@ */ import { Type } from "typebox"; +import { Value } from "typebox/value"; import { StringEnum } from "../typebox-helpers.ts"; import { PHASE_TYPES, type PhaseType } from "../schema.ts"; +import type { EffectDecl } from "../effects/types.ts"; +import { EffectDeclSchema } from "../effects/schema.ts"; // --------------------------------------------------------------------------- // FlowIRNodeKind — closed literal union = PHASE_TYPES (currently 12 kinds) @@ -122,6 +125,12 @@ export interface FlowIRNode { timeout?: number; /** Runtime-affecting DSL payload not otherwise modeled by the core node fields. */ payload?: Record; + /** + * Trusted Effects (0.3 MVP): declared side effects for this node. + * Shape is {@link EffectDecl}[]; deep validation is `validateEffectIR` / + * the effects verifier (TypeBox here only checks presence of an array). + */ + effects?: EffectDecl[]; } // --------------------------------------------------------------------------- @@ -247,6 +256,12 @@ export const FlowIRNodeSchema = Type.Object( description: "Runtime-affecting DSL payload not otherwise modeled by the core node fields", }), ), + effects: Type.Optional( + Type.Array(EffectDeclSchema, { + description: + "Trusted Effects (0.3): closed declared side effects (EffectDecl[]).", + }), + ), }, { additionalProperties: false }, ); @@ -317,6 +332,10 @@ export function isFlowIRNode(value: unknown): value is FlowIRNode { if (n.payload !== undefined && (typeof n.payload !== "object" || n.payload === null || Array.isArray(n.payload))) { return false; } + if (n.effects !== undefined && ( + !Array.isArray(n.effects) || + n.effects.some((effect) => !Value.Check(EffectDeclSchema, effect)) + )) return false; return true; } diff --git a/packages/taskflow-core/src/flowir/translate.ts b/packages/taskflow-core/src/flowir/translate.ts index 223366dd..f931b7aa 100644 --- a/packages/taskflow-core/src/flowir/translate.ts +++ b/packages/taskflow-core/src/flowir/translate.ts @@ -15,6 +15,8 @@ */ import { collectRefs, type Phase, type Taskflow } from "../schema.ts"; +import type { EffectDecl } from "../effects/types.ts"; +import { validateComposedEffectFlow, validateEffectIR } from "../effects/validate.ts"; import type { CompileError, CompileWarning, @@ -84,6 +86,7 @@ const SIDECAR_PHASE_FIELDS = [ "idleTimeout", "reduceStrategy", "batchSize", + "effects", ] as const; /** Build the per-phase sidecar record (verbatim copy of non-IR fields). */ @@ -158,15 +161,44 @@ export function translateTaskflow(def: Taskflow): { sidecarPhases[phase.id] = sidecarForPhase(phase); + const effectsRaw = phase.effects; + let effects: EffectDecl[] | undefined; + if (effectsRaw !== undefined) { + const effectValidation = validateEffectIR({ effects: effectsRaw }); + for (const issue of effectValidation.issues) { + if (issue.severity === "error") { + errors.push({ phaseId: phase.id, code: `effect-${issue.code}`, message: issue.message }); + } else { + warnings.push({ phaseId: phase.id, message: issue.message }); + } + } + if (effectValidation.ok && Array.isArray(effectsRaw) && effectsRaw.length > 0) effects = effectsRaw as EffectDecl[]; + } + return { id: phase.id, kind: phase.type ?? "agent", inject: Array.from(reads), emits: [phase.id], when: phase.when, + ...(effects ? { effects } : {}), } satisfies FlowIRNode; }); + const effectFlow = validateComposedEffectFlow({ name: def.name, phases: def.phases }, { + // Static gates have no flow store: an unresolved `flow{use}` child is + // advisory (the runtime loader is the authoritative admission gate). + downgradeUnresolvedUse: true, + }); + for (const issue of effectFlow.issues) { + const phaseId = issue.effectId?.includes("/") ? issue.effectId.split("/")[0] : undefined; + if (issue.severity === "error") { + errors.push({ phaseId, code: `effect-${issue.code}`, message: issue.message }); + } else { + warnings.push({ phaseId, message: issue.message }); + } + } + const ir: FlowIR = { name: def.name, nodes, diff --git a/packages/taskflow-core/src/index.ts b/packages/taskflow-core/src/index.ts index 89c3701d..c49939f3 100644 --- a/packages/taskflow-core/src/index.ts +++ b/packages/taskflow-core/src/index.ts @@ -62,3 +62,5 @@ export * from "./preflight.ts"; export * from "./savings.ts"; export * from "./hooks.ts"; export * from "./analytics.ts"; +// 0.3 Trusted Effects MVP (additive pure surface + resource-controlled FS transaction) +export * from "./effects/index.ts"; diff --git a/packages/taskflow-core/src/resources/execution.ts b/packages/taskflow-core/src/resources/execution.ts index e1e4b0cd..dbd5f2f3 100644 --- a/packages/taskflow-core/src/resources/execution.ts +++ b/packages/taskflow-core/src/resources/execution.ts @@ -27,8 +27,14 @@ import { import { PersistentLeaseCoordinator, type LeaseHandle } from "./leases.ts"; import { createHostRootGrant, createRootRegistry, type RootGrant, type RootRegistry } from "./registry.ts"; import { resolvePathRef, type ResolvedPathRef } from "./resolve.ts"; +import { + garbageCollectResourceFileTransactions, + prepareResourceFileTransaction, + recoverResourceFileIntent, + type PreparedResourceFileTransaction, +} from "./file-transaction.ts"; import { computeHostBaselineBodyDigest, createSandboxPolicyPlan, SandboxPolicyFactory } from "./sandbox.ts"; -import type { ScopedCapability } from "./schema.ts"; +import type { PathRef, ScopedCapability } from "./schema.ts"; import { sameExecutionOwner, type ExecutionOwner } from "./types.ts"; export interface CoordinatedScriptResult { @@ -85,6 +91,10 @@ export interface ResolveOnlyPhaseBinding { readonly resourceDomainId: string; readonly runId: string; readonly phaseId: string; + beginFileWriteTransaction( + targets: readonly { effectId: string; path: PathRef }[], + options?: { unitId?: string; signal?: AbortSignal }, + ): Promise; runAgent(call: ResolveOnlyAgentCall): Promise; runScript(call: ResolveOnlyScriptCall): Promise; } @@ -285,6 +295,7 @@ class ResolveOnlyWorkspaceSessionImpl implements ResolveOnlyWorkspaceSession { readonly #rootIdentity: DirectoryIdentity; readonly #pendingLeaseReleases = new Set(); readonly #allowReconcile: boolean; + readonly #controlDirectory: string; // A single resolve-only session can fan out many tasks over the same granted // root. They are all potential writers and cannot safely overlap without a // native broker/snapshot backend. Serialize them before lease acquisition so @@ -317,6 +328,7 @@ class ResolveOnlyWorkspaceSessionImpl implements ResolveOnlyWorkspaceSession { const controlDirectory = options.controlDirectory ?? defaultWorkspaceControlDirectory(this.invocationRoot); ensurePrivateDirectory(controlDirectory); const canonicalControlDirectory = fs.realpathSync(controlDirectory); + this.#controlDirectory = canonicalControlDirectory; if (isWithin(this.invocationRoot, canonicalControlDirectory) || isWithin(canonicalControlDirectory, this.invocationRoot)) { throw new Error("TFWS_INVALID_POLICY: control-plane storage must not overlap the flow workspace grant"); } @@ -352,7 +364,15 @@ class ResolveOnlyWorkspaceSessionImpl implements ResolveOnlyWorkspaceSession { const active = await this.#leases.list(); return active.some((lease) => sameExecutionOwner(lease.owner, owner)); }, + recoverKnownClean: (intent) => recoverResourceFileIntent({ + controlDirectory: this.#controlDirectory, + intent, + leases: this.#leases, + leaseTimeoutMs: this.#leaseTimeoutMs, + signal: this.#signal, + }), }); + garbageCollectResourceFileTransactions(this.#controlDirectory, await this.#journal.listIntents()); } async bindPhase(input: BindResolveOnlyPhaseInput): Promise { @@ -468,6 +488,83 @@ class ResolveOnlyWorkspaceSessionImpl implements ResolveOnlyWorkspaceSession { } } + async beginFileWriteTransaction( + input: BindResolveOnlyPhaseInput, + boundPath: string, + targets: readonly { effectId: string; path: PathRef }[], + unitId: string, + signal: AbortSignal | undefined, + ): Promise { + await this.#drainPendingLeaseReleases(); + this.#assertRootIdentity(); + if (signal?.aborted || this.#signal?.aborted) { + throw new Error("ABORT_ERR: file transaction was cancelled before admission"); + } + const dirty = (await this.#journal.listIntents()).find((intent) => + intent.resourceDomainId === this.#grant.resourceDomainId && intent.status === "dirty-unknown"); + if (dirty) throw new Error("TFWS_RESOURCE_DIRTY: workspace requires reconciliation before another write"); + + const owner: ExecutionOwner = { + runId: input.runId, + phaseId: input.phaseId, + attemptId: crypto.randomUUID(), + unitId, + ancestry: [], + }; + const generation = await this.#journal.getDomainGeneration(this.#grant.resourceDomainId); + const rootPrefix = path.relative(this.invocationRoot, boundPath).split(path.sep).filter(Boolean).join("/"); + const baseCapability: ScopedCapability = { + bindingId: this.#grant.bindingId, + resourceDomainId: this.#grant.resourceDomainId, + providerInstanceId: "root", + logicalWorkspaceId: "invocation", + logicalPrefix: rootPrefix, + physicalScopeRoot: boundPath, + access: "read-write", + version: { identityMode: "path-bound", generation, state: "clean" }, + lifetime: { scope: "phase", runId: input.runId, phaseId: input.phaseId, attemptId: owner.attemptId }, + }; + const resolvedTargets = targets.map((target) => { + if (!("workspace" in target.path) || target.path.workspace === undefined) { + throw new Error(`TFWS_HANDLE_INVALID: effect '${target.effectId}' requires a bound workspace PathRef`); + } + if (target.path.intent !== "create-file" && target.path.intent !== "existing-file") { + throw new Error(`TFWS_INVALID_PATH: effect '${target.effectId}' fs.write requires create-file or existing-file intent`); + } + const logicalWorkspaceId = target.path.workspace; + const capability: ScopedCapability = { ...baseCapability, logicalWorkspaceId }; + const resolved = resolvePathRef( + { ...target.path, access: "read-write", maxLifetime: { scope: "phase" } }, + { + workspaces: new Map([[logicalWorkspaceId, capability]]), + runId: input.runId, + phaseId: input.phaseId, + attemptId: owner.attemptId, + }, + { definitions: input.argDefinitions, values: input.argValues }, + ); + if (!resolved.ok) throw new Error(`${resolved.error.code}: ${resolved.error.redactedMessage}`); + return { effectId: target.effectId, ref: resolved.value }; + }); + const combinedSignal = this.#signal && signal + ? AbortSignal.any([this.#signal, signal]) + : this.#signal ?? signal; + return prepareResourceFileTransaction({ + controlDirectory: this.#controlDirectory, + resourceDomainId: this.#grant.resourceDomainId, + owner, + targets: resolvedTargets, + leases: this.#leases, + journal: this.#journal, + leaseTimeoutMs: this.#leaseTimeoutMs, + permitTtlMs: this.#permitTtlMs, + signal: combinedSignal, + authorizationPrincipalId: this.authority.principalId, + authorizationScopeRoot: boundPath, + onDeferredLeaseRelease: (lease) => this.#pendingLeaseReleases.add(lease), + }); + } + async #executeMutationNow( input: BindResolveOnlyPhaseInput, boundPath: string, @@ -712,6 +809,19 @@ class ResolveOnlyPhaseBindingImpl implements ResolveOnlyPhaseBinding { } } + beginFileWriteTransaction( + targets: readonly { effectId: string; path: PathRef }[], + options: { unitId?: string; signal?: AbortSignal } = {}, + ): Promise { + return this.#session.beginFileWriteTransaction( + this.#input, + this.absolutePath, + targets, + options.unitId ?? this.phaseId, + options.signal, + ); + } + runScript(call: ResolveOnlyScriptCall): Promise { return this.#session.executeMutation( this.#input, diff --git a/packages/taskflow-core/src/resources/file-transaction.ts b/packages/taskflow-core/src/resources/file-transaction.ts new file mode 100644 index 00000000..452e2d6b --- /dev/null +++ b/packages/taskflow-core/src/resources/file-transaction.ts @@ -0,0 +1,872 @@ +/** + * Resource-controlled file mutation transaction. + * + * This module is deliberately internal to `resources/execution.ts`: callers + * receive transactions only after host authority and PathRef resolution. It + * owns the data-plane sequence beneath that boundary: + * + * durable snapshots -> atomic multi-scope lease -> durable intent/permit + * -> stage -> promote -> commit-content | restore + abort-restored + * + * The local backend provides taskflow-exclusive isolation. It does not claim + * hostile external-process fencing; concurrent Taskflow writers are excluded + * by the persistent lease and unexpected external changes fail closed. + */ + +import * as crypto from "node:crypto"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import type { ResolvedPathRef } from "./resolve.ts"; +import type { WriteIntentJournal, PreparedMutation, WriteIntentRecord } from "./journal.ts"; +import type { LeaseHandle, PersistentLeaseCoordinator } from "./leases.ts"; +import { ensureDirectory, fsyncDirectory, writeJsonAtomicDurable } from "./persistence.ts"; +import type { ExecutionOwner, ScopedContentEvidence } from "./types.ts"; + +export interface ResolvedFileWriteTarget { + effectId: string; + ref: ResolvedPathRef; +} + +export interface FileWritePayload { + effectId: string; + content: string | Buffer; +} + +export type FileTransactionResult = + | { + ok: true; + intentId: string; + committedPaths: string[]; + commitGeneration: number; + } + | { + ok: false; + intentId: string; + code: "declared-path-bypass" | "commit-rejected"; + reason: string; + restored: true; + }; + +interface SnapshotManifest { + version: 1; + artifactId: string; + effectId: string; + physicalPath: string; + logicalSubpath: string; + exists: boolean; + contentId: string; + mode?: number; + nearestExistingAncestor: string; + blobPath?: string; +} + +interface Snapshot extends SnapshotManifest { + capabilityBindingId: string; + scopeDigest: string; +} + +export interface ResourceFileTransactionOptions { + controlDirectory: string; + resourceDomainId: string; + owner: ExecutionOwner; + targets: readonly ResolvedFileWriteTarget[]; + leases: PersistentLeaseCoordinator; + journal: WriteIntentJournal; + leaseTimeoutMs: number; + permitTtlMs: number; + signal?: AbortSignal; + authorizationPrincipalId?: string; + authorizationScopeRoot: string; + /** Preserve a failed authenticated release for the session's next-admission drain. */ + onDeferredLeaseRelease?: (lease: LeaseHandle) => void; + /** Internal fault-injection seam for post-terminal staging cleanup tests. */ + cleanupStaging?: (stagingDirectory: string) => void; +} + +function hash(parts: readonly (string | Buffer)[]): string { + const digest = crypto.createHash("sha256"); + for (const part of parts) digest.update(part); + return `sha256:${digest.digest("hex")}`; +} + +function contentId(content: Buffer | undefined): string { + return content === undefined ? hash(["missing\0"]) : hash(["regular-file\0", content]); +} + +function isWithin(root: string, candidate: string): boolean { + const rel = path.relative(root, candidate); + return rel === "" || (rel !== ".." && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel)); +} + +/** Stage blob basenames must be path-safe; effect ids are attacker-influenced IR. */ +function assertSafeEffectId(effectId: string): void { + if (!effectId || effectId !== path.basename(effectId) || effectId === "." || effectId === "..") { + throw new Error(`TFWS_INVALID_EFFECT_ID: effect id is not a safe path segment: ${JSON.stringify(effectId)}`); + } + if (/[\\/]/.test(effectId) || (process.platform === "win32" && effectId.includes(":"))) { + throw new Error(`TFWS_INVALID_EFFECT_ID: effect id must not contain path separators: ${JSON.stringify(effectId)}`); + } +} + +function stageBlobPath(stagingDirectory: string, effectId: string): string { + assertSafeEffectId(effectId); + return path.join(stagingDirectory, "staged", `${effectId}-${crypto.randomUUID()}.blob`); +} + +/** Re-validate containment for commit: no intermediate symlinks; parents stay in scope. */ +function assertCommitContainment(snapshots: readonly Snapshot[], authorizationScopeRoot: string): void { + const root = fs.realpathSync(authorizationScopeRoot); + for (const snapshot of snapshots) { + assertWritablePathInScope(snapshot.physicalPath, root); + } +} + +/** + * Ensure each path segment from root→file is either missing or a real directory + * (never a symlink). Prevents prepare→commit TOCTOU where a body plants mid-path links. + */ +function assertWritablePathInScope(filePath: string, authorizationRoot: string): void { + const root = path.resolve(authorizationRoot); + const absolute = path.resolve(filePath); + if (!isWithin(root, path.dirname(absolute)) && path.dirname(absolute) !== root) { + // dirname must be inside root (file itself may be direct child) + const parent = path.dirname(absolute); + if (!isWithin(root, parent) && parent !== root) { + throw new Error(`TFWS_PATH_ESCAPE: parent ${parent} outside scope ${root}`); + } + } + const rel = path.relative(root, absolute); + if (rel.startsWith(`..`) || path.isAbsolute(rel)) { + throw new Error(`TFWS_PATH_ESCAPE: ${absolute} outside scope ${root}`); + } + const segments = rel.split(path.sep).filter(Boolean); + let current = root; + // Walk all parent segments (exclude final filename). + for (let i = 0; i < segments.length - 1; i++) { + current = path.join(current, segments[i]!); + try { + const st = fs.lstatSync(current); + if (st.isSymbolicLink()) { + throw new Error(`TFWS_PATH_ESCAPE: intermediate symlink at ${current}`); + } + if (!st.isDirectory()) { + throw new Error(`TFWS_PATH_ESCAPE: intermediate path is not a directory: ${current}`); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + // Remaining segments will be created without following links (see ensureDirectoryNoFollow). + break; + } + throw error; + } + } + try { + const st = fs.lstatSync(absolute); + if (st.isSymbolicLink()) { + throw new Error(`TFWS_PATH_ESCAPE: target is a symlink: ${absolute}`); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } +} + +/** mkdir -p that refuses to traverse symlinks (segment-wise, lstat each existing). */ +function ensureDirectoryNoFollow(directory: string, authorizationRoot: string): void { + const root = path.resolve(authorizationRoot); + const absolute = path.resolve(directory); + if (!isWithin(root, absolute) && absolute !== root) { + throw new Error(`TFWS_PATH_ESCAPE: mkdir outside scope: ${absolute}`); + } + const rel = absolute === root ? "" : path.relative(root, absolute); + if (rel.startsWith("..") || path.isAbsolute(rel)) { + throw new Error(`TFWS_PATH_ESCAPE: mkdir outside scope: ${absolute}`); + } + let current = root; + if (rel) { + for (const seg of rel.split(path.sep).filter(Boolean)) { + current = path.join(current, seg); + try { + const st = fs.lstatSync(current); + if (st.isSymbolicLink()) throw new Error(`TFWS_PATH_ESCAPE: mkdir hits symlink ${current}`); + if (!st.isDirectory()) throw new Error(`TFWS_PATH_ESCAPE: mkdir hits non-dir ${current}`); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + fs.mkdirSync(current, { mode: 0o700 }); + } + } + } +} + + +function nearestExistingAncestor(candidate: string): string { + let current = path.dirname(candidate); + while (true) { + try { + const real = fs.realpathSync(current); + if (!fs.statSync(real).isDirectory()) throw new Error(`ancestor is not a directory: ${current}`); + return real; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + const parent = path.dirname(current); + if (parent === current) throw new Error(`no existing ancestor for ${candidate}`); + current = parent; + } + } +} + +function inspectRegularFile(filePath: string): { exists: false } | { exists: true; content: Buffer; mode: number } { + try { + const stat = fs.lstatSync(filePath); + if (!stat.isFile()) throw new Error(`TFWS_INVALID_PATH: target is not a regular file: ${filePath}`); + const noFollow = typeof fs.constants.O_NOFOLLOW === "number" ? fs.constants.O_NOFOLLOW : 0; + const fd = fs.openSync(filePath, fs.constants.O_RDONLY | noFollow); + try { + return { exists: true, content: fs.readFileSync(fd), mode: stat.mode & 0o777 }; + } finally { + fs.closeSync(fd); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return { exists: false }; + throw error; + } +} + +function writeBufferDurable(filePath: string, content: Buffer, mode = 0o600): void { + ensureDirectory(path.dirname(filePath)); + const fd = fs.openSync(filePath, "wx", mode); + try { + let offset = 0; + while (offset < content.length) offset += fs.writeSync(fd, content, offset, content.length - offset); + fs.fsyncSync(fd); + } finally { + fs.closeSync(fd); + } + fsyncDirectory(path.dirname(filePath)); +} + +function replaceFileAtomic(filePath: string, content: Buffer, mode = 0o600, authorizationRoot?: string): void { + const parent = path.dirname(filePath); + if (authorizationRoot !== undefined) ensureDirectoryNoFollow(parent, authorizationRoot); + else ensureDirectory(parent); + const temp = path.join(parent, `.taskflow-write-${process.pid}-${crypto.randomUUID()}`); + try { + writeBufferDurable(temp, content, mode); + fs.renameSync(temp, filePath); + if (process.platform !== "win32") fs.chmodSync(filePath, mode); + fsyncDirectory(parent); + } catch (error) { + try { fs.unlinkSync(temp); } catch { /* best effort; transaction restore remains authoritative */ } + throw error; + } +} + +function pruneCreatedParents(snapshot: Snapshot): void { + let current = path.dirname(snapshot.physicalPath); + while (current !== snapshot.nearestExistingAncestor && isWithin(snapshot.nearestExistingAncestor, current)) { + try { + fs.rmdirSync(current); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + current = path.dirname(current); + continue; + } + break; + } + current = path.dirname(current); + } +} + +function currentContentId(snapshot: Snapshot): string { + const current = inspectRegularFile(snapshot.physicalPath); + return contentId(current.exists ? current.content : undefined); +} + +function restoreSnapshot(snapshot: Snapshot): void { + if (!snapshot.exists) { + try { + const stat = fs.lstatSync(snapshot.physicalPath); + if (stat.isDirectory()) throw new Error(`cannot restore over directory target: ${snapshot.physicalPath}`); + fs.unlinkSync(snapshot.physicalPath); + fsyncDirectory(path.dirname(snapshot.physicalPath)); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + pruneCreatedParents(snapshot); + return; + } + if (!snapshot.blobPath || snapshot.mode === undefined) throw new Error(`snapshot artifact is incomplete: ${snapshot.artifactId}`); + const bytes = fs.readFileSync(snapshot.blobPath); + if (contentId(bytes) !== snapshot.contentId) throw new Error(`snapshot artifact hash mismatch: ${snapshot.artifactId}`); + replaceFileAtomic(snapshot.physicalPath, bytes, snapshot.mode); +} + +function restoredEvidence(snapshots: readonly Snapshot[]): ScopedContentEvidence[] { + return snapshots.map((snapshot) => ({ + canonicalPrefix: snapshot.physicalPath, + scopeDigest: snapshot.scopeDigest, + effectId: snapshot.effectId, + capabilityBindingId: snapshot.capabilityBindingId, + beforeContentId: snapshot.contentId, + afterContentId: currentContentId(snapshot), + })); +} + +function assertExactPreState(snapshots: readonly Snapshot[]): void { + for (const snapshot of snapshots) { + if (currentContentId(snapshot) !== snapshot.contentId) { + throw new Error(`declared final path '${snapshot.logicalSubpath}' changed outside the resource transaction`); + } + } +} + +function assertExactPostState(snapshots: readonly Snapshot[], expected: ReadonlyMap): void { + for (const snapshot of snapshots) { + if (currentContentId(snapshot) !== expected.get(snapshot.effectId)) { + throw new Error(`post-state changed before durable commit for '${snapshot.logicalSubpath}'`); + } + } +} + +async function releaseBestEffort(lease: LeaseHandle): Promise { + for (let attempt = 0; attempt < 3; attempt++) { + try { + await lease.release(); + return true; + } catch { + // A terminal journal record must remain the operation result. Keep the + // authenticated handle for a later admission drain instead of making a + // committed mutation appear retryable. + } + } + return false; +} + +function removeTransactionDirectoryBestEffort(transactionDirectory: string, label: string): void { + try { + fs.rmSync(transactionDirectory, { recursive: true, force: true }); + } catch (error) { + console.warn( + `[taskflow] resource transaction ${label} deferred for ${path.basename(transactionDirectory)}: ` + + (error instanceof Error ? error.message : String(error)), + ); + } +} + +function transactionIdFromArtifact(artifactId: string): string | undefined { + return /^workspace-snapshot:([0-9a-f-]{36}):[0-9a-f]{64}$/i.exec(artifactId)?.[1]; +} + +const ORPHAN_TRANSACTION_GRACE_MS = 5 * 60_000; + +/** Remove terminal and pre-intent orphan before-images while retaining any + * pending/dirty transaction needed for recovery or explicit reconciliation. */ +export function garbageCollectResourceFileTransactions( + controlDirectory: string, + intents: readonly WriteIntentRecord[], +): void { + const retained = new Set(); + const terminal = new Set(); + for (const intent of intents) { + for (const artifactId of intent.restorableSnapshotArtifactIds ?? []) { + const transactionId = transactionIdFromArtifact(artifactId); + if (!transactionId) continue; + if (intent.status === "pending" || intent.status === "dirty-unknown") retained.add(transactionId); + else terminal.add(transactionId); + } + } + const root = path.join(controlDirectory, "file-transactions"); + let entries: string[]; + try { + entries = fs.readdirSync(root); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return; + console.warn(`[taskflow] resource transaction orphan GC deferred: ${error instanceof Error ? error.message : String(error)}`); + return; + } + for (const entry of entries) { + if (retained.has(entry)) continue; + if (!terminal.has(entry)) { + try { + const stat = fs.lstatSync(path.join(root, entry)); + if (stat.mtimeMs > Date.now() - ORPHAN_TRANSACTION_GRACE_MS) continue; + } catch { + // A concurrently removed entry is already collected. Any other inspection + // failure stays fail-safe: the best-effort remove below may still decline. + } + } + removeTransactionDirectoryBestEffort(path.join(root, entry), "orphan GC"); + } +} + +function readManifestFile(filePath: string): unknown { + const noFollow = typeof fs.constants.O_NOFOLLOW === "number" ? fs.constants.O_NOFOLLOW : 0; + const fd = fs.openSync(filePath, fs.constants.O_RDONLY | noFollow); + try { + const stat = fs.fstatSync(fd); + if (!stat.isFile() || stat.size <= 0 || stat.size > 64 * 1024) { + throw new Error(`invalid snapshot manifest: ${filePath}`); + } + return JSON.parse(fs.readFileSync(fd, "utf8")) as unknown; + } finally { + fs.closeSync(fd); + } +} + +function loadRecoverySnapshots( + controlDirectory: string, + intent: WriteIntentRecord, +): Snapshot[] { + const authorityRoot = intent.authorizationScopeRoot; + if (!authorityRoot || !path.isAbsolute(authorityRoot)) { + throw new Error(`intent ${intent.intentId} lacks an authorized recovery root`); + } + const canonicalAuthorityRoot = fs.realpathSync(authorityRoot); + const artifacts = intent.restorableSnapshotArtifactIds; + if (!artifacts || artifacts.length !== intent.scopes.length) { + throw new Error(`intent ${intent.intentId} lacks an exact snapshot artifact set`); + } + const byEffect = new Map(intent.scopes.map((scope) => [scope.effectId, scope])); + if (byEffect.size !== intent.scopes.length || byEffect.has(undefined)) { + throw new Error(`intent ${intent.intentId} lacks unique effect attribution`); + } + const snapshots: Snapshot[] = []; + let expectedTransactionId: string | undefined; + for (const artifactId of artifacts) { + const match = /^workspace-snapshot:([0-9a-f-]{36}):([0-9a-f]{64})$/i.exec(artifactId); + if (!match) throw new Error(`invalid snapshot artifact id: ${artifactId}`); + const [, transactionId, artifactStem] = match; + expectedTransactionId ??= transactionId; + if (transactionId !== expectedTransactionId) throw new Error("snapshot artifacts span multiple transactions"); + const transactionDirectory = path.join(controlDirectory, "file-transactions", transactionId!); + const canonicalTransactionDirectory = fs.realpathSync(transactionDirectory); + if (!isWithin(fs.realpathSync(controlDirectory), canonicalTransactionDirectory)) { + throw new Error("snapshot transaction directory escapes trusted control storage"); + } + const raw = readManifestFile(path.join(canonicalTransactionDirectory, `${artifactStem}.json`)); + if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error(`invalid snapshot manifest ${artifactId}`); + const manifest = raw as Record; + if ( + manifest.version !== 1 || manifest.artifactId !== artifactId || + typeof manifest.effectId !== "string" || typeof manifest.physicalPath !== "string" || + typeof manifest.logicalSubpath !== "string" || typeof manifest.exists !== "boolean" || + typeof manifest.contentId !== "string" || typeof manifest.nearestExistingAncestor !== "string" || + (manifest.mode !== undefined && (!Number.isInteger(manifest.mode) || Number(manifest.mode) < 0 || Number(manifest.mode) > 0o777)) + ) throw new Error(`invalid snapshot manifest ${artifactId}`); + const scope = byEffect.get(manifest.effectId); + if (!scope || path.normalize(manifest.physicalPath) !== scope.canonicalPrefix) { + throw new Error(`snapshot ${artifactId} does not match its durable intent scope`); + } + const physicalPath = path.normalize(manifest.physicalPath); + const ancestor = path.normalize(manifest.nearestExistingAncestor); + if (!isWithin(canonicalAuthorityRoot, physicalPath) || + !isWithin(canonicalAuthorityRoot, ancestor) || + !isWithin(ancestor, physicalPath)) { + throw new Error(`snapshot ${artifactId} escapes its authorized recovery root`); + } + const exists = manifest.exists; + const blobPath = exists ? path.join(canonicalTransactionDirectory, `${artifactStem}.blob`) : undefined; + const beforeBytes = exists ? fs.readFileSync(blobPath!) : undefined; + if (contentId(beforeBytes) !== manifest.contentId || + hash(["workspace-snapshot\0", physicalPath, "\0", String(manifest.contentId)]) !== `sha256:${artifactStem}`) { + throw new Error(`snapshot ${artifactId} failed content-address verification`); + } + snapshots.push({ + version: 1, + artifactId, + effectId: manifest.effectId, + physicalPath, + logicalSubpath: manifest.logicalSubpath, + exists, + contentId: manifest.contentId, + ...(manifest.mode === undefined ? {} : { mode: Number(manifest.mode) }), + nearestExistingAncestor: ancestor, + ...(blobPath === undefined ? {} : { blobPath }), + capabilityBindingId: scope.capabilityBindingId ?? "", + scopeDigest: scope.scopeDigest, + }); + } + return snapshots; +} + +/** Restore a stale content intent while journal recovery holds its mutex. */ +export async function recoverResourceFileIntent(options: { + controlDirectory: string; + intent: WriteIntentRecord; + leases: PersistentLeaseCoordinator; + leaseTimeoutMs: number; + signal?: AbortSignal; +}): Promise<{ + scopes: ScopedContentEvidence[]; + reason: string; + restorableSnapshotArtifactIds: string[]; +} | undefined> { + const { intent } = options; + if (intent.commitMode !== "content-snapshot" || intent.externalMutation !== "taskflow-managed") return undefined; + const snapshots = loadRecoverySnapshots(options.controlDirectory, intent); + const owner: ExecutionOwner = { + runId: `recovery-${crypto.randomUUID()}`, + phaseId: "resource-recovery", + attemptId: crypto.randomUUID(), + unitId: intent.intentId, + ancestry: [], + }; + const lease = await options.leases.acquire(intent.scopes.map((scope) => ({ + key: { resourceDomainId: intent.resourceDomainId, canonicalPrefix: scope.canonicalPrefix }, + access: "read-write" as const, + owner, + })), { timeoutMs: options.leaseTimeoutMs, signal: options.signal }); + try { + for (const snapshot of snapshots) { + const currentAncestor = nearestExistingAncestor(snapshot.physicalPath); + if (!isWithin(intent.authorizationScopeRoot!, currentAncestor)) { + throw new Error(`recovery parent for '${snapshot.logicalSubpath}' escaped its authorized root`); + } + } + for (const snapshot of [...snapshots].reverse()) restoreSnapshot(snapshot); + const scopes = restoredEvidence(snapshots); + if (scopes.some((scope) => scope.afterContentId !== scope.beforeContentId)) { + throw new Error("crash recovery did not reproduce the durable pre-state"); + } + return { + scopes, + reason: "startup recovery restored durable file-transaction pre-state", + restorableSnapshotArtifactIds: snapshots.map((snapshot) => snapshot.artifactId), + }; + } finally { + if (!(await releaseBestEffort(lease))) { + console.warn(`[taskflow] resource recovery lease cleanup deferred for lease ${lease.leaseId}`); + } + } +} + +export class PreparedResourceFileTransaction { + readonly intentId: string; + readonly #snapshots: readonly Snapshot[]; + readonly #mutation: PreparedMutation; + readonly #lease: LeaseHandle; + readonly #journal: WriteIntentJournal; + readonly #stagingDirectory: string; + readonly #onDeferredLeaseRelease?: (lease: LeaseHandle) => void; + readonly #cleanupStaging: (stagingDirectory: string) => void; + #settled = false; + #busy = false; + readonly #authorizationScopeRoot: string; + #knownCleanTerminal = false; + + constructor(input: { + snapshots: readonly Snapshot[]; + mutation: PreparedMutation; + lease: LeaseHandle; + journal: WriteIntentJournal; + stagingDirectory: string; + onDeferredLeaseRelease?: (lease: LeaseHandle) => void; + authorizationScopeRoot: string; + cleanupStaging?: (stagingDirectory: string) => void; + }) { + this.intentId = input.mutation.intent.intentId; + this.#snapshots = input.snapshots; + this.#mutation = input.mutation; + this.#lease = input.lease; + this.#journal = input.journal; + this.#stagingDirectory = input.stagingDirectory; + this.#onDeferredLeaseRelease = input.onDeferredLeaseRelease; + this.#authorizationScopeRoot = input.authorizationScopeRoot; + this.#cleanupStaging = input.cleanupStaging ?? ((stagingDirectory) => { + fs.rmSync(path.join(stagingDirectory, "staged"), { recursive: true, force: true }); + }); + } + + async commit(payloads: readonly FileWritePayload[]): Promise { + this.#assertOpen(); + this.#enterBusy(); + try { + return await this.#commitLocked(payloads); + } finally { + this.#leaveBusy(); + } + } + + async #commitLocked(payloads: readonly FileWritePayload[]): Promise { + const byId = new Map(payloads.map((payload) => [payload.effectId, payload])); + if (byId.size !== this.#snapshots.length || this.#snapshots.some((snapshot) => !byId.has(snapshot.effectId))) { + return this.#rejectAndFinish("commit-rejected", "payload ids do not exactly match the admitted effects"); + } + try { + assertExactPreState(this.#snapshots); + assertCommitContainment(this.#snapshots, this.#authorizationScopeRoot); + } catch (error) { + return this.#rejectAndFinish( + "declared-path-bypass", + error instanceof Error ? error.message : String(error), + ); + } + + const expected = new Map(); + try { + for (const snapshot of this.#snapshots) { + const payload = byId.get(snapshot.effectId)!; + const bytes = typeof payload.content === "string" ? Buffer.from(payload.content, "utf8") : payload.content; + const stagedPath = stageBlobPath(this.#stagingDirectory, snapshot.effectId); + writeBufferDurable(stagedPath, bytes); + expected.set(snapshot.effectId, contentId(bytes)); + } + for (const snapshot of this.#snapshots) { + await this.#journal.assertActive(this.#mutation.permit, this.#mutation.intent.owner); + const payload = byId.get(snapshot.effectId)!; + const bytes = typeof payload.content === "string" ? Buffer.from(payload.content, "utf8") : payload.content; + replaceFileAtomic(snapshot.physicalPath, bytes, snapshot.mode ?? 0o600, this.#authorizationScopeRoot); + } + const evidence = this.#snapshots.map((snapshot) => ({ + canonicalPrefix: snapshot.physicalPath, + scopeDigest: snapshot.scopeDigest, + effectId: snapshot.effectId, + capabilityBindingId: snapshot.capabilityBindingId, + beforeContentId: snapshot.contentId, + afterContentId: expected.get(snapshot.effectId)!, + })); + const committed = await this.#journal.commitContent( + this.intentId, + evidence, + this.#snapshots.map((snapshot) => snapshot.artifactId), + { preCommitGuard: () => assertExactPostState(this.#snapshots, expected) }, + ); + this.#settled = true; + this.#knownCleanTerminal = true; + return { + ok: true, + intentId: this.intentId, + committedPaths: this.#snapshots.map((snapshot) => snapshot.logicalSubpath), + commitGeneration: committed.commitGeneration!, + }; + } catch (error) { + return await this.#rejectAndRestore("commit-rejected", error instanceof Error ? error.message : String(error)); + } finally { + await this.#finish(); + } + } + + async reject(reason: string): Promise { + this.#assertOpen(); + this.#enterBusy(); + try { + try { + return await this.#rejectAndRestore("commit-rejected", reason || "phase rejected"); + } finally { + await this.#finish(); + } + } finally { + this.#leaveBusy(); + } + } + + async #rejectAndRestore( + code: "declared-path-bypass" | "commit-rejected", + reason: string, + ): Promise { + try { + for (const snapshot of [...this.#snapshots].reverse()) restoreSnapshot(snapshot); + const evidence = restoredEvidence(this.#snapshots); + if (evidence.some((scope) => scope.afterContentId !== scope.beforeContentId)) { + throw new Error("restored filesystem state does not match the durable pre-state snapshot"); + } + await this.#journal.abortRestored( + this.intentId, + evidence, + reason, + this.#snapshots.map((snapshot) => snapshot.artifactId), + ); + this.#settled = true; + this.#knownCleanTerminal = true; + return { ok: false, intentId: this.intentId, code, reason, restored: true }; + } catch (restoreError) { + const current = await this.#journal.getIntent(this.intentId); + if (current?.status === "pending") { + await this.#journal.markUnknown( + this.intentId, + `restore failed after ${reason}: ${restoreError instanceof Error ? restoreError.message : String(restoreError)}`, + ); + } + this.#settled = true; + throw restoreError; + } + } + + async #rejectAndFinish( + code: "declared-path-bypass" | "commit-rejected", + reason: string, + ): Promise { + try { + return await this.#rejectAndRestore(code, reason); + } finally { + await this.#finish(); + } + } + + #assertOpen(): void { + if (this.#settled) throw new Error(`resource file transaction ${this.intentId} is already settled`); + } + + #enterBusy(): void { + if (this.#busy) throw new Error(`resource file transaction ${this.intentId} is already in progress`); + this.#busy = true; + } + + #leaveBusy(): void { + this.#busy = false; + } + + async #finish(): Promise { + try { + try { + this.#cleanupStaging(this.#stagingDirectory); + } catch (error) { + console.warn( + `[taskflow] resource transaction staging cleanup deferred for ${this.intentId}: ` + + (error instanceof Error ? error.message : String(error)), + ); + } + if (this.#knownCleanTerminal) { + removeTransactionDirectoryBestEffort(this.#stagingDirectory, "snapshot GC"); + } + } finally { + if (!(await releaseBestEffort(this.#lease))) { + try { + this.#onDeferredLeaseRelease?.(this.#lease); + } catch (error) { + console.warn( + `[taskflow] resource transaction deferred lease release callback failed for ${this.#lease.leaseId}: ` + + (error instanceof Error ? error.message : String(error)), + ); + } + console.warn(`[taskflow] resource transaction lease cleanup deferred for lease ${this.#lease.leaseId}`); + } + } + } +} + +export async function prepareResourceFileTransaction( + options: ResourceFileTransactionOptions, +): Promise { + if (options.targets.length === 0) throw new Error("a resource file transaction requires at least one target"); + const seenEffects = new Set(); + const seenPaths = new Set(); + for (const target of options.targets) { + if (!target.effectId || seenEffects.has(target.effectId)) throw new Error(`duplicate or empty effect id: ${target.effectId}`); + assertSafeEffectId(target.effectId); + if (target.ref.capability.resourceDomainId !== options.resourceDomainId) { + throw new Error(`TFWS_ACCESS_ESCALATION: effect '${target.effectId}' resolved to a foreign resource domain`); + } + if (target.ref.capability.access !== "read-write") { + throw new Error(`TFWS_ACCESS_ESCALATION: effect '${target.effectId}' lacks read-write capability`); + } + if (seenPaths.has(target.ref.physicalPath)) throw new Error(`duplicate file target: ${target.ref.logicalSubpath}`); + seenEffects.add(target.effectId); + seenPaths.add(target.ref.physicalPath); + } + + let lease: LeaseHandle | undefined; + let mutation: PreparedMutation | undefined; + const transactionId = crypto.randomUUID(); + const transactionDirectory = path.join(options.controlDirectory, "file-transactions", transactionId); + try { + lease = await options.leases.acquire(options.targets.map((target) => ({ + key: { resourceDomainId: options.resourceDomainId, canonicalPrefix: target.ref.physicalPath }, + access: "read-write" as const, + owner: options.owner, + })), { + timeoutMs: options.leaseTimeoutMs, + signal: options.signal, + }); + ensureDirectory(transactionDirectory); + const snapshots: Snapshot[] = []; + for (const target of options.targets) { + const inspected = inspectRegularFile(target.ref.physicalPath); + const before = inspected.exists ? inspected.content : undefined; + const beforeId = contentId(before); + const artifactHash = hash(["workspace-snapshot\0", target.ref.physicalPath, "\0", beforeId]); + const artifactStem = artifactHash.slice("sha256:".length); + const artifactId = `workspace-snapshot:${transactionId}:${artifactStem}`; + const blobPath = inspected.exists ? path.join(transactionDirectory, `${artifactStem}.blob`) : undefined; + if (inspected.exists && blobPath) writeBufferDurable(blobPath, inspected.content); + const snapshot: Snapshot = { + version: 1, + artifactId, + effectId: target.effectId, + physicalPath: target.ref.physicalPath, + logicalSubpath: target.ref.logicalSubpath, + exists: inspected.exists, + contentId: beforeId, + ...(inspected.exists ? { mode: inspected.mode } : {}), + nearestExistingAncestor: nearestExistingAncestor(target.ref.physicalPath), + ...(blobPath ? { blobPath } : {}), + capabilityBindingId: target.ref.capability.bindingId, + scopeDigest: hash([ + options.resourceDomainId, + "\0", + target.ref.capability.bindingId, + "\0", + target.ref.logicalSubpath, + ]), + }; + writeJsonAtomicDurable(path.join(transactionDirectory, `${artifactStem}.json`), { + version: snapshot.version, + artifactId: snapshot.artifactId, + effectId: snapshot.effectId, + physicalPath: snapshot.physicalPath, + logicalSubpath: snapshot.logicalSubpath, + exists: snapshot.exists, + contentId: snapshot.contentId, + ...(snapshot.mode === undefined ? {} : { mode: snapshot.mode }), + nearestExistingAncestor: snapshot.nearestExistingAncestor, + ...(snapshot.blobPath === undefined ? {} : { blobPath: snapshot.blobPath }), + } satisfies SnapshotManifest); + snapshots.push(snapshot); + } + mutation = await options.journal.prepare({ + resourceDomainId: options.resourceDomainId, + providerInstanceId: "root", + scopes: snapshots.map((snapshot) => ({ + canonicalPrefix: snapshot.physicalPath, + scopeDigest: snapshot.scopeDigest, + effectId: snapshot.effectId, + capabilityBindingId: snapshot.capabilityBindingId, + beforeContentId: snapshot.contentId, + })), + owner: options.owner, + commitMode: "content-snapshot", + externalMutation: "taskflow-managed", + restorableSnapshotArtifactIds: snapshots.map((snapshot) => snapshot.artifactId), + authorizationPrincipalId: options.authorizationPrincipalId, + authorizationScopeRoot: options.authorizationScopeRoot, + permitTtlMs: options.permitTtlMs, + }); + await options.journal.activate([mutation.permit], options.owner); + return new PreparedResourceFileTransaction({ + snapshots, + mutation, + lease, + journal: options.journal, + stagingDirectory: transactionDirectory, + onDeferredLeaseRelease: options.onDeferredLeaseRelease, + authorizationScopeRoot: options.authorizationScopeRoot, + cleanupStaging: options.cleanupStaging, + }); + } catch (error) { + let journalError: unknown; + try { + if (mutation) { + const current = await options.journal.getIntent(mutation.intent.intentId); + if (current?.status === "pending") await options.journal.markUnknown(current.intentId, "file transaction preparation failed"); + } + } catch (cleanupError) { + journalError = cleanupError; + } finally { + if (lease && !(await releaseBestEffort(lease))) { + options.onDeferredLeaseRelease?.(lease); + console.warn(`[taskflow] resource transaction lease cleanup deferred for lease ${lease.leaseId}`); + } + if (!mutation) removeTransactionDirectoryBestEffort(transactionDirectory, "pre-intent GC"); + } + throw journalError ?? error; + } +} diff --git a/packages/taskflow-core/src/resources/journal.ts b/packages/taskflow-core/src/resources/journal.ts index d3ca2786..f3373062 100644 --- a/packages/taskflow-core/src/resources/journal.ts +++ b/packages/taskflow-core/src/resources/journal.ts @@ -29,7 +29,13 @@ import { export type { ExternalMutationModel, ScopedContentEvidence, VersionCommitMode } from "./types.ts"; export type { MutationPermit } from "./permits.ts"; -export type WriteIntentStatus = "pending" | "committed-content" | "committed-generation" | "dirty-unknown" | "reconciled"; +export type WriteIntentStatus = + | "pending" + | "committed-content" + | "committed-generation" + | "aborted-restored" + | "dirty-unknown" + | "reconciled"; export interface WriteIntentRecord { journalVersion: 1; @@ -46,6 +52,9 @@ export interface WriteIntentRecord { externalMutation: ExternalMutationModel; status: WriteIntentStatus; restorableSnapshotArtifactIds?: string[]; + terminalReason?: string; + authorizationPrincipalId?: string; + authorizationScopeRoot?: string; } export interface RecoverPendingOptions { @@ -56,6 +65,15 @@ export interface RecoverPendingOptions { * recovery append. */ isOwnerActive?: (owner: ExecutionOwner) => boolean | Promise; + /** Trusted resource backend hook. Return exact before=after evidence only + * after restoring every scope from durable pre-state artifacts. */ + recoverKnownClean?: ( + intent: WriteIntentRecord, + ) => Promise<{ + scopes: readonly ScopedContentEvidence[]; + reason: string; + restorableSnapshotArtifactIds?: readonly string[]; + } | undefined>; } export interface PrepareWriteIntent { @@ -68,6 +86,11 @@ export interface PrepareWriteIntent { beforeGeneration?: number; commitMode: VersionCommitMode; externalMutation: ExternalMutationModel; + /** Durable pre-state artifacts captured before permit activation. */ + restorableSnapshotArtifactIds?: readonly string[]; + /** Host-authenticated principal; flow JSON cannot supply this value. */ + authorizationPrincipalId?: string; + authorizationScopeRoot?: string; permitTtlMs?: number; } @@ -112,6 +135,17 @@ interface UnknownWalRecord { reason: string; } +interface AbortRestoredWalRecord { + journalVersion: 1; + type: "write-abort-restored"; + ts: string; + intentId: string; + resourceDomainId: string; + reason: string; + scopes: ScopedContentEvidence[]; + restorableSnapshotArtifactIds?: string[]; +} + interface ReconcileWalRecord { journalVersion: 1; type: "write-reconcile"; @@ -122,7 +156,13 @@ interface ReconcileWalRecord { reason: string; } -export type JournalWalRecord = IntentWalRecord | CommitContentWalRecord | CommitGenerationWalRecord | UnknownWalRecord | ReconcileWalRecord; +export type JournalWalRecord = + | IntentWalRecord + | CommitContentWalRecord + | CommitGenerationWalRecord + | AbortRestoredWalRecord + | UnknownWalRecord + | ReconcileWalRecord; interface JournalProjection { intents: Map; @@ -142,6 +182,7 @@ export interface PreparedMutation { export interface RecoveryResult { recoveredIntentIds: string[]; + restoredIntentIds: string[]; dirtyDomains: string[]; } @@ -165,6 +206,8 @@ function cloneEvidence(scope: ScopedContentEvidence): ScopedContentEvidence { return { canonicalPrefix: normalizeCanonicalPrefix(scope.canonicalPrefix), scopeDigest: scope.scopeDigest, + ...(scope.effectId === undefined ? {} : { effectId: scope.effectId }), + ...(scope.capabilityBindingId === undefined ? {} : { capabilityBindingId: scope.capabilityBindingId }), ...(scope.beforeContentId === undefined ? {} : { beforeContentId: scope.beforeContentId }), ...(scope.afterContentId === undefined ? {} : { afterContentId: scope.afterContentId }), }; @@ -241,6 +284,27 @@ function fold(records: readonly JournalWalRecord[]): JournalProjection { if (record.resourceDomainId !== intent.resourceDomainId) throw new JournalStateError(`Intent ${record.intentId} domain mismatch`); if (record.type === "write-unknown") { intent.status = "dirty-unknown"; + intent.terminalReason = record.reason; + continue; + } + if (record.type === "write-abort-restored") { + if (!sameCanonicalScopes(intentScopeKeys(intent), record.scopes.map((scope) => ({ + resourceDomainId: intent.resourceDomainId, + canonicalPrefix: scope.canonicalPrefix, + })))) { + throw new JournalStateError(`Restored scopes do not match intent ${record.intentId}`); + } + for (const scope of record.scopes) { + if (!scope.beforeContentId || scope.afterContentId !== scope.beforeContentId) { + throw new JournalStateError(`Restored scope ${scope.canonicalPrefix} does not prove before=after`); + } + } + intent.status = "aborted-restored"; + intent.scopes = structuredClone(record.scopes); + intent.restorableSnapshotArtifactIds = record.restorableSnapshotArtifactIds + ? [...record.restorableSnapshotArtifactIds] + : undefined; + intent.terminalReason = record.reason; continue; } const previousGeneration = domainGenerations.get(record.resourceDomainId) ?? 0; @@ -304,6 +368,12 @@ export class WriteIntentJournal { throw new Error("beforeGeneration must be a non-negative safe integer"); } if (!input.resourceDomainId) throw new Error("resourceDomainId must be non-empty"); + if (input.authorizationPrincipalId !== undefined && !input.authorizationPrincipalId.trim()) { + throw new Error("authorizationPrincipalId must be non-empty when supplied"); + } + if (input.authorizationScopeRoot !== undefined && !path.isAbsolute(input.authorizationScopeRoot)) { + throw new Error("authorizationScopeRoot must be absolute when supplied"); + } if (!(["content-snapshot", "generation-only", "unavailable"] as const).includes(input.commitMode)) throw new Error(`Unsupported commitMode ${String(input.commitMode)}`); if (!(["taskflow-managed", "externally-mutable"] as const).includes(input.externalMutation)) throw new Error(`Unsupported externalMutation ${String(input.externalMutation)}`); const scopes = normalizeEvidence(input.scopes); @@ -337,6 +407,15 @@ export class WriteIntentJournal { commitMode: input.commitMode, externalMutation: input.externalMutation, status: "pending", + ...(input.authorizationPrincipalId === undefined + ? {} + : { authorizationPrincipalId: input.authorizationPrincipalId }), + ...(input.authorizationScopeRoot === undefined + ? {} + : { authorizationScopeRoot: path.normalize(input.authorizationScopeRoot) }), + ...(input.restorableSnapshotArtifactIds === undefined + ? {} + : { restorableSnapshotArtifactIds: [...input.restorableSnapshotArtifactIds] }), }; this.#append([{ journalVersion: 1, type: "write-intent", ts: nowIso, intent }]); try { @@ -476,10 +555,63 @@ export class WriteIntentJournal { await this.#settleTerminalPermit(intentId); const dirty = cloneIntent(intent); dirty.status = "dirty-unknown"; + dirty.terminalReason = reason || "mutation outcome unknown"; return cloneIntent(dirty); }); } + /** + * Settle an active mutation without advancing the resource generation after + * the caller has restored every admitted scope to its exact pre-state. + * This is a known-clean terminal state, not reconciliation: callers must + * provide content evidence proving `beforeContentId === afterContentId` for + * every originally admitted scope. + */ + async abortRestored( + intentId: string, + scopes: readonly ScopedContentEvidence[], + reason: string, + restorableSnapshotArtifactIds?: readonly string[], + ): Promise { + const normalized = normalizeEvidence(scopes); + if (normalized.some((scope) => !scope.beforeContentId || scope.afterContentId !== scope.beforeContentId)) { + throw new JournalStateError("abort-restored requires beforeContentId === afterContentId for every scope"); + } + return this.#mutex.runExclusive(async () => { + const intent = this.#projection().intents.get(intentId); + if (!intent || intent.status !== "pending") throw new JournalStateError(`Intent ${intentId} is not pending`); + if (!sameCanonicalScopes(intentScopeKeys(intent), normalized.map((scope) => ({ + resourceDomainId: intent.resourceDomainId, + canonicalPrefix: scope.canonicalPrefix, + })))) { + throw new JournalStateError(`Restored content scopes do not match intent ${intentId}`); + } + await this.permits.assertIntentActive(intentId, intent.owner); + const terminalReason = reason || "mutation rejected and pre-state restored"; + this.#append([{ + journalVersion: 1, + type: "write-abort-restored", + ts: new Date(this.now()).toISOString(), + intentId, + resourceDomainId: intent.resourceDomainId, + reason: terminalReason, + scopes: normalized, + ...(restorableSnapshotArtifactIds === undefined + ? {} + : { restorableSnapshotArtifactIds: [...restorableSnapshotArtifactIds] }), + }]); + await this.#settleTerminalPermit(intentId); + const aborted = cloneIntent(intent); + aborted.status = "aborted-restored"; + aborted.scopes = structuredClone(normalized); + aborted.restorableSnapshotArtifactIds = restorableSnapshotArtifactIds + ? [...restorableSnapshotArtifactIds] + : undefined; + aborted.terminalReason = terminalReason; + return cloneIntent(aborted); + }); + } + /** Explicitly acknowledge the current external filesystem state for one * resource domain. Callers must hold an exclusive whole-domain lease; this * method serializes the durable decision and advances the generation so no @@ -541,8 +673,9 @@ export class WriteIntentJournal { } } - /** Startup recovery: any fsynced intent without a terminal WAL record and - * without a live mutation owner becomes dirty-unknown before reuse. */ + /** Startup recovery: a stale taskflow-managed content intent may first be + * restored by its trusted resource backend. Every other fsynced intent + * without a terminal WAL record becomes dirty-unknown before reuse. */ async recoverPending( reason = "startup recovery found an uncommitted write intent", options: RecoverPendingOptions = {}, @@ -550,21 +683,67 @@ export class WriteIntentJournal { return this.#mutex.runExclusive(async () => { const projection = this.#projection(); const pending: WriteIntentRecord[] = []; + const restored: Array<{ + intent: WriteIntentRecord; + scopes: ScopedContentEvidence[]; + reason: string; + artifacts?: string[]; + }> = []; + const dirty: WriteIntentRecord[] = []; for (const intent of projection.intents.values()) { if (intent.status !== "pending") continue; if (options.isOwnerActive && await options.isOwnerActive(cloneExecutionOwner(intent.owner))) continue; pending.push(intent); + let recovered: Awaited>>; + try { + recovered = await options.recoverKnownClean?.(cloneIntent(intent)); + } catch { + recovered = undefined; + } + if (recovered) { + const scopes = normalizeEvidence(recovered.scopes); + const exact = sameCanonicalScopes(intentScopeKeys(intent), scopes.map((scope) => ({ + resourceDomainId: intent.resourceDomainId, + canonicalPrefix: scope.canonicalPrefix, + }))); + const clean = scopes.every((scope) => + typeof scope.beforeContentId === "string" && scope.afterContentId === scope.beforeContentId); + if (exact && clean) { + restored.push({ + intent, + scopes, + reason: recovered.reason || reason, + ...(recovered.restorableSnapshotArtifactIds === undefined + ? {} + : { artifacts: [...recovered.restorableSnapshotArtifactIds] }), + }); + continue; + } + } + dirty.push(intent); } if (pending.length > 0) { const ts = new Date(this.now()).toISOString(); - this.#append(pending.map((intent): UnknownWalRecord => ({ - journalVersion: 1, - type: "write-unknown", - ts, - intentId: intent.intentId, - resourceDomainId: intent.resourceDomainId, - reason, - }))); + this.#append([ + ...restored.map(({ intent, scopes, reason: restoredReason, artifacts }): AbortRestoredWalRecord => ({ + journalVersion: 1, + type: "write-abort-restored", + ts, + intentId: intent.intentId, + resourceDomainId: intent.resourceDomainId, + reason: restoredReason, + scopes, + ...(artifacts === undefined ? {} : { restorableSnapshotArtifactIds: artifacts }), + })), + ...dirty.map((intent): UnknownWalRecord => ({ + journalVersion: 1, + type: "write-unknown", + ts, + intentId: intent.intentId, + resourceDomainId: intent.resourceDomainId, + reason, + })), + ]); } // Also closes the crash window after a durable commit/unknown append but // before the permit registry transition was persisted. Retained pending @@ -576,7 +755,8 @@ export class WriteIntentJournal { for (const intent of settle) await this.#settleTerminalPermit(intent.intentId); return { recoveredIntentIds: pending.map((intent) => intent.intentId), - dirtyDomains: [...new Set(pending.map((intent) => intent.resourceDomainId))].sort(), + restoredIntentIds: restored.map(({ intent }) => intent.intentId), + dirtyDomains: [...new Set(dirty.map((intent) => intent.resourceDomainId))].sort(), }; }); } diff --git a/packages/taskflow-core/src/resources/types.ts b/packages/taskflow-core/src/resources/types.ts index cc31c963..9554f8f1 100644 --- a/packages/taskflow-core/src/resources/types.ts +++ b/packages/taskflow-core/src/resources/types.ts @@ -24,6 +24,10 @@ export interface LeaseRequest { export interface ScopedContentEvidence { canonicalPrefix: string; scopeDigest: string; + /** Stable declaration id when this scope originates from Trusted Effects. */ + effectId?: string; + /** Capability binding that resolved and authorized this exact scope. */ + capabilityBindingId?: string; beforeContentId?: string; afterContentId?: string; } diff --git a/packages/taskflow-core/src/runtime.ts b/packages/taskflow-core/src/runtime.ts index 4b0b905f..fab15cb0 100644 --- a/packages/taskflow-core/src/runtime.ts +++ b/packages/taskflow-core/src/runtime.ts @@ -832,6 +832,51 @@ function flowTreeUsesCwdBridge( return false; } +function flowTreeUsesDeclaredEffects( + def: Taskflow, + loadFlow: RuntimeDeps["loadFlow"], + seenUses = new Set(), +): boolean { + if (def.contextSharing === true || def.phases.some((phase) => phase.shareContext === true)) return true; + if (def.phases.some((phase) => (phase as { effects?: unknown }).effects !== undefined)) return true; + for (const phase of def.phases) { + const type = phase.type ?? "agent"; + if ((type === "flow" || type === "expand") && phase.def !== undefined) { + // Inline definitions are resolved before their parent phase cache lookup. + // Statically inspect authored object/JSON forms. An interpolated/malformed + // form is capability-bearing until resolution proves otherwise: otherwise a + // prior parent cache row could skip a newly generated resource transaction. + const parsed = typeof phase.def === "string" ? safeParse(phase.def) : phase.def; + if ( + parsed === undefined && + typeof phase.def === "string" && + !/[{](?:steps[.]|args[.]|previous[.]|item(?:[.}]|\b)|loop[.]|reflexion[}])/.test(phase.def) + ) { + // A malformed authored literal cannot resolve into a child at runtime; + // preserve the established fail-open/cleanup path. Interpolated strings + // remain unknown and therefore authority-bearing until resolved. + continue; + } + const child = normalizeInlineDef(parsed, phase.id); + if (!child) return true; + if (flowTreeUsesDeclaredEffects(child, loadFlow, seenUses)) return true; + } + if (type === "flow" && phase.use) { + if (seenUses.has(phase.use)) continue; + seenUses.add(phase.use); + if (!loadFlow) return true; + try { + const child = loadFlow(phase.use); + if (child && flowTreeUsesDeclaredEffects(child, loadFlow, seenUses)) return true; + } catch { + // A mutable/unavailable saved flow is authority-bearing until proven otherwise. + return true; + } + } + } + return false; +} + /** * Freeze the saved-flow namespace for one top-level execution. Capability * discovery and phase execution must observe the same definition, including @@ -1154,10 +1199,142 @@ async function executePhaseImpl( } } return ps; + }; + const executeInnerWithDeclaredEffects = async (innerDeps: RuntimeDeps): Promise => { + const effects = (phase as { effects?: unknown }).effects; + if (effects === undefined || (Array.isArray(effects) && effects.length === 0)) { + return executePhaseInner(phase, state, innerDeps, prior, emitProgress, _retryDepth, innerOpts); + } + if (!Array.isArray(effects)) { + return { + id: phase.id, + status: "failed", + error: "trusted-effects admission failed (effects-not-array): effects must be an array", + endedAt: Date.now(), + usage: emptyUsage(), + }; + } + const te = await import("./effects/index.ts"); + const effectValidation = te.validateDeclaredEffectsBeforeAdmission(effects); + if (!effectValidation.ok) { + return { + id: phase.id, + status: "failed", + error: `trusted-effects admission failed (${effectValidation.code}): ${effectValidation.reason}`, + endedAt: Date.now(), + usage: emptyUsage(), + }; + } + const hasWrites = te.hasDeclaredFsWriteEffects(effects); + let binding = innerDeps._workspaceBinding; + if (hasWrites && !binding) { + const effectiveCwd = resolveEffCwd(innerDeps, phase); + try { + binding = await innerDeps.workspaceSession?.bindPhase({ + invocationRoot: effectiveCwd, + runId: state.runId, + phaseId: phase.id, + argDefinitions: state.def.args ?? {}, + argValues: state.args, + }); + } catch (error) { + return { + id: phase.id, + status: "failed", + error: error instanceof Error ? error.message : String(error), + endedAt: Date.now(), + usage: emptyUsage(), + }; + } + } + if (hasWrites && !binding) { + return { + id: phase.id, + status: "failed", + error: "TFWS_RESOURCE_AUTHORITY_UNAVAILABLE: declared fs.write requires a workspace authority binding", + endedAt: Date.now(), + usage: emptyUsage(), + }; + } + const admitted = binding + ? await te.preparePhaseDeclaredFsWrites(binding, { effects, signal: innerDeps.signal }) + : { ok: true as const, prepared: undefined }; + if (!admitted.ok) { + return { + id: phase.id, + status: "failed", + error: `trusted-effects admission failed (${admitted.code}): ${admitted.reason}`, + endedAt: Date.now(), + usage: emptyUsage(), + }; + } + let ps: PhaseState; + try { + // The file transaction owns admission for declared targets. Do not open a + // second broad generation-only intent around each nested runner call. + ps = await executePhaseInner( + phase, + state, + hasWrites + ? { ...innerDeps, _workspaceBinding: undefined, _disableCache: true } + : innerDeps, + prior, + emitProgress, + _retryDepth, + innerOpts, + ); + } catch (error) { + if (admitted.prepared) { + await te.rejectPreparedDeclaredFsWrites( + admitted.prepared, + `phase body threw: ${error instanceof Error ? error.message : String(error)}`, + ); + } + throw error; + } + if (!admitted.prepared) return ps; + if (ps.status !== "done") { + try { + await te.rejectPreparedDeclaredFsWrites( + admitted.prepared, + ps.error ?? `phase ended with status ${ps.status}`, + ); + } catch (error) { + return { + ...ps, + status: "failed", + error: `${ps.error ?? "phase failed"}; trusted-effects restore failed: ${error instanceof Error ? error.message : String(error)}`, + endedAt: Date.now(), + }; + } + return ps; + } + const finalized = await te.finalizePreparedDeclaredFsWrites(admitted.prepared, ps.output ?? ""); + if (!finalized.ok) { + return { + ...ps, + status: "failed", + error: `trusted-effects finalize failed (${finalized.code}): ${finalized.reason}`, + sideEffect: true, + endedAt: Date.now(), + }; + } + return { + ...ps, + sideEffect: finalized.committedPaths.length > 0 || ps.sideEffect, + warnings: finalized.committedPaths.length === 0 + ? ps.warnings + : [ + ...(ps.warnings ?? []), + `trusted-effects: committed ${finalized.committedPaths.join(", ")} via resource intent ${finalized.intentId}`, + ], + }; }; const cwdArg = cwdArgName(phase.cwd); let innerOpts: PhaseExecOpts = { ...opts, upstreamDeps: deps }; - if ((cwdArg !== undefined || deps._dynamic === true || deps._cwdBoundary !== undefined) && phase.when !== undefined) { + if ((cwdArg !== undefined || deps._dynamic === true || deps._cwdBoundary !== undefined || + (Array.isArray((phase as { effects?: unknown }).effects) && ((phase as { effects?: unknown[] }).effects?.length ?? 0) > 0)) && + phase.when !== undefined) { const whenReadRefs: string[] = []; const whenCtx = buildInterpolationContext( state, @@ -1259,7 +1436,7 @@ async function executePhaseImpl( _disableCache: true, _workspaceBinding: workspaceBinding, }; - const ps = await executePhaseInner(phase, state, innerDeps, prior, emitProgress, _retryDepth, innerOpts); + const ps = await executeInnerWithDeclaredEffects(innerDeps); ps.warnings = [ ...(ps.warnings ?? []), `cwd bridge: resolve-only {args.${cwdArg}} -> ${bound.value.logicalPath}; principal/root authorization, cross-process lease, and write journal are active, but filesystem access outside this directory is not sandbox-enforced`, @@ -1304,25 +1481,17 @@ async function executePhaseImpl( usage: emptyUsage(), }); } - return stamp(await executePhaseInner( - phase, - state, - { + return stamp(await executeInnerWithDeclaredEffects({ ...deps, _cwdOverride: selected.canonicalPath, _cwdBoundary: selected.canonicalPath, _cacheCwdIdentity: selected.canonicalPath, _workspaceBinding: narrowedBinding, - }, - prior, - emitProgress, - _retryDepth, - innerOpts, - )); + })); } // Non-keyword cwd (or none): no workspace lifecycle — run directly. if (!isWorkspaceKeyword(phase.cwd)) { - return stamp(await executePhaseInner(phase, state, deps, prior, emitProgress, _retryDepth, innerOpts)); + return stamp(await executeInnerWithDeclaredEffects(deps)); } let ws: Workspace | undefined; try { @@ -1337,7 +1506,7 @@ async function executePhaseImpl( } const innerDeps: RuntimeDeps = ws ? { ...deps, _cwdOverride: ws.dir, _cacheCwdIdentity: ws.dir } : deps; try { - const ps = await executePhaseInner(phase, state, innerDeps, prior, emitProgress, _retryDepth, innerOpts); + const ps = await executeInnerWithDeclaredEffects(innerDeps); if (ws && (ws.kind !== "inherited" || ws.note)) { const tag = ws.kind === "inherited" ? "workspace" : `workspace:${ws.kind}`; const msg = ws.note ? `${tag} — ${ws.note}` : `${tag} at ${ws.dir}`; @@ -1466,6 +1635,13 @@ async function executePhaseInner( if (phase.idempotent === false) { cacheScope = "off"; } + // Declared fs.write → resource transaction finalize; never cache-skip promotion. + if ( + Array.isArray((phase as { effects?: unknown }).effects) && + ((phase as { effects?: { kind?: string }[] }).effects ?? []).some((e) => e?.kind === "fs.write") + ) { + cacheScope = "off"; + } if (deps._disableCache) { cacheScope = "off"; } @@ -1481,7 +1657,7 @@ async function executePhaseInner( flowDefHash: state.flowDefHash === "failed" ? undefined : state.flowDefHash, phaseFp: state.phaseFingerprints?.[phase.id], forceRerun: opts?.forceRerun, - thinking: phase.thinking, + thinking: phase.thinking ?? deps.globalThinking, tools: phase.tools, preRead, agentScope: state.def.agentScope, @@ -2945,7 +3121,9 @@ async function executePhaseInner( // change between the root pre-scan and this phase (or return aliases), and // a bridge-bearing child must never be skipped by a cached parent result. const nestedBridgeTree = flowTreeUsesCwdBridge(subDef, deps.loadFlow); - if (nestedBridgeTree) deps._disableCache = true; + const nestedEffectsTree = flowTreeUsesDeclaredEffects(subDef, deps.loadFlow); + const nestedResourceTree = nestedBridgeTree || nestedEffectsTree; + if (nestedResourceTree) deps._disableCache = true; // Plugin-error verifier preflight (no-spend gate) BEFORE cache/resume reuse, // for SAVED-USE children only. Inline-def children are already gated by the // verifyTaskflow in the hasDef branch above (plugin errors fail-close, @@ -2965,7 +3143,7 @@ async function executePhaseInner( return failPhase(phase.id, `flow phase '${phase.id}': sub-flow '${subDef.name}' failed verifier preflight: ${flowPluginErrors.join("; ")}`); } } - const flowCc: PhaseCacheCtx = nestedBridgeTree ? { ...cc, scope: "off" } : cc; + const flowCc: PhaseCacheCtx = nestedResourceTree ? { ...cc, scope: "off" } : cc; // Every sub-flow cache identity includes the resolved definition. A saved // flow's name alone is insufficient: its contents can change without the // parent definition moving. @@ -4265,11 +4443,27 @@ export async function executeTaskflow(state: RunState, deps: RuntimeDeps): Promi // mutations or let downstream phases observe stale files. Disable cache and // within-run resume reuse across the complete reachable flow tree. const bridgeTree = flowTreeUsesCwdBridge(def, deps.loadFlow); + const effectsTree = flowTreeUsesDeclaredEffects(def, deps.loadFlow); + const resourceTree = bridgeTree || effectsTree; + if (effectsTree) { + const { validateComposedEffectFlow } = await import("./effects/validate.ts"); + const labelFlow = validateComposedEffectFlow({ name: def.name, phases: def.phases }, { + resolveFlow: deps.loadFlow, + }); + if (!labelFlow.ok) { + return failBeforeExecution( + `Taskflow '${def.name}' EffectIR label flow is invalid: ${labelFlow.issues + .filter((issue) => issue.severity === "error") + .map((issue) => issue.message) + .join("; ")}`, + ); + } + } // Persisted binding is a permanent taint bit: saved-flow definitions can // change between resumes, but prior outputs may already depend on filesystem // mutations. Never regain cache/rebind privileges merely because the current // snapshot no longer declares the bridge. - const bridgeTainted = bridgeTree || state.cwdRootBinding !== undefined; + const bridgeTainted = resourceTree || state.cwdRootBinding !== undefined; if (bridgeTainted) { const invocationRoot = directoryIdentity(deps.cwd); const statePathRoot = directoryIdentity(state.cwd); @@ -4281,7 +4475,7 @@ export async function executeTaskflow(state: RunState, deps: RuntimeDeps): Promi // phase previously executed without a persisted root binding. Conversely, // a host's launch snapshot proves root continuity, not prior bridge // authorization: adding a bridge after ordinary phases ran still fails. - const isLegacyResume = bridgeTree && recordedRoot === undefined && hasExecutablePriorState; + const isLegacyResume = resourceTree && recordedRoot === undefined && hasExecutablePriorState; if ( isLegacyResume || !sameDirectoryIdentity(statePathRoot, invocationRoot) || @@ -4289,7 +4483,7 @@ export async function executeTaskflow(state: RunState, deps: RuntimeDeps): Promi (recordedRoot !== undefined && !sameDirectoryIdentity(recordedRoot, invocationRoot)) ) { return failBeforeExecution( - `Taskflow '${def.name}' cwd-bridge invocation root does not match the run's persisted root; start a new run instead of rebinding on resume`, + `Taskflow '${def.name}' resource-authority invocation root does not match the run's persisted root; start a new run instead of rebinding on resume`, ); } state.cwdRootBinding ??= invocationRoot; @@ -4305,7 +4499,7 @@ export async function executeTaskflow(state: RunState, deps: RuntimeDeps): Promi // control/durability scaffold. This does not upgrade its assurance: the // session is deliberately labelled resolve-only and no OS sandbox claim is // made. A native session must come from an exact approved host baseline cell. - if (bridgeTree && deps.cwdBridgeMode === "resolve-only" && !deps.workspaceSession) { + if (resourceTree && (effectsTree || deps.cwdBridgeMode === "resolve-only") && !deps.workspaceSession) { try { deps = { ...deps, @@ -4372,7 +4566,7 @@ export async function executeTaskflow(state: RunState, deps: RuntimeDeps): Promi // not yet persist compatible input hashes, so it must never blindly trust a // prior `done` row. const hasPriorState = Object.keys(state.phases).length > 0; - if (eventKernelEnabled(deps) && deps._cwdBoundary === undefined && !hasPriorState && canUseEventKernel(def, deps.loadFlow)) { + if (eventKernelEnabled(deps) && !effectsTree && deps._cwdBoundary === undefined && !hasPriorState && canUseEventKernel(def, deps.loadFlow)) { if (!deps.runTask) { throw new Error("event kernel requires RuntimeDeps.runTask"); } diff --git a/packages/taskflow-core/src/runtime/phases/script.ts b/packages/taskflow-core/src/runtime/phases/script.ts index 95e503c6..6900199c 100644 --- a/packages/taskflow-core/src/runtime/phases/script.ts +++ b/packages/taskflow-core/src/runtime/phases/script.ts @@ -1,6 +1,12 @@ /** * Script phase — zero-token shell command execution. * Isolated from runtime.ts so S5 strangler can flip kinds without growing the monolith. + * + * Trusted Effects (0.3): when a phase declares `fs.write` effects, the runtime + * (imperative path + event-kernel step) pre-snapshots declared finals, runs the + * script (content via stdout only — **must not** write those finals), then + * promotes through the phase-level resource transaction owned by runtime.ts. + * Mid-phase writes to declared finals fail closed (`declared-path-bypass`). */ import type { Phase } from "../../schema.ts"; diff --git a/packages/taskflow-core/src/schema.ts b/packages/taskflow-core/src/schema.ts index ea35c4b3..8deaa921 100644 --- a/packages/taskflow-core/src/schema.ts +++ b/packages/taskflow-core/src/schema.ts @@ -13,6 +13,8 @@ import { Type, type Static } from "typebox"; import { Errors as SchemaErrors } from "typebox/value"; import { cwdArgName, hasCwdPlaceholder, normalizeRelativePath } from "./cwd-bridge.ts"; import { WORKSPACE_KEYWORDS } from "./workspace.ts"; +import { EffectDeclSchema } from "./effects/schema.ts"; +import { validateComposedEffectFlow, type ComposedEffectFlowLike } from "./effects/validate.ts"; // --------------------------------------------------------------------------- // Phase types @@ -374,6 +376,17 @@ const PhaseSchema = Type.Object( default: true, }), ), + /** + * Trusted Effects (0.3 MVP): declared side effects for this phase. + * Validated by the effects verifier (`validateEffectIR`). See + * docs/internal/0.3.0-trusted-effects-mvp.md and effects/types.ts. + */ + effects: Type.Optional( + Type.Array(EffectDeclSchema, { + description: + "[0.3 Trusted Effects] Declared side effects (fs.read/write/delete, secret.read, service.call) with PathRef/SecretRef/ServiceRef targets and optional confidentiality/integrity labels.", + }), + ), concurrency: Type.Optional(Type.Number({ description: "Override max concurrency for map/parallel" })), context: Type.Optional( Type.Array(Type.String(), { @@ -772,6 +785,11 @@ export interface ValidationOptions { * (phase count, map items, concurrency) and denial of cwd/context/script * resource capabilities until a FileBroker/sandbox exists. */ dynamic?: boolean; + /** Optional saved-flow loader used to resolve `flow{use}` children during + * effect label-flow validation. When provided, resolved children are checked + * with their real effects; children the loader cannot resolve degrade to + * advisory warnings (the runtime loader remains the authoritative gate). */ + resolveFlow?: (name: string) => ComposedEffectFlowLike | undefined; } type ArgSpecRecord = Record & { @@ -1528,6 +1546,24 @@ export function validateTaskflow(def: unknown, opts: ValidationOptions = {}): Va } } + // Cycle detection (Kahn) + try { + const labelFlow = validateComposedEffectFlow({ name: flow.name, phases: flow.phases as Phase[] }, { + // Static gates have no flow store: an unresolved `flow{use}` child is + // advisory (the runtime loader is the authoritative admission gate), + // not a hard confidentiality taint. + downgradeUnresolvedUse: true, + resolveFlow: opts.resolveFlow, + }); + for (const issue of labelFlow.issues) { + const message = `[effects] ${issue.message}`; + if (issue.severity === "error") errors.push(message); + else warnings.push(message); + } + } catch (error) { + errors.push(`[effects] label-flow validation failed closed: ${error instanceof Error ? error.message : String(error)}`); + } + // Cycle detection (Kahn) if (errors.length === 0) { const cycle = detectCycle(flow.phases as Phase[]); diff --git a/packages/taskflow-core/src/store.ts b/packages/taskflow-core/src/store.ts index f175f0e8..a2d37c10 100644 --- a/packages/taskflow-core/src/store.ts +++ b/packages/taskflow-core/src/store.ts @@ -16,7 +16,6 @@ import * as crypto from "node:crypto"; import * as fs from "node:fs"; -import * as os from "node:os"; import * as path from "node:path"; import { parseJsonc } from "./jsonc.ts"; import { getAgentDir } from "./paths.ts"; @@ -27,6 +26,7 @@ import type { UsageStats } from "./usage.ts"; import type { DeclaredDeps } from "./flowir/meta.ts"; import type { ScorerResult } from "./scorers.ts"; import type { FlowMeta } from "./library/types.ts"; +import { findProjectTaskflowsDir, canonicalDiscoveryPath, sameDiscoveryPath } from "./discovery-boundary.ts"; export interface SavedFlow { name: string; @@ -1028,39 +1028,14 @@ function userFlowsDir(): string { return path.join(getAgentDir(), "taskflows"); } -function canonicalDiscoveryPath(input: string): string { - const absolute = path.resolve(input); - try { - return fs.realpathSync.native(absolute); - } catch { - return absolute; - } -} - -function sameDiscoveryPath(a: string, b: string): boolean { - if (process.platform === "win32") return a.toLowerCase() === b.toLowerCase(); - return a === b; -} - function findProjectFlowsDirInternal(cwd: string, create = false): string | null { - // Prefer an existing .pi dir up the tree; else use cwd/.pi when creating. - // **Never inherit `~/.pi/` or the shared OS temp root's `.pi/` while walking - // ancestors.** Resolve physical paths first so relative cwd values and symlink - // aliases cannot bypass either boundary. An explicit create at cwd still uses - // cwd/.pi; only ancestor discovery stops at these user/shared boundaries. - const home = canonicalDiscoveryPath(os.homedir()); - const tempRoot = canonicalDiscoveryPath(os.tmpdir()); + // Prefer an existing .pi dir up the tree (shared boundary helper); else use + // cwd/.pi when creating. Never inherit ~/.pi or temp .pi via walk/symlink. + const existing = findProjectTaskflowsDir(cwd); + if (existing) return existing; + if (!create) return null; const canonicalCwd = canonicalDiscoveryPath(cwd); - let dir = canonicalCwd; - while (true) { - if (sameDiscoveryPath(dir, home) || sameDiscoveryPath(dir, tempRoot)) break; - const candidate = path.join(dir, ".pi"); - if (fs.existsSync(candidate)) return path.join(candidate, "taskflows"); - const parent = path.dirname(dir); - if (parent === dir) break; - dir = parent; - } - return create ? path.join(canonicalCwd, ".pi", "taskflows") : null; + return path.join(canonicalCwd, ".pi", "taskflows"); } const MAX_FLOW_DEFINITION_BYTES = 1_048_576; // 1 MiB per JSON/JSONC/defineFile @@ -1271,6 +1246,11 @@ function isPhysicallyContained(rootReal: string, candidateReal: string): boolean return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); } +/** Diagnostics are persisted/displayed as portable paths, never host-native separators. */ +function portableRelativePath(root: string, candidate: string): string { + return path.relative(root, candidate).split(path.sep).join("/"); +} + /** Validate every directory component from a trusted boundary (`.pi` for a * project, agent root for user flows) through the taskflows storage root. * Only an explicitly configured user agent boundary may itself be a symlink. */ @@ -1485,13 +1465,13 @@ function discoverFlows(cwd: string): FlowDiscoveryResult { } else { diagnostics.push( `[taskflow] duplicate saved flow name '${r.value.name}' in ${scope} scope; ` + - `using ${path.relative(rootReal, existing.filePath)} and ignoring ${path.relative(rootReal, filePath)}`, + `using ${portableRelativePath(rootReal, existing.filePath)} and ignoring ${portableRelativePath(rootReal, filePath)}`, ); } } else if (r.reason === "unparseable") { failures.push({ scope, filePath, result: r }); diagnostics.push( - `[taskflow] saved flow is corrupt and was excluded from the list: ${path.relative(rootReal, filePath)} — ${r.detail}`, + `[taskflow] saved flow is corrupt and was excluded from the list: ${portableRelativePath(rootReal, filePath)} — ${r.detail}`, ); } } diff --git a/packages/taskflow-core/src/verifiers/discover.ts b/packages/taskflow-core/src/verifiers/discover.ts index c6521099..b7eba1af 100644 --- a/packages/taskflow-core/src/verifiers/discover.ts +++ b/packages/taskflow-core/src/verifiers/discover.ts @@ -18,27 +18,12 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; +import { findProjectVerifiersDir } from "../discovery-boundary.ts"; import type { TaskflowVerifier } from "../verify.ts"; /** The convention directory name under `.pi/taskflows/`. */ const VERIFIERS_DIR = "verifiers"; -/** Find the project-scope verifiers directory (walk-up, same as flows). */ -function findProjectVerifiersDir(cwd: string): string | null { - const home = os.homedir(); - let dir = cwd; - while (true) { - if (dir !== home) { - const candidate = path.join(dir, ".pi", "taskflows", VERIFIERS_DIR); - if (fs.existsSync(candidate)) return candidate; - } - const parent = path.dirname(dir); - if (parent === dir) break; - dir = parent; - } - return null; -} - /** The user-scope verifiers directory. */ function userVerifiersDir(): string { return path.join(os.homedir(), ".pi", "taskflows", VERIFIERS_DIR); diff --git a/packages/taskflow-core/src/verifiers/effects-lint.ts b/packages/taskflow-core/src/verifiers/effects-lint.ts new file mode 100644 index 00000000..52859407 --- /dev/null +++ b/packages/taskflow-core/src/verifiers/effects-lint.ts @@ -0,0 +1,94 @@ +/** + * Built-in effects verifier — static EffectIR checks (0.3 Trusted Effects MVP). + * + * Collects phase-level `effects[]`, + * runs validateEffectIR, maps issues to VerificationIssue with category `"effects"`. + * + * Wired into `verifyTaskflow` as a built-in detector (always on when effects are + * present). Also exported as {@link effectsLintVerifier} for plugin-style + * registration (e.g. `pluginVerifierErrors` / explicit host lists). + */ + +import type { Phase } from "../schema.ts"; +import { validateComposedEffectFlow, validateEffectIR, type ComposedEffectFlowLike } from "../effects/validate.ts"; +import type { + TaskflowVerifier, + VerifiableFlow, + VerificationIssue, + VerifierIssue, +} from "../verify.ts"; + +/** Options for the static effects detector. */ +export interface DetectEffectsIssuesOptions { + /** Optional saved-flow loader used to resolve `flow{use}` children. When + * provided, resolved children are checked with their real effects; children + * the loader cannot resolve degrade to advisory warnings (the runtime + * loader remains the authoritative admission gate). */ + resolveFlow?: (name: string) => ComposedEffectFlowLike | undefined; +} + +/** + * Collect all declared effects from each phase, prefix + * phase effect ids with `phaseId/` for unique bag identity, and run + * `validateEffectIR`. Returns VerificationIssues with `category: "effects"`. + * + * Pure — no I/O. Empty effects → empty array (does not fail verify). + */ +export function detectEffectsIssues(flow: VerifiableFlow, options: DetectEffectsIssuesOptions = {}): VerificationIssue[] { + const phases = Array.isArray(flow.phases) ? flow.phases : []; + const scopedResults: Array<{ phaseId?: string; result: ReturnType }> = []; + for (const rawPhase of phases) { + if (!rawPhase || typeof rawPhase !== "object") continue; + const phase = rawPhase as Phase; + const effects = (phase as Phase & { effects?: unknown }).effects; + if (effects !== undefined) { + scopedResults.push({ phaseId: phase.id, result: validateEffectIR({ effects }) }); + } + } + + const flowResult = validateComposedEffectFlow({ phases: phases as Phase[] }, { + // Static gates have no flow store: an unresolved `flow{use}` child is + // advisory (the runtime loader is the authoritative admission gate), + // not a hard confidentiality taint. + downgradeUnresolvedUse: true, + resolveFlow: options.resolveFlow, + }); + if (scopedResults.length === 0 && flowResult.issues.length === 0) return []; + const issues: VerificationIssue[] = []; + for (const scoped of scopedResults) { + for (const issue of scoped.result.issues) { + issues.push({ + message: `[effects] ${issue.message}`, + severity: issue.severity, + category: "effects", + phaseId: scoped.phaseId, + source: "effects-lint", + }); + } + } + for (const issue of flowResult.issues) { + const phaseId = issue.effectId?.includes("/") ? issue.effectId.split("/")[0] : undefined; + issues.push({ + message: `[effects] ${issue.message}`, + severity: issue.severity, + category: "effects", + phaseId, + source: "effects-lint", + }); + } + return issues; +} + +/** Plugin-style wrapper around {@link detectEffectsIssues} for hosts that + * register verifiers explicitly. Prefer the built-in path via `verifyTaskflow` + * (category `"effects"`); this path stamps category `"plugin"`. */ +export const effectsLintVerifier: TaskflowVerifier = { + name: "effects-lint", + verify(flow: VerifiableFlow): VerifierIssue[] { + return detectEffectsIssues(flow).map((i) => ({ + message: i.message, + severity: i.severity, + phaseId: i.phaseId, + })); + }, +}; diff --git a/packages/taskflow-core/src/verifiers/index.ts b/packages/taskflow-core/src/verifiers/index.ts index 96539f10..543c89dc 100644 --- a/packages/taskflow-core/src/verifiers/index.ts +++ b/packages/taskflow-core/src/verifiers/index.ts @@ -6,10 +6,12 @@ */ export { scriptLintVerifier } from "./script-lint.ts"; +export { effectsLintVerifier } from "./effects-lint.ts"; export { discoverVerifiers, listVerifierPaths, type DiscoveredVerifiers } from "./discover.ts"; import type { TaskflowVerifier } from "../verify.ts"; import { scriptLintVerifier } from "./script-lint.ts"; +import { effectsLintVerifier } from "./effects-lint.ts"; /** All built-in verifiers, in recommended registration order. */ -export const builtinVerifiers: readonly TaskflowVerifier[] = [scriptLintVerifier]; +export const builtinVerifiers: readonly TaskflowVerifier[] = [scriptLintVerifier, effectsLintVerifier]; diff --git a/packages/taskflow-core/src/verify.ts b/packages/taskflow-core/src/verify.ts index eac85fe0..68e138e6 100644 --- a/packages/taskflow-core/src/verify.ts +++ b/packages/taskflow-core/src/verify.ts @@ -12,6 +12,8 @@ import type { Phase } from "./schema.ts"; import { asArray, dependenciesOf, LOOP_DEFAULT_MAX_ITERATIONS } from "./schema.ts"; import { type OutputContract } from "./contract.ts"; +import { detectEffectsIssues } from "./verifiers/effects-lint.ts"; +import type { ComposedEffectFlowLike } from "./effects/validate.ts"; // --------------------------------------------------------------------------- // Types @@ -26,6 +28,7 @@ export type IssueCategory = | "ref-integrity" | "guard-contradiction" | "contract" + | "effects" | "plugin"; export interface VerificationIssue { @@ -85,6 +88,11 @@ export interface VerifyOptions { /** Caller-supplied verifiers. Run after the built-in detectors, in array * order, against the same sanitized flow; built-in issues always come first. */ verifiers?: TaskflowVerifier[]; + /** Optional saved-flow loader used to resolve `flow{use}` children during + * effect label-flow verification. When provided, resolved children are + * checked with their real effects; children the loader cannot resolve + * degrade to advisory warnings (the runtime loader remains authoritative). */ + resolveFlow?: (name: string) => ComposedEffectFlowLike | undefined; } // --------------------------------------------------------------------------- @@ -638,6 +646,8 @@ export function verifyTaskflow(flow: VerifiableFlow, options?: VerifyOptions): V issues.push(...detectConcurrencyWarnings(safeFlow, succ)); issues.push(...detectGuardContradictions(phases)); issues.push(...detectContractRefMismatches(phases)); + // Trusted Effects (0.3): static EffectIR checks when a phase carries effects[] + issues.push(...detectEffectsIssues(safeFlow, { resolveFlow: options?.resolveFlow })); // Caller-supplied verifiers run last, against an isolated deep-frozen snapshot // of the sanitized flow (so a verifier cannot mutate the real execution plan diff --git a/packages/taskflow-core/test/adv-high-fix.test.ts b/packages/taskflow-core/test/adv-high-fix.test.ts new file mode 100644 index 00000000..44a26a77 --- /dev/null +++ b/packages/taskflow-core/test/adv-high-fix.test.ts @@ -0,0 +1,225 @@ +/** + * ADV High-fix regression: shared .pi discovery boundary + file-transaction hardenings. + */ +import assert from "node:assert/strict"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { test } from "node:test"; +import { + findProjectAgentsDir, + findProjectDotPiDir, + findProjectTaskflowsDir, + findProjectVerifiersDir, +} from "../src/discovery-boundary.ts"; +import { discoverVerifiers } from "../src/verifiers/discover.ts"; +import { findProjectFlowsDir } from "../src/store.ts"; +import { prepareResourceFileTransaction } from "../src/resources/file-transaction.ts"; +import { WriteIntentJournal } from "../src/resources/journal.ts"; +import { PersistentLeaseCoordinator } from "../src/resources/leases.ts"; +import { resolvePathRef } from "../src/resources/resolve.ts"; +import type { ScopedCapability } from "../src/resources/schema.ts"; +import type { ExecutionOwner } from "../src/resources/types.ts"; + +function tmp(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "tf-adv-fix-")); +} + +test("discovery: stops before home and does not load temp-root verifiers via walk", async () => { + const homeProbe = tmp(); + const prevHome = process.env.HOME; + process.env.HOME = homeProbe; + try { + // Poison home + temp convention trees + const evilUser = path.join(homeProbe, ".pi", "taskflows", "verifiers"); + fs.mkdirSync(evilUser, { recursive: true }); + fs.writeFileSync(path.join(evilUser, "evil.js"), "export default { name: 'evil', verify() { return []; } };\n"); + + const nested = path.join(os.tmpdir(), `tf-nest-${process.pid}`, "proj", "child"); + fs.mkdirSync(nested, { recursive: true }); + // No project .pi — walk would hit temp root then home without boundary + assert.equal(findProjectVerifiersDir(nested), null); + assert.equal(findProjectTaskflowsDir(nested), null); + assert.equal(findProjectAgentsDir(nested), null); + + const discovered = await discoverVerifiers(nested); + // user-scope still loads from HOME intentionally; project must not + assert.ok(!discovered.dirs.some((d) => d.includes(`${path.sep}proj${path.sep}`) && d.includes("verifiers") && !d.startsWith(homeProbe))); + } finally { + if (prevHome === undefined) delete process.env.HOME; + else process.env.HOME = prevHome; + } +}); + +test("discovery: rejects .pi symlink into ~/.pi", () => { + const root = tmp(); + const project = path.join(root, "project"); + const homePi = path.join(root, "home", ".pi"); + fs.mkdirSync(path.join(project, "src"), { recursive: true }); + fs.mkdirSync(path.join(homePi, "taskflows"), { recursive: true }); + fs.writeFileSync(path.join(homePi, "taskflows", "leaked.json"), "{}"); + fs.symlinkSync(homePi, path.join(project, ".pi"), "dir"); + + assert.equal(findProjectDotPiDir(path.join(project, "src")), null); + assert.equal(findProjectFlowsDir(path.join(project, "src"), false), null); + assert.equal(findProjectTaskflowsDir(path.join(project, "src")), null); +}); + +test("discovery: accepts real project .pi and verifiers subdir", async () => { + const project = tmp(); + const vdir = path.join(project, ".pi", "taskflows", "verifiers"); + fs.mkdirSync(vdir, { recursive: true }); + fs.writeFileSync( + path.join(vdir, "ok.js"), + "export default { name: 'ok-v', verify() { return []; } };\n", + ); + assert.equal(findProjectVerifiersDir(project), vdir); + const r = await discoverVerifiers(project); + assert.ok(r.verifiers.some((v) => v.name === "ok-v")); +}); + +// --------------------------------------------------------------------------- +// file-transaction +// --------------------------------------------------------------------------- + +const OWNER: ExecutionOwner = { + runId: "run", + phaseId: "phase", + attemptId: "attempt", + unitId: "unit", + ancestry: [], +}; + +function capability(root: string): ScopedCapability { + return { + bindingId: "binding", + resourceDomainId: "domain", + providerInstanceId: "root", + logicalWorkspaceId: "project", + logicalPrefix: "", + physicalScopeRoot: root, + access: "read-write", + version: { identityMode: "path-bound", generation: 0, state: "clean" }, + lifetime: { scope: "phase", runId: OWNER.runId, phaseId: OWNER.phaseId, attemptId: OWNER.attemptId }, + }; +} + +function target(root: string, relative: string, effectId: string) { + const ref = resolvePathRef( + { + workspace: "project", + subpath: { literalPath: relative }, + access: "read-write", + intent: "create-file", + maxLifetime: { scope: "phase" }, + }, + { + workspaces: new Map([["project", capability(root)]]), + runId: OWNER.runId, + phaseId: OWNER.phaseId, + attemptId: OWNER.attemptId, + }, + { definitions: {}, values: {} }, + ); + if (!ref.ok) throw new Error(ref.error.redactedMessage); + return { effectId, ref: ref.value }; +} + +async function prepareTx(workspace: string, relative: string, effectId = "w") { + const control = tmp(); + const leases = new PersistentLeaseCoordinator({ directory: control, registryId: "registry" }); + const journal = new WriteIntentJournal({ directory: control, journalEpoch: 1 }); + const prepared = await prepareResourceFileTransaction({ + controlDirectory: control, + resourceDomainId: "domain", + owner: OWNER, + targets: [target(workspace, relative, effectId)], + leases, + journal, + leaseTimeoutMs: 5_000, + permitTtlMs: 30_000, + authorizationScopeRoot: workspace, + }); + return { prepared, control, workspace }; +} + +test("file-transaction: rejects path-like effectId at prepare", async () => { + const workspace = tmp(); + await assert.rejects( + () => prepareTx(workspace, "out/a.txt", "../escape"), + /TFWS_INVALID_EFFECT_ID|safe path segment/, + ); +}); + +test("file-transaction: concurrent commit is rejected (busy)", async () => { + const workspace = tmp(); + const { prepared } = await prepareTx(workspace, "out/a.txt", "w1"); + const results = await Promise.allSettled([ + prepared.commit([{ effectId: "w1", content: "one" }]), + prepared.commit([{ effectId: "w1", content: "two" }]), + ]); + const fulfilled = results.filter((r): r is PromiseFulfilledResult>> => r.status === "fulfilled"); + const rejected = results.filter((r) => r.status === "rejected"); + const oks = fulfilled.filter((r) => r.value.ok); + assert.equal(oks.length, 1, `expected exactly one ok commit, got ${oks.length}, rejected=${rejected.length}`); + const body = fs.readFileSync(path.join(workspace, "out/a.txt"), "utf8"); + assert.ok(body === "one" || body === "two", body); + if (rejected.length) { + assert.match(String((rejected[0] as PromiseRejectedResult).reason), /already in progress|already settled/); + } else { + // loser returned ok:false via restore path — file must still match single winner + const failed = fulfilled.filter((r) => !r.value.ok); + assert.ok(failed.length >= 1); + } +}); + +test("file-transaction: intermediate symlink between prepare and commit is rejected", async () => { + const workspace = tmp(); + const outside = tmp(); + fs.writeFileSync(path.join(outside, "pwned.txt"), "nope"); + // deep path admitted + const { prepared } = await prepareTx(workspace, "deep/nested/x.txt", "w1"); + // plant intermediate symlink after prepare + fs.mkdirSync(path.join(workspace, "deep"), { recursive: true }); + // If deep already created as dir by something, remove and replace + try { + fs.rmSync(path.join(workspace, "deep"), { recursive: true, force: true }); + } catch { + /* */ + } + fs.symlinkSync(outside, path.join(workspace, "deep"), "dir"); + + const result = await prepared.commit([{ effectId: "w1", content: "escaped" }]); + assert.equal(result.ok, false); + if (result.ok) throw new Error("unreachable"); + assert.equal(result.code, "declared-path-bypass"); + // outside must not receive payload + assert.equal(fs.readFileSync(path.join(outside, "pwned.txt"), "utf8"), "nope"); + assert.ok(!fs.existsSync(path.join(outside, "nested"))); +}); + +test("file-transaction: deferred lease callback throw does not flip ok:true", async () => { + const workspace = tmp(); + const control = tmp(); + const leases = new PersistentLeaseCoordinator({ directory: control, registryId: "registry" }); + const journal = new WriteIntentJournal({ directory: control, journalEpoch: 1 }); + const prepared = await prepareResourceFileTransaction({ + controlDirectory: control, + resourceDomainId: "domain", + owner: OWNER, + targets: [target(workspace, "out/b.txt", "w2")], + leases, + journal, + leaseTimeoutMs: 5_000, + permitTtlMs: 30_000, + authorizationScopeRoot: workspace, + onDeferredLeaseRelease: () => { + throw new Error("injected deferred release failure"); + }, + }); + + // Success path: lease release usually succeeds so callback may not run; commit must still ok. + const r = await prepared.commit([{ effectId: "w2", content: "ok" }]); + assert.equal(r.ok, true); + assert.equal(fs.readFileSync(path.join(workspace, "out/b.txt"), "utf8"), "ok"); +}); diff --git a/packages/taskflow-core/test/atomic-rename.test.ts b/packages/taskflow-core/test/atomic-rename.test.ts new file mode 100644 index 00000000..05f44446 --- /dev/null +++ b/packages/taskflow-core/test/atomic-rename.test.ts @@ -0,0 +1,78 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { renameAtomicWithRetry } from "../src/atomic-rename.ts"; + +function transientError(code: string): NodeJS.ErrnoException { + const error = new Error(`simulated ${code}`) as NodeJS.ErrnoException; + error.code = code; + return error; +} + +test("renameAtomicWithRetry: retries eligible Windows contention until success", () => { + let attempts = 0; + let sleeps = 0; + renameAtomicWithRetry("tmp", "target", { + platform: "win32", + maxAttempts: 5, + renameSync: () => { + attempts++; + if (attempts < 3) throw transientError("EPERM"); + }, + sleep: () => { sleeps++; }, + }); + assert.equal(attempts, 3); + assert.equal(sleeps, 2); +}); + +test("renameAtomicWithRetry: persistent contention exhausts a hard attempt bound", () => { + let attempts = 0; + let sleeps = 0; + assert.throws( + () => renameAtomicWithRetry("tmp", "target", { + platform: "win32", + maxAttempts: 3, + renameSync: () => { + attempts++; + throw transientError("EBUSY"); + }, + sleep: () => { sleeps++; }, + }), + (error: NodeJS.ErrnoException) => error.code === "EBUSY", + ); + assert.equal(attempts, 3); + assert.equal(sleeps, 2); +}); + +test("renameAtomicWithRetry: non-Windows and ineligible errors fail immediately", () => { + for (const [platform, code] of [["linux", "EPERM"], ["win32", "EIO"]] as const) { + let attempts = 0; + assert.throws( + () => renameAtomicWithRetry("tmp", "target", { + platform, + maxAttempts: 5, + renameSync: () => { + attempts++; + throw transientError(code); + }, + sleep: () => { throw new Error("must not sleep"); }, + }), + (error: NodeJS.ErrnoException) => error.code === code, + ); + assert.equal(attempts, 1); + } +}); + +test("renameAtomicWithRetry: rejects non-finite or excessive attempt bounds", () => { + for (const maxAttempts of [Number.NaN, Number.POSITIVE_INFINITY, 0, 52]) { + let attempts = 0; + assert.throws( + () => renameAtomicWithRetry("tmp", "target", { + platform: "win32", + maxAttempts, + renameSync: () => { attempts++; }, + }), + RangeError, + ); + assert.equal(attempts, 0); + } +}); \ No newline at end of file diff --git a/packages/taskflow-core/test/cache.test.ts b/packages/taskflow-core/test/cache.test.ts index 139ee86d..fee1b246 100644 --- a/packages/taskflow-core/test/cache.test.ts +++ b/packages/taskflow-core/test/cache.test.ts @@ -428,6 +428,30 @@ test("runtime: cross-run misses when phase 'thinking' changes (P0-2)", async () fs.rmSync(dir, { recursive: true, force: true }); }); +test("runtime: cross-run misses when effective global thinking changes", async () => { + const dir = tmpDir(); + const store = new CacheStore(dir); + const def: Taskflow = { + name: "global-think-cr", + phases: [{ id: "p", type: "agent", agent: "a", task: "go", cache: { scope: "cross-run" }, final: true }], + }; + const counter = { n: 0 }; + const deps = (globalThinking: ThinkingLevel): RuntimeDeps => ({ + cwd: dir, + agents: AGENTS, + globalThinking, + runTask: countingRunner(counter), + cacheStore: store, + }); + + await executeTaskflow(mkState(def, dir), deps("off")); + await executeTaskflow(mkState(def, dir), deps("high")); + assert.equal(counter.n, 2, "changing the effective global thinking must invalidate the cross-run hit"); + await executeTaskflow(mkState(def, dir), deps("off")); + assert.equal(counter.n, 2, "identical effective global thinking re-hits"); + fs.rmSync(dir, { recursive: true, force: true }); +}); + test("runtime: cross-run misses when phase 'tools' change (P0-2)", async () => { const dir = tmpDir(); const store = new CacheStore(dir); diff --git a/packages/taskflow-core/test/detached.test.ts b/packages/taskflow-core/test/detached.test.ts index 24033efb..e67f4ca1 100644 --- a/packages/taskflow-core/test/detached.test.ts +++ b/packages/taskflow-core/test/detached.test.ts @@ -3,6 +3,7 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { test } from "node:test"; +import { pathToFileURL } from "node:url"; import type { Taskflow } from "../src/schema.ts"; import { isProcessAlive, @@ -126,7 +127,7 @@ test("detached-runner: completes flow and persists terminal state", async () => const mockRunnerPath = path.join(cwd, "mock-detached-runner.mts"); fs.writeFileSync(mockRunnerPath, ` import { readFileSync } from "node:fs"; -import { loadRun, saveRun } from "${path.resolve("packages/taskflow-core/src/store.ts")}"; +import { loadRun, saveRun } from "${pathToFileURL(path.resolve("packages/taskflow-core/src/store.ts")).href}"; interface DetachContext { runId: string; diff --git a/packages/taskflow-core/test/effects-agent-te.test.ts b/packages/taskflow-core/test/effects-agent-te.test.ts new file mode 100644 index 00000000..bb2c22f4 --- /dev/null +++ b/packages/taskflow-core/test/effects-agent-te.test.ts @@ -0,0 +1,398 @@ +/** + * G5 agent-path Trusted Effects: mock runner must not promote finals by + * writing declared paths; content is resource-transaction promoted from output. + * Illegal EffectIR (label-flow) fails closed before authority admission. + */ + +import assert from "node:assert/strict"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { test } from "node:test"; +import type { AgentConfig } from "../src/agents.ts"; +import type { RunResult } from "../src/host/runner-types.ts"; +import { executeTaskflow, type RuntimeDeps } from "../src/runtime.ts"; +import type { Taskflow } from "../src/schema.ts"; +import type { RunState } from "../src/store.ts"; +import { emptyUsage } from "../src/usage.ts"; + +const AGENTS: AgentConfig[] = [ + { name: "executor", description: "test", systemPrompt: "", source: "user", filePath: "" }, +]; + +function mkState(def: Taskflow, cwd: string): RunState { + return { + runId: "test-run", + flowName: def.name, + def, + args: {}, + status: "running", + phases: {}, + createdAt: Date.now(), + updatedAt: Date.now(), + cwd, + }; +} + +function okResult(agentName: string, task: string, output: string): RunResult { + return { + agent: agentName, + task, + exitCode: 0, + output, + stderr: "", + usage: { ...emptyUsage(), output: 5, turns: 1 }, + stopReason: "end", + completionSource: "process-exit", + }; +} + +const writeEffect = { + id: "report", + kind: "fs.write" as const, + target: { + kind: "path" as const, + path: { + workspace: "project", + subpath: { literalPath: "out/report.md" }, + intent: "create-file" as const, + }, + }, + confidentiality: "internal" as const, + integrity: "project" as const, +}; + +test("agent path: mock runner writing declared final fails closed (bypass)", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "tf-te-agent-bypass-")); + try { + const def: Taskflow = { + name: "te-agent-bypass", + phases: [ + { + id: "write", + type: "agent", + agent: "executor", + task: "write the report", + effects: [writeEffect], + final: true, + }, + ], + }; + const deps: RuntimeDeps = { + cwd: root, + agents: AGENTS, + runTask: async (_cwd, _agents, agentName, task) => { + fs.mkdirSync(path.join(root, "out"), { recursive: true }); + fs.writeFileSync(path.join(root, "out/report.md"), "BYPASS\n"); + return okResult(agentName, task, "should-not-promote\n"); + }, + }; + const res = await executeTaskflow(mkState(def, root), deps); + assert.equal(res.ok, false, "bypass must fail the run/phase"); + const ps = res.state.phases["write"]; + assert.equal(ps?.status, "failed"); + assert.match(ps?.error ?? "", /declared-path-bypass|trusted-effects/i); + assert.equal( + fs.existsSync(path.join(root, "out/report.md")), + false, + "failed phase must restore the pre-phase state instead of retaining the bypass write", + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("gate eval fast path: declared write is finalized through the same authority path", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "tf-te-gate-eval-")); + try { + const def: Taskflow = { + name: "te-gate-eval-write", + phases: [ + { + id: "gate", + type: "gate", + eval: ["true"], + effects: [writeEffect], + final: true, + }, + ], + }; + const deps: RuntimeDeps = { + cwd: root, + agents: AGENTS, + runTask: async () => { + throw new Error("eval auto-pass must not call an LLM"); + }, + }; + const res = await executeTaskflow(mkState(def, root), deps); + assert.equal(res.ok, true, res.state.finalOutput ?? JSON.stringify(res.state.phases)); + assert.equal( + fs.readFileSync(path.join(root, "out/report.md"), "utf8"), + "PASS (eval checks passed — no LLM call)", + ); + assert.ok((res.state.phases["gate"]?.warnings ?? []).some((w) => /trusted-effects|resource intent/i.test(w))); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("agent path: mock runner content is promoted only via the resource transaction", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "tf-te-agent-ok-")); + try { + const def: Taskflow = { + name: "te-agent-ok", + phases: [ + { + id: "write", + type: "agent", + agent: "executor", + task: "produce report body", + effects: [writeEffect], + final: true, + }, + ], + }; + const deps: RuntimeDeps = { + cwd: root, + agents: AGENTS, + runTask: async (_c, _a, agentName, task) => okResult(agentName, task, "FROM_AGENT_VIA_RESOURCE_INTENT\n"), + }; + const res = await executeTaskflow(mkState(def, root), deps); + assert.equal(res.ok, true, res.state.finalOutput ?? JSON.stringify(res.state.phases)); + assert.equal(fs.readFileSync(path.join(root, "out/report.md"), "utf8"), "FROM_AGENT_VIA_RESOURCE_INTENT\n"); + const ps = res.state.phases["write"]; + assert.equal(ps?.status, "done"); + assert.ok((ps?.warnings ?? []).some((w) => /trusted-effects|resource intent/i.test(w))); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("agent path: illegal label-flow fails phase via executeTaskflow", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "tf-te-agent-label-")); + try { + const def: Taskflow = { + name: "te-agent-label", + phases: [ + { + id: "write", + type: "agent", + agent: "executor", + task: "x", + effects: [ + { + id: "secret-input", + kind: "secret.read", + target: { kind: "secret", secret: { secretId: "api-key" } }, + }, + { + id: "bad-output", + kind: "fs.write", + confidentiality: "public", + target: { + kind: "path", + path: { + workspace: "p", + subpath: { literalPath: "leak.txt" }, + intent: "create-file", + }, + }, + }, + ], + final: true, + }, + ], + }; + const deps: RuntimeDeps = { + cwd: root, + agents: AGENTS, + runTask: async (_c, _a, agentName, task) => okResult(agentName, task, "nope\n"), + }; + const res = await executeTaskflow(mkState(def, root), deps); + assert.equal(res.ok, false); + assert.match(res.state.phases["write"]?.error ?? "", /effectir-invalid|trusted-effects|label/i); + assert.equal(fs.existsSync(path.join(root, "leak.txt")), false); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("runtime: cross-phase label violation fails before any phase body", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "tf-te-cross-label-")); + let calls = 0; + try { + const def: Taskflow = { + name: "te-cross-label", + phases: [ + { + id: "read-secret", + type: "agent", + agent: "executor", + task: "read", + effects: [{ + id: "secret-input", + kind: "secret.read", + target: { kind: "secret", secret: { secretId: "api-key" } }, + }], + }, + { + id: "publish", + type: "agent", + agent: "executor", + task: "publish", + dependsOn: ["read-secret"], + effects: [{ + id: "public-output", + kind: "fs.write", + confidentiality: "public", + target: { + kind: "path", + path: { workspace: "project", subpath: { literalPath: "leak.txt" }, intent: "create-file" }, + }, + }], + final: true, + }, + ], + }; + const deps: RuntimeDeps = { + cwd: root, + agents: AGENTS, + runTask: async (_c, _a, agentName, task) => { + calls++; + return okResult(agentName, task, "nope\n"); + }, + }; + const res = await executeTaskflow(mkState(def, root), deps); + assert.equal(res.ok, false); + assert.equal(calls, 0, "whole-flow validation must run before the first phase body"); + assert.match(res.finalOutput, /EffectIR label flow is invalid.*read-secret\/secret-input.*publish\/public-output/); + assert.equal(fs.existsSync(path.join(root, "leak.txt")), false); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("runtime: parent secret cannot flow into a saved child public sink", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "tf-te-saved-label-")); + let calls = 0; + try { + const child: Taskflow = { + name: "saved-public-child", + phases: [{ + id: "publish", + type: "agent", + agent: "executor", + task: "publish", + effects: [{ + id: "public-output", + kind: "fs.write", + confidentiality: "public", + target: { + kind: "path", + path: { workspace: "project", subpath: { literalPath: "leak.txt" }, intent: "create-file" }, + }, + }], + final: true, + }], + }; + const def: Taskflow = { + name: "saved-parent-label", + phases: [ + { + id: "read-secret", + type: "agent", + agent: "executor", + task: "read", + effects: [{ + id: "secret-input", + kind: "secret.read", + target: { kind: "secret", secret: { secretId: "api-key" } }, + }], + }, + { id: "child", type: "flow", use: child.name, dependsOn: ["read-secret"], final: true }, + ], + }; + const res = await executeTaskflow(mkState(def, root), { + cwd: root, + agents: AGENTS, + loadFlow: (name) => name === child.name ? child : undefined, + runTask: async (_c, _a, agentName, task) => { + calls++; + return okResult(agentName, task, "nope\n"); + }, + }); + assert.equal(res.ok, false); + assert.equal(calls, 0); + assert.match(res.finalOutput, /read-secret\/secret-input.*child\/publish\/public-output/); + assert.equal(fs.existsSync(path.join(root, "leak.txt")), false); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("runtime: malformed non-array effects fail before phase execution", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "tf-te-effects-shape-")); + let calls = 0; + try { + const def = { + name: "te-effects-shape", + phases: [{ + id: "write", + type: "agent", + agent: "executor", + task: "must-not-run", + effects: { id: "erased-write" }, + final: true, + }], + } as unknown as Taskflow; + const res = await executeTaskflow(mkState(def, root), { + cwd: root, + agents: AGENTS, + runTask: async (_c, _a, agentName, task) => { + calls++; + return okResult(agentName, task, "ran\n"); + }, + }); + assert.equal(res.ok, false); + assert.equal(calls, 0); + assert.match(res.state.phases.write?.error ?? "", /effects must be an array|effects-not-array/i); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("runtime: unbound effect kind fails before the phase body", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "tf-te-unbound-effect-")); + let calls = 0; + try { + const def: Taskflow = { + name: "te-unbound-effect", + phases: [{ + id: "read-secret", + type: "agent", + agent: "executor", + task: "read", + effects: [{ + id: "secret-input", + kind: "secret.read", + target: { kind: "secret", secret: { secretId: "api-key" } }, + }], + final: true, + }], + }; + const deps: RuntimeDeps = { + cwd: root, + agents: AGENTS, + runTask: async (_c, _a, agentName, task) => { + calls++; + return okResult(agentName, task, "nope\n"); + }, + }; + const res = await executeTaskflow(mkState(def, root), deps); + assert.equal(res.ok, false); + assert.equal(calls, 0); + assert.match(res.state.phases["read-secret"]?.error ?? "", /unsupported-effect-kind.*secret\.read/); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/packages/taskflow-core/test/effects-composition-cache.test.ts b/packages/taskflow-core/test/effects-composition-cache.test.ts new file mode 100644 index 00000000..fcc8fc5e --- /dev/null +++ b/packages/taskflow-core/test/effects-composition-cache.test.ts @@ -0,0 +1,284 @@ +/** Adversarial cache/resume coverage for resource-bearing composed flows. */ + +import assert from "node:assert/strict"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { test } from "node:test"; +import type { AgentConfig } from "../src/agents.ts"; +import { CacheStore } from "../src/cache.ts"; +import { queueSpawn } from "../src/context-store.ts"; +import type { RunOptions, RunResult } from "../src/runner-core.ts"; +import { executeTaskflow, type RuntimeDeps } from "../src/runtime.ts"; +import type { Taskflow } from "../src/schema.ts"; +import type { RunState } from "../src/store.ts"; +import { emptyUsage } from "../src/usage.ts"; + +const AGENTS: AgentConfig[] = [ + { name: "executor", description: "test", systemPrompt: "", source: "user", filePath: "" }, + { name: "planner", description: "test", systemPrompt: "", source: "user", filePath: "" }, +]; + +function state(def: Taskflow, cwd: string, runId: string): RunState { + return { + runId, + flowName: def.name, + def, + args: {}, + status: "running", + phases: {}, + createdAt: Date.now(), + updatedAt: Date.now(), + cwd, + }; +} + +function writeEffect(relativePath: string) { + return { + id: "report", + kind: "fs.write" as const, + target: { + kind: "path" as const, + path: { + workspace: "project", + subpath: { literalPath: relativePath }, + intent: "create-file" as const, + }, + }, + }; +} + +function runner(counter: { calls: number }, plannerOutput?: Taskflow): RuntimeDeps["runTask"] { + return async (_cwd, _agents, agentName, task): Promise => { + counter.calls++; + const output = agentName === "planner" && plannerOutput + ? JSON.stringify(plannerOutput) + : `CONTENT:${task}`; + return { + agent: agentName, + task, + exitCode: 0, + output, + stderr: "", + usage: { ...emptyUsage(), output: 1, turns: 1 }, + stopReason: "end", + }; + }; +} + +test("flow.def: a resource-bearing inline child cannot be skipped by a cross-run parent cache hit", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "tf-te-flow-cache-")); + const control = fs.mkdtempSync(path.join(os.tmpdir(), "tf-te-control-")); + try { + const child: Taskflow = { + name: "writer-child", + phases: [{ + id: "write", + type: "agent", + agent: "executor", + task: "inline", + effects: [writeEffect("out/report.txt")], + final: true, + }], + }; + const def: Taskflow = { + name: "inline-parent", + phases: [{ + id: "child", + type: "flow", + def: child, + cache: { scope: "cross-run" }, + final: true, + }], + }; + const counter = { calls: 0 }; + const cacheStore = new CacheStore(control); + const deps: RuntimeDeps = { + cwd: root, + workspaceControlDirectory: control, + cacheStore, + agents: AGENTS, + runTask: runner(counter), + }; + + const first = await executeTaskflow(state(def, root, "inline-first"), deps); + assert.equal(first.ok, true, first.finalOutput); + assert.equal(counter.calls, 1); + fs.rmSync(path.join(root, "out/report.txt")); + + const second = await executeTaskflow(state(def, root, "inline-second"), deps); + assert.equal(second.ok, true, second.finalOutput); + assert.equal(counter.calls, 2, "the resource-bearing child must execute again"); + assert.equal(second.state.phases.child?.cacheHit, undefined); + assert.equal(fs.readFileSync(path.join(root, "out/report.txt"), "utf8"), "CONTENT:inline"); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(control, { recursive: true, force: true }); + } +}); + +test("expand.def: a resource-bearing child cannot be skipped by within-run resume reuse", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "tf-te-expand-resume-")); + const control = fs.mkdtempSync(path.join(os.tmpdir(), "tf-te-control-")); + try { + const def: Taskflow = { + name: "expand-parent", + phases: [{ + id: "grow", + type: "expand", + expandMode: "nested", + def: { + name: "expand-child", + phases: [{ + id: "write", + type: "agent", + agent: "executor", + task: "expand", + effects: [writeEffect("expanded.txt")], + final: true, + }], + }, + final: true, + }], + }; + const counter = { calls: 0 }; + const deps: RuntimeDeps = { + cwd: root, + workspaceControlDirectory: control, + agents: AGENTS, + runTask: runner(counter), + }; + + const first = await executeTaskflow(state(def, root, "expand-resume"), deps); + assert.equal(first.ok, true, first.finalOutput); + assert.equal(counter.calls, 1); + fs.rmSync(path.join(root, "expanded.txt")); + + const resumed = await executeTaskflow(first.state, deps); + assert.equal(resumed.ok, true, resumed.finalOutput); + assert.equal(counter.calls, 2, "resume must re-enter the resource-bearing expand child"); + assert.equal(resumed.state.phases.grow?.cacheHit, undefined); + assert.equal(fs.readFileSync(path.join(root, "expanded.txt"), "utf8"), "CONTENT:expand"); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(control, { recursive: true, force: true }); + } +}); + +test("dynamic flow.def: an unknown child capability permanently binds the parent invocation root", async () => { + const rootA = fs.mkdtempSync(path.join(os.tmpdir(), "tf-te-dynamic-root-a-")); + const rootB = fs.mkdtempSync(path.join(os.tmpdir(), "tf-te-dynamic-root-b-")); + const control = fs.mkdtempSync(path.join(os.tmpdir(), "tf-te-control-")); + try { + const child: Taskflow = { + name: "planned-writer", + phases: [{ + id: "write", + type: "agent", + agent: "executor", + task: "dynamic", + effects: [writeEffect("dynamic.txt")], + final: true, + }], + }; + const def: Taskflow = { + name: "dynamic-parent", + phases: [ + { id: "plan", type: "agent", agent: "planner", task: "plan", output: "json" }, + { id: "run", type: "flow", def: "{steps.plan.json}", dependsOn: ["plan"], final: true }, + ], + }; + const counter = { calls: 0 }; + const first = await executeTaskflow(state(def, rootA, "dynamic-root"), { + cwd: rootA, + workspaceControlDirectory: control, + agents: AGENTS, + runTask: runner(counter, child), + }); + assert.equal(first.ok, true, first.finalOutput); + assert.ok(first.state.cwdRootBinding, "the parent must persist the capability root"); + assert.equal(fs.readFileSync(path.join(rootA, "dynamic.txt"), "utf8"), "CONTENT:dynamic"); + + const rebound = await executeTaskflow(first.state, { + cwd: rootB, + workspaceControlDirectory: control, + agents: AGENTS, + runTask: runner(counter, child), + }); + assert.equal(rebound.ok, false); + assert.match(rebound.finalOutput, /invocation root does not match|start a new run/i); + assert.equal(fs.existsSync(path.join(rootB, "dynamic.txt")), false); + } finally { + fs.rmSync(rootA, { recursive: true, force: true }); + fs.rmSync(rootB, { recursive: true, force: true }); + fs.rmSync(control, { recursive: true, force: true }); + } +}); + +test("ctx_spawn: shareContext cannot cache away a resource-bearing dynamic child", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "tf-te-spawn-cache-")); + const control = fs.mkdtempSync(path.join(os.tmpdir(), "tf-te-control-")); + try { + const child: Taskflow = { + name: "spawned-writer", + phases: [{ + id: "write", + type: "agent", + agent: "executor", + task: "spawned-write", + effects: [writeEffect("spawned.txt")], + final: true, + }], + }; + const def: Taskflow = { + name: "spawn-parent", + phases: [{ + id: "parent", + type: "agent", + agent: "executor", + task: "queue-child", + shareContext: true, + cache: { scope: "cross-run" }, + final: true, + }], + }; + const counter = { calls: 0 }; + const runTask: RuntimeDeps["runTask"] = async (_cwd, _agents, agentName, task, options: RunOptions) => { + counter.calls++; + if (task.includes("queue-child")) { + queueSpawn(options.ctxDir!, options.nodeId!, [{ subflow: child }]); + } + return { + agent: agentName, + task, + exitCode: 0, + output: task.includes("spawned-write") ? "SPAWNED" : "PARENT", + stderr: "", + usage: { ...emptyUsage(), output: 1, turns: 1 }, + stopReason: "end", + }; + }; + const deps: RuntimeDeps = { + cwd: root, + workspaceControlDirectory: control, + cacheStore: new CacheStore(control), + agents: AGENTS, + runTask, + }; + + const first = await executeTaskflow(state(def, root, "spawn-first"), deps); + assert.equal(first.ok, true, first.finalOutput); + assert.equal(counter.calls, 2); + assert.equal(fs.readFileSync(path.join(root, "spawned.txt"), "utf8"), "SPAWNED"); + fs.rmSync(path.join(root, "spawned.txt")); + + const second = await executeTaskflow(state(def, root, "spawn-second"), deps); + assert.equal(second.ok, true, second.finalOutput); + assert.equal(counter.calls, 4, "the parent and resource-bearing spawned child must execute again"); + assert.equal(second.state.phases.parent?.cacheHit, undefined); + assert.equal(fs.readFileSync(path.join(root, "spawned.txt"), "utf8"), "SPAWNED"); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(control, { recursive: true, force: true }); + } +}); diff --git a/packages/taskflow-core/test/effects-deliverables.test.ts b/packages/taskflow-core/test/effects-deliverables.test.ts new file mode 100644 index 00000000..ce583f9b --- /dev/null +++ b/packages/taskflow-core/test/effects-deliverables.test.ts @@ -0,0 +1,207 @@ +/** + * Named observations for each of the 8 Trusted Effects MVP deliverables. + * Each test title maps to a scoreboard row. + */ + +import assert from "node:assert/strict"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import { test } from "node:test"; +import { + CONFIDENTIALITY_LABELS, + EFFECT_KINDS, + INTEGRITY_LABELS, + finalizePreparedDeclaredFsWrites, + isSecretRef, + isServiceRef, + preparePhaseDeclaredFsWrites, + validateEffectIR, + whyAuthorized, + whyContext, + whyEffect, +} from "../src/effects/index.ts"; +import { createResolveOnlyWorkspaceSession } from "../src/resources/execution.ts"; +import * as os from "node:os"; + +// test/ → package → packages → repo root +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../.."); + +// 1 EffectIR +test("deliverable:EffectIR closed kinds + validate rejects unknown", () => { + assert.ok(EFFECT_KINDS.includes("fs.write")); + const bad = validateEffectIR({ + effects: [{ id: "x", kind: "net.open", target: { kind: "path", path: { workspace: "p", intent: "create-file" } } }], + }); + assert.equal(bad.ok, false); + const good = validateEffectIR({ + effects: [ + { + id: "w", + kind: "fs.write", + target: { + kind: "path", + path: { workspace: "p", subpath: { literalPath: "a.md" }, intent: "create-file" }, + }, + }, + ], + }); + assert.equal(good.ok, true); +}); + +// 2 PathRef / SecretRef / ServiceRef +test("deliverable:Refs PathRef + SecretRef/ServiceRef fail-closed", () => { + assert.equal(isSecretRef({ secretId: "k" }), true); + assert.equal(isSecretRef({ secretId: "k", value: "nope" }), false); + assert.equal(isServiceRef({ serviceId: "s" }), true); + const mat = validateEffectIR({ + effects: [ + { + id: "s", + kind: "secret.read", + target: { kind: "secret", secret: { secretId: "k", material: "x" } }, + }, + ], + }); + assert.equal(mat.ok, false); + assert.ok(mat.issues.some((i) => i.code === "secret-material-forbidden")); +}); + +// 3 labels +test("deliverable:Labels confidentiality+integrity lattice enforcement", () => { + assert.ok(CONFIDENTIALITY_LABELS.includes("secret")); + assert.ok(INTEGRITY_LABELS.includes("project")); + const r = validateEffectIR({ + effects: [ + { + id: "secret-input", + kind: "secret.read", + target: { kind: "secret", secret: { secretId: "k" } }, + }, + { + id: "public-output", + kind: "fs.write", + confidentiality: "public", + target: { + kind: "path", + path: { workspace: "p", subpath: { literalPath: "x" }, intent: "create-file" }, + }, + }, + ], + }); + assert.equal(r.ok, false); + assert.ok(r.issues.some((i) => i.code === "confidentiality-flow-violation")); +}); + +// 4 resource transaction + 5 durable authority (combined observation) +test("deliverable:ResourceTransaction PathRef-lease-intent-permit-commit", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "tf-te-del-")); + const control = fs.mkdtempSync(path.join(os.tmpdir(), "tf-te-del-control-")); + try { + const session = await createResolveOnlyWorkspaceSession({ invocationRoot: root, controlDirectory: control }); + const binding = await session.bindPhase({ + invocationRoot: root, + runId: "d", + phaseId: "write", + argDefinitions: {}, + argValues: {}, + }); + const admitted = await preparePhaseDeclaredFsWrites(binding, { + effects: [ + { + id: "w", + kind: "fs.write", + target: { + kind: "path", + path: { workspace: "p", subpath: { literalPath: "f.txt" }, intent: "create-file" }, + }, + }, + ], + }); + assert.equal(admitted.ok, true); + if (!admitted.ok) return; + const finalized = await finalizePreparedDeclaredFsWrites(admitted.prepared, "via-resource-control\n"); + assert.equal(finalized.ok, true); + assert.equal(fs.readFileSync(path.join(root, "f.txt"), "utf8"), "via-resource-control\n"); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(control, { recursive: true, force: true }); + } +}); + +// 6 overlap +test("deliverable:Overlap mutating paths denied", () => { + const r = validateEffectIR({ + effects: [ + { + id: "a", + kind: "fs.write", + target: { + kind: "path", + path: { workspace: "p", subpath: { literalPath: "out/x" }, intent: "create-file" }, + }, + }, + { + id: "b", + kind: "fs.delete", + target: { + kind: "path", + path: { workspace: "p", subpath: { literalPath: "out" }, intent: "existing-directory" }, + }, + }, + ], + }); + assert.equal(r.ok, false); + assert.ok(r.issues.some((i) => i.code === "mutating-path-overlap")); +}); + +// 7 host matrix honesty +test("deliverable:HostMatrix FileBroker unsupported", () => { + const p = path.join(repoRoot, "conformance/workspace/host-support-baseline.json"); + const j = JSON.parse(fs.readFileSync(p, "utf8")) as { + cells: Array<{ capability: string; status: string; guarantee: string }>; + }; + const fb = j.cells.find((c) => c.capability === "file-broker"); + assert.ok(fb, "file-broker cell required"); + assert.equal(fb.status, "unsupported"); + assert.equal(fb.guarantee, "none"); + const te = j.cells.find((c) => c.capability === "trusted-effects-resource-transaction"); + assert.ok(te); + assert.equal(te.status, "supported"); +}); + +// 8 why-* +test("deliverable:Why authorized/context/effect", () => { + const effect = { + id: "w1", + kind: "fs.write" as const, + purpose: "demo", + confidentiality: "internal" as const, + integrity: "project" as const, + target: { + kind: "path" as const, + path: { + workspace: "project", + subpath: { literalPath: "out/r.md" }, + intent: "create-file" as const, + }, + }, + }; + const input = { + effect, + runId: "run", + phaseId: "p", + allowed: true, + allowReasons: ["declared"], + status: "committed" as const, + intentId: "intent", + workspaceRoot: "/tmp/x", + }; + const a = whyAuthorized(input); + const c = whyContext(input); + const e = whyEffect(input); + assert.equal(a.allowed, true); + assert.equal(c.confidentiality, "internal"); + assert.equal(e.effectId, "w1"); + assert.ok(e.targetSummary.includes("out/r.md")); +}); diff --git a/packages/taskflow-core/test/effects-e2e-fixture.test.ts b/packages/taskflow-core/test/effects-e2e-fixture.test.ts new file mode 100644 index 00000000..2d5466fb --- /dev/null +++ b/packages/taskflow-core/test/effects-e2e-fixture.test.ts @@ -0,0 +1,164 @@ +/** No-LLM runtime fixture for the resource-controlled Trusted Effects path. */ + +import assert from "node:assert/strict"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import { test } from "node:test"; +import { executeTaskflow, type RuntimeDeps } from "../src/runtime.ts"; +import { type Taskflow, validateTaskflow } from "../src/schema.ts"; +import type { RunState } from "../src/store.ts"; + +function mkState(def: Taskflow, cwd: string, runId: string): RunState { + return { + runId, + flowName: def.name, + def, + args: {}, + status: "running", + phases: {}, + createdAt: Date.now(), + updatedAt: Date.now(), + cwd, + }; +} + +function deps(cwd: string, control: string): RuntimeDeps { + return { + cwd, + workspaceControlDirectory: control, + agents: [], + runTask: async () => { throw new Error("script-only fixture must not call an LLM"); }, + persist: () => {}, + onProgress: () => {}, + }; +} + +function fileEffect(id: string, relativePath: string, intent: "create-file" | "existing-file" = "create-file") { + return { + id, + kind: "fs.write" as const, + purpose: `write ${relativePath}`, + confidentiality: "internal" as const, + integrity: "project" as const, + target: { + kind: "path" as const, + path: { workspace: "project", subpath: { literalPath: relativePath }, intent }, + }, + }; +} + +test("fixture: script output commits through resource authority without an LLM", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "tf-te-fixture-")); + const control = fs.mkdtempSync(path.join(os.tmpdir(), "tf-te-control-")); + try { + const def: Taskflow = { + name: "trusted-effects-write", + phases: [{ + id: "write-report", + type: "script", + run: ["node", "-e", "process.stdout.write('REPORT')"], + effects: [fileEffect("report", "out/report.md")], + final: true, + }], + }; + assert.equal(validateTaskflow(def).ok, true); + const result = await executeTaskflow(mkState(def, root, "fixture-commit"), deps(root, control)); + assert.equal(result.ok, true, result.finalOutput); + assert.equal(fs.readFileSync(path.join(root, "out/report.md"), "utf8"), "REPORT"); + assert.ok((result.state.phases["write-report"]?.warnings ?? []).some((warning) => /resource intent/.test(warning))); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(control, { recursive: true, force: true }); + } +}); + +test("fixture: checked-in Trusted Effects example executes through the same path", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "tf-te-example-")); + const control = fs.mkdtempSync(path.join(os.tmpdir(), "tf-te-control-")); + try { + const example = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../examples/trusted-effects-write.json"); + const def = JSON.parse(fs.readFileSync(example, "utf8")) as Taskflow; + const result = await executeTaskflow(mkState(def, root, "fixture-example"), deps(root, control)); + assert.equal(result.ok, true, result.finalOutput); + assert.ok(fs.existsSync(path.join(root, "out/report.md"))); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(control, { recursive: true, force: true }); + } +}); + +test("fixture: direct overwrite of an existing final is rejected and original bytes are restored", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "tf-te-existing-")); + const control = fs.mkdtempSync(path.join(os.tmpdir(), "tf-te-control-")); + try { + fs.mkdirSync(path.join(root, "out")); + fs.writeFileSync(path.join(root, "out/report.md"), "ORIGINAL"); + const def: Taskflow = { + name: "trusted-effects-restore-existing", + phases: [{ + id: "write-report", + type: "script", + run: ["node", "-e", "require('fs').writeFileSync('out/report.md','BYPASS');process.stdout.write('DECLARED')"], + effects: [fileEffect("report", "out/report.md", "existing-file")], + final: true, + }], + }; + const result = await executeTaskflow(mkState(def, root, "fixture-restore"), deps(root, control)); + assert.equal(result.ok, false); + assert.equal(fs.readFileSync(path.join(root, "out/report.md"), "utf8"), "ORIGINAL"); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(control, { recursive: true, force: true }); + } +}); + +test("fixture: multi-write JSON output commits as one resource transaction", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "tf-te-multi-")); + const control = fs.mkdtempSync(path.join(os.tmpdir(), "tf-te-control-")); + try { + const def: Taskflow = { + name: "trusted-effects-multi", + phases: [{ + id: "write", + type: "script", + run: ["node", "-e", "process.stdout.write(JSON.stringify({a:'A',b:'B'}))"], + effects: [fileEffect("a", "a.txt"), fileEffect("b", "b.txt")], + final: true, + }], + }; + const result = await executeTaskflow(mkState(def, root, "fixture-multi"), deps(root, control)); + assert.equal(result.ok, true, result.finalOutput); + assert.equal(fs.readFileSync(path.join(root, "a.txt"), "utf8"), "A"); + assert.equal(fs.readFileSync(path.join(root, "b.txt"), "utf8"), "B"); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(control, { recursive: true, force: true }); + } +}); + +test("fixture: overlapping declared targets fail before script invocation", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "tf-te-overlap-")); + const control = fs.mkdtempSync(path.join(os.tmpdir(), "tf-te-control-")); + try { + const marker = path.join(root, "invoked"); + const def: Taskflow = { + name: "trusted-effects-overlap", + phases: [{ + id: "write", + type: "script", + run: ["node", "-e", `require('fs').writeFileSync(${JSON.stringify(marker)},'yes')`], + effects: [fileEffect("a", "same.txt"), fileEffect("b", "same.txt")], + final: true, + }], + }; + const result = await executeTaskflow(mkState(def, root, "fixture-overlap"), deps(root, control)); + assert.equal(result.ok, false); + assert.equal(fs.existsSync(marker), false); + assert.match(result.state.phases.write?.error ?? "", /overlap|effectir-invalid/); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(control, { recursive: true, force: true }); + } +}); diff --git a/packages/taskflow-core/test/effects-gateway-bypass.test.ts b/packages/taskflow-core/test/effects-gateway-bypass.test.ts new file mode 100644 index 00000000..fc0e4976 --- /dev/null +++ b/packages/taskflow-core/test/effects-gateway-bypass.test.ts @@ -0,0 +1,342 @@ +/** Adversarial runtime coverage for resource-controlled Trusted Effects. */ + +import assert from "node:assert/strict"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { test } from "node:test"; +import { createResolveOnlyWorkspaceSession } from "../src/resources/execution.ts"; +import { WriteIntentJournal } from "../src/resources/journal.ts"; +import { executeTaskflow } from "../src/runtime.ts"; +import { emptyUsage } from "../src/usage.ts"; +import type { Taskflow } from "../src/schema.ts"; +import type { RunState } from "../src/store.ts"; + +function mkState(def: Taskflow, cwd: string, runId = "te-runtime"): RunState { + return { + runId, + flowName: def.name, + def, + args: {}, + status: "running", + phases: {}, + createdAt: Date.now(), + updatedAt: Date.now(), + cwd, + }; +} + +function writeEffect(relativePath: string, id = "report") { + return { + id, + kind: "fs.write" as const, + target: { + kind: "path" as const, + path: { + workspace: "project", + subpath: { literalPath: relativePath }, + intent: "create-file" as const, + }, + }, + }; +} + +test("runtime script: declared write commits through a resource intent", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "tf-te-script-")); + const control = fs.mkdtempSync(path.join(os.tmpdir(), "tf-te-control-")); + try { + const flow: Taskflow = { + name: "te-script-write", + phases: [{ + id: "write", + type: "script", + run: ["node", "-e", "process.stdout.write('HELLO_FROM_RESOURCE')"], + effects: [writeEffect("out/report.md")], + final: true, + }], + }; + const result = await executeTaskflow(mkState(flow, root), { + cwd: root, + workspaceControlDirectory: control, + agents: [], + runTask: async () => { throw new Error("script flow must not call an LLM"); }, + }); + assert.equal(result.ok, true, result.finalOutput); + assert.equal(fs.readFileSync(path.join(root, "out/report.md"), "utf8"), "HELLO_FROM_RESOURCE"); + assert.ok((result.state.phases.write?.warnings ?? []).some((warning) => /resource intent/.test(warning))); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(control, { recursive: true, force: true }); + } +}); + +test("runtime script: direct final write fails and restores the exact pre-state", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "tf-te-script-bypass-")); + const control = fs.mkdtempSync(path.join(os.tmpdir(), "tf-te-control-")); + try { + const flow: Taskflow = { + name: "te-script-bypass", + phases: [{ + id: "write", + type: "script", + run: [ + "node", + "-e", + "const fs=require('fs');fs.mkdirSync('out',{recursive:true});fs.writeFileSync('out/report.md','BYPASS');process.stdout.write('DECLARED')", + ], + effects: [writeEffect("out/report.md")], + final: true, + }], + }; + const result = await executeTaskflow(mkState(flow, root, "script-bypass"), { + cwd: root, + workspaceControlDirectory: control, + agents: [], + runTask: async () => { throw new Error("script flow must not call an LLM"); }, + }); + assert.equal(result.ok, false); + assert.match(result.state.phases.write?.error ?? "", /declared-path-bypass|changed outside/i); + assert.equal(fs.existsSync(path.join(root, "out")), false, "failed phase must leave no declared-path residue"); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(control, { recursive: true, force: true }); + } +}); + +test("runtime admission: workspace parent symlink escape is rejected before body execution", async (t) => { + if (process.platform === "win32") return t.skip("symlink privileges are platform-specific"); + const root = fs.mkdtempSync(path.join(os.tmpdir(), "tf-te-symlink-root-")); + const outside = fs.mkdtempSync(path.join(os.tmpdir(), "tf-te-symlink-outside-")); + const control = fs.mkdtempSync(path.join(os.tmpdir(), "tf-te-control-")); + let invoked = 0; + try { + fs.symlinkSync(outside, path.join(root, "linked"), "dir"); + const flow: Taskflow = { + name: "te-symlink-escape", + phases: [{ + id: "write", + type: "agent", + agent: "executor", + task: "produce content", + effects: [writeEffect("linked/escape.txt")], + final: true, + }], + }; + const result = await executeTaskflow(mkState(flow, root, "symlink-escape"), { + cwd: root, + workspaceControlDirectory: control, + agents: [{ name: "executor", description: "test", systemPrompt: "", source: "user", filePath: "" }], + runTask: async () => { + invoked++; + throw new Error("must not run"); + }, + }); + assert.equal(result.ok, false); + assert.equal(invoked, 0); + assert.match(result.state.phases.write?.error ?? "", /TFWS_PATH_ESCAPE/); + assert.equal(fs.existsSync(path.join(outside, "escape.txt")), false); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(outside, { recursive: true, force: true }); + fs.rmSync(control, { recursive: true, force: true }); + } +}); + +test("runtime multi-write: incomplete content map rejects without final files", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "tf-te-multi-map-")); + const control = fs.mkdtempSync(path.join(os.tmpdir(), "tf-te-control-")); + try { + const flow: Taskflow = { + name: "te-multi-map", + phases: [{ + id: "write", + type: "script", + run: ["node", "-e", "process.stdout.write(JSON.stringify({a:'A'}))"], + effects: [writeEffect("a.txt", "a"), writeEffect("b.txt", "b")], + final: true, + }], + }; + const result = await executeTaskflow(mkState(flow, root, "multi-map"), { + cwd: root, + workspaceControlDirectory: control, + agents: [], + runTask: async () => { throw new Error("script flow must not call an LLM"); }, + }); + assert.equal(result.ok, false); + assert.match(result.state.phases.write?.error ?? "", /content-resolution-failed|missing string content/); + assert.equal(fs.existsSync(path.join(root, "a.txt")), false); + assert.equal(fs.existsSync(path.join(root, "b.txt")), false); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(control, { recursive: true, force: true }); + } +}); + +test("runtime event-kernel flag: declared effects use the same resource transaction semantics", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "tf-te-kernel-fallback-")); + const control = fs.mkdtempSync(path.join(os.tmpdir(), "tf-te-control-")); + try { + const flow: Taskflow = { + name: "te-kernel-fallback", + phases: [{ + id: "write", + type: "script", + run: ["node", "-e", "process.stdout.write('SAME_AUTHORITY')"], + effects: [writeEffect("kernel.txt")], + final: true, + }], + }; + const result = await executeTaskflow(mkState(flow, root, "kernel-fallback"), { + cwd: root, + workspaceControlDirectory: control, + eventKernel: true, + agents: [], + runTask: async () => { throw new Error("script flow must not call an LLM"); }, + }); + assert.equal(result.ok, true, result.finalOutput); + assert.equal(fs.readFileSync(path.join(root, "kernel.txt"), "utf8"), "SAME_AUTHORITY"); + assert.equal((await new WriteIntentJournal({ directory: control, journalEpoch: 1 }).listIntents())[0]?.status, "committed-content"); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(control, { recursive: true, force: true }); + } +}); + +test("runtime PathRef: typed dynamic output path resolves once at resource admission", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "tf-te-dynamic-path-")); + const control = fs.mkdtempSync(path.join(os.tmpdir(), "tf-te-control-")); + try { + const flow: Taskflow = { + name: "te-dynamic-path", + args: { outputPath: { type: "relative-path", required: true } }, + phases: [{ + id: "write", + type: "script", + run: ["node", "-e", "process.stdout.write('DYNAMIC')"], + effects: [{ + id: "report", + kind: "fs.write", + target: { + kind: "path", + path: { + workspace: "project", + subpath: { argPath: "outputPath" }, + intent: "create-file", + }, + }, + }], + final: true, + }], + }; + const state = mkState(flow, root, "dynamic-path"); + state.args = { outputPath: "generated/report.txt" }; + const result = await executeTaskflow(state, { + cwd: root, + workspaceControlDirectory: control, + agents: [], + runTask: async () => { throw new Error("script flow must not call an LLM"); }, + }); + assert.equal(result.ok, true, result.finalOutput); + assert.equal(fs.readFileSync(path.join(root, "generated/report.txt"), "utf8"), "DYNAMIC"); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(control, { recursive: true, force: true }); + } +}); + +test("runtime authority: an isolated cwd outside the invocation grant fails before its body", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "tf-te-isolated-cwd-")); + const control = fs.mkdtempSync(path.join(os.tmpdir(), "tf-te-control-")); + const marker = path.join(root, "body-ran"); + try { + const flow: Taskflow = { + name: "te-isolated-cwd", + phases: [{ + id: "write", + type: "script", + cwd: "temp", + run: ["node", "-e", `require('fs').writeFileSync(${JSON.stringify(marker)},'yes')`], + effects: [writeEffect("report.txt")], + final: true, + }], + }; + const result = await executeTaskflow(mkState(flow, root, "isolated-cwd"), { + cwd: root, + workspaceControlDirectory: control, + agents: [], + runTask: async () => { throw new Error("script flow must not call an LLM"); }, + }); + assert.equal(result.ok, false); + assert.match(result.state.phases.write?.error ?? "", /TFWS_PATH_ESCAPE/); + assert.equal(fs.existsSync(marker), false); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(control, { recursive: true, force: true }); + } +}); + +test("runtime admission: overlapping cross-run effects reject before the second body", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "tf-te-cross-run-")); + const control = fs.mkdtempSync(path.join(os.tmpdir(), "tf-te-control-")); + let releaseFirst!: () => void; + let firstEntered!: () => void; + const firstBodyEntered = new Promise((resolve) => { firstEntered = resolve; }); + const firstBodyRelease = new Promise((resolve) => { releaseFirst = resolve; }); + let secondInvoked = 0; + try { + const flow: Taskflow = { + name: "te-cross-run-overlap", + phases: [{ + id: "write", + type: "agent", + agent: "executor", + task: "produce content", + effects: [writeEffect("shared.txt")], + final: true, + }], + }; + const agents = [{ name: "executor", description: "test", systemPrompt: "", source: "user" as const, filePath: "" }]; + const firstSession = await createResolveOnlyWorkspaceSession({ + invocationRoot: root, + controlDirectory: control, + leaseTimeoutMs: 500, + }); + const secondSession = await createResolveOnlyWorkspaceSession({ + invocationRoot: root, + controlDirectory: control, + leaseTimeoutMs: 30, + }); + const first = executeTaskflow(mkState(flow, root, "run-first"), { + cwd: root, + workspaceSession: firstSession, + agents, + runTask: async (cwd, _agents, agent, task) => { + firstEntered(); + await firstBodyRelease; + return { agent, task, exitCode: 0, output: "FIRST", stderr: "", usage: emptyUsage(), stopReason: "end" }; + }, + }); + await firstBodyEntered; + const second = await executeTaskflow(mkState(flow, root, "run-second"), { + cwd: root, + workspaceSession: secondSession, + agents, + runTask: async () => { + secondInvoked++; + throw new Error("overlapping body must not run"); + }, + }); + assert.equal(second.ok, false); + assert.equal(secondInvoked, 0); + assert.match(second.state.phases.write?.error ?? "", /Lease timeout/); + releaseFirst(); + const firstResult = await first; + assert.equal(firstResult.ok, true, firstResult.finalOutput); + assert.equal(fs.readFileSync(path.join(root, "shared.txt"), "utf8"), "FIRST"); + assert.equal((await new WriteIntentJournal({ directory: control, journalEpoch: 1 }).listIntents()).length, 1); + } finally { + releaseFirst?.(); + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(control, { recursive: true, force: true }); + } +}); diff --git a/packages/taskflow-core/test/effects-static-use-downgrade.test.ts b/packages/taskflow-core/test/effects-static-use-downgrade.test.ts new file mode 100644 index 00000000..2335ea10 --- /dev/null +++ b/packages/taskflow-core/test/effects-static-use-downgrade.test.ts @@ -0,0 +1,250 @@ +/** + * S-H2 (ADV-R1 F1): static flow{use} composition over-taint vs runtime loadFlow. + * + * Static gates (validateTaskflow / verifyTaskflow / FlowIR translate+compile) + * run without a flow store, so a `flow{use: }` child degrades to the + * unknown-boundary summary. Before the fix every such composition hard-failed + * with confidentiality/integrity-flow-violation — even when the saved child + * declares NO effects — while the runtime, which resolves the name through + * `loadFlow`, executed it successfully. Now: + * + * - unresolved `flow{use}` boundaries at static gates are advisory warnings; + * - a resolver can be injected into the static gates to check real child + * effects (resolved real violations still hard-fail); + * - the runtime with a loader remains the authoritative fail-closed gate; + * - dynamic inline `flow{def}` boundaries stay hard-tainted (not downgraded). + */ + +import assert from "node:assert/strict"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { test } from "node:test"; +import type { AgentConfig } from "../src/agents.ts"; +import { compileTaskflowToFlowIR } from "../src/flowir/compile.ts"; +import { translateTaskflow } from "../src/flowir/translate.ts"; +import type { RunResult } from "../src/host/runner-types.ts"; +import { executeTaskflow } from "../src/runtime.ts"; +import { validateTaskflow, type Taskflow } from "../src/schema.ts"; +import type { RunState } from "../src/store.ts"; +import { emptyUsage } from "../src/usage.ts"; +import { verifyTaskflow } from "../src/verify.ts"; + +const AGENTS: AgentConfig[] = [ + { name: "executor", description: "test", systemPrompt: "", source: "user", filePath: "" }, +]; + +function mkState(def: Taskflow, cwd: string): RunState { + return { + runId: "sh2-test-run", + flowName: def.name, + def, + args: {}, + status: "running", + phases: {}, + createdAt: Date.now(), + updatedAt: Date.now(), + cwd, + }; +} + +function okResult(agentName: string, task: string, output: string): RunResult { + return { + agent: agentName, + task, + exitCode: 0, + output, + stderr: "", + usage: { ...emptyUsage(), output: 5, turns: 1 }, + stopReason: "end", + }; +} + +function writeEffect(confidentiality: "public" | "internal" = "internal", literalPath = "out/report.md") { + return { + id: "report", + kind: "fs.write" as const, + target: { + kind: "path" as const, + path: { workspace: "project", subpath: { literalPath }, intent: "create-file" as const }, + }, + confidentiality, + integrity: "project" as const, + }; +} + +const secretEffect = { + id: "secret-input", + kind: "secret.read" as const, + target: { kind: "secret" as const, secret: { secretId: "api-key" } }, +}; + +// --------------------------------------------------------------------------- + +test("S-H2: benign saved flow{use} + downstream declared write passes static gates and runtime", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "tf-sh2-benign-use-")); + try { + // Saved child declares NO effects at all — the legal composition the + // review flagged: static gates rejected it while the runtime accepted it. + const child: Taskflow = { + name: "benign-child", + phases: [{ id: "work", type: "agent", agent: "executor", task: "benign", final: true }], + }; + const def: Taskflow = { + name: "saved-parent-write", + phases: [ + { id: "child", type: "flow", use: child.name }, + { + id: "write", type: "agent", agent: "executor", task: "write", dependsOn: ["child"], + effects: [writeEffect("internal")], final: true, + }, + ], + }; + + // Static gates accept with advisory warnings instead of hard taint errors. + const sv = validateTaskflow(def); + assert.equal(sv.ok, true, sv.errors.join(" | ")); + assert.ok(sv.warnings.some((w) => /unresolved-flow-use|unresolved flow\{use\}/.test(w)), JSON.stringify(sv.warnings)); + + const vv = verifyTaskflow(def); + assert.equal(vv.ok, true, vv.issues.map((i) => i.message).join(" | ")); + assert.ok( + vv.issues.some((i) => i.severity === "warning" && i.category === "effects" && /unresolved flow\{use\}/.test(i.message)), + JSON.stringify(vv.issues), + ); + + const tr = translateTaskflow(def); + assert.equal(tr.errors.length, 0, tr.errors.map((e) => e.message).join(" | ")); + assert.ok(tr.warnings.some((w) => /unresolved flow\{use\}/.test(w.message)), JSON.stringify(tr.warnings)); + + const cr = compileTaskflowToFlowIR(def); + assert.equal(cr.errors.length, 0, cr.errors.map((e) => e.message).join(" | ")); + assert.ok(cr.warnings.some((w) => /unresolved flow\{use\}/.test(w.message)), JSON.stringify(cr.warnings)); + + // Runtime with a loader resolves the child and executes the composition. + const res = await executeTaskflow(mkState(def, root), { + cwd: root, + agents: AGENTS, + loadFlow: (name: string) => (name === child.name ? child : undefined), + runTask: async (_c: string, _a: unknown, agentName: string, task: string) => okResult(agentName, task, "CONTENT\n"), + }); + assert.equal(res.ok, true, res.finalOutput); + assert.equal(fs.readFileSync(path.join(root, "out/report.md"), "utf8"), "CONTENT\n", "declared write must commit"); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("S-H2: real violation is advisory without a loader, hard-fails with a resolver and at runtime", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "tf-sh2-real-violation-")); + let calls = 0; + try { + // Saved child declares a public sink: secret source flowing into it is a + // REAL confidentiality violation — the runtime must keep rejecting it. + const child: Taskflow = { + name: "public-child", + phases: [{ + id: "publish", type: "agent", agent: "executor", task: "publish", + effects: [writeEffect("public", "leak.txt")], final: true, + }], + }; + const def: Taskflow = { + name: "saved-parent-label", + phases: [ + { id: "read-secret", type: "agent", agent: "executor", task: "read", effects: [secretEffect] }, + { id: "child", type: "flow", use: child.name, dependsOn: ["read-secret"], final: true }, + ], + }; + + // Static gate without a loader cannot see the child → advisory only. + const sv = validateTaskflow(def); + assert.equal(sv.ok, true, "static-without-loader must downgrade to warnings"); + assert.ok(sv.warnings.some((w) => /advisory/i.test(w)), JSON.stringify(sv.warnings)); + + // With a loader the child is checked with its REAL effects → hard error. + const loader = (name: string) => (name === child.name ? child : undefined); + const svResolved = validateTaskflow(def, { resolveFlow: loader }); + assert.equal(svResolved.ok, false, "static-with-resolver must catch the real violation"); + assert.ok(svResolved.errors.some((e) => /confidentiality-flow-violation|secret-input.*publish/.test(e)), JSON.stringify(svResolved.errors)); + + const vvResolved = verifyTaskflow(def, { resolveFlow: loader }); + assert.equal(vvResolved.ok, false, "verify-with-resolver must catch the real violation"); + assert.ok(vvResolved.issues.some((i) => i.severity === "error" && /secret-input.*publish/.test(i.message)), JSON.stringify(vvResolved.issues)); + + // Runtime admission stays the authoritative fail-closed gate. + const res = await executeTaskflow(mkState(def, root), { + cwd: root, + agents: AGENTS, + loadFlow: loader, + runTask: async (_c: string, _a: unknown, agentName: string, task: string) => { + calls++; + return okResult(agentName, task, "nope\n"); + }, + }); + assert.equal(res.ok, false); + assert.equal(calls, 0, "real violation must fail before any subagent runs"); + assert.equal(fs.existsSync(path.join(root, "leak.txt")), false); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("S-H2: dynamic inline flow{def} boundary stays hard-tainted (never downgraded)", () => { + // A dynamic inline def string is LLM-authored at runtime — it is not a + // resolver-resolvable saved name, so its unknown boundary stays an error. + const def = { + name: "dynamic-parent", + phases: [ + { id: "read-secret", type: "agent", agent: "executor", task: "read", effects: [secretEffect] }, + { id: "dynamic", type: "flow", def: "{steps.plan.json}", dependsOn: ["read-secret"], final: true }, + ], + } as unknown as Taskflow; + + const sv = validateTaskflow(def); + assert.equal(sv.ok, false, "dynamic inline def must stay hard-tainted"); + assert.ok(sv.errors.some((e) => /dynamic\//.test(e)), JSON.stringify(sv.errors)); + + const vv = verifyTaskflow(def); + assert.equal(vv.ok, false, "verify must keep dynamic def taint as error"); + assert.ok(vv.issues.some((i) => i.severity === "error" && /dynamic\//.test(i.message)), JSON.stringify(vv.issues)); + + const cr = compileTaskflowToFlowIR(def); + assert.ok(cr.errors.some((e) => /dynamic\//.test(e.message)), JSON.stringify(cr.errors)); +}); + +test("S-H2: unresolved use with a store loader that misses the name is advisory, runtime fails closed", async () => { + // Static gate with a loader that cannot find the name (e.g. saved between + // validate and run) degrades to advisory; the runtime loader is the + // authoritative gate and fails before any subagent runs. + const root = fs.mkdtempSync(path.join(os.tmpdir(), "tf-sh2-missing-name-")); + let calls = 0; + try { + const def: Taskflow = { + name: "missing-use-parent", + phases: [ + { id: "child", type: "flow", use: "not-saved-yet" }, + { + id: "write", type: "agent", agent: "executor", task: "write", dependsOn: ["child"], + effects: [writeEffect("internal")], final: true, + }, + ], + }; + const sv = validateTaskflow(def, { resolveFlow: () => undefined }); + assert.equal(sv.ok, true, "a loader miss at static time must stay advisory"); + assert.ok(sv.warnings.some((w) => /unresolved-flow-use|advisory/i.test(w)), JSON.stringify(sv.warnings)); + + const res = await executeTaskflow(mkState(def, root), { + cwd: root, + agents: AGENTS, + loadFlow: () => undefined, + runTask: async (_c: string, _a: unknown, agentName: string, task: string) => { + calls++; + return okResult(agentName, task, "nope\n"); + }, + }); + assert.equal(res.ok, false, "runtime loader miss must fail closed"); + assert.equal(calls, 0); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/packages/taskflow-core/test/effects-trusted.test.ts b/packages/taskflow-core/test/effects-trusted.test.ts new file mode 100644 index 00000000..6b6bfc39 --- /dev/null +++ b/packages/taskflow-core/test/effects-trusted.test.ts @@ -0,0 +1,1006 @@ +/** + * Trusted Effects MVP — unit + vertical-slice fixture (no LLM). + */ + +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + pathsOverlap, + precheckDeclaredFsWriteOverlap, + validateEffectIR, + whyEffect, + whyEffectFromFlow, + formatWhyEffect, + type EffectDecl, + type EffectIR, +} from "../src/effects/index.ts"; + +// --------------------------------------------------------------------------- +// Schema accept / reject — PhaseSchema + FlowIRNode (effects optional) +// --------------------------------------------------------------------------- + +test("PhaseSchema/validateTaskflow: accepts phase with effects[] (and without — backward compat)", async () => { + const { validateTaskflow } = await import("../src/schema.ts"); + const withEffects = { + name: "fx-schema-ok", + phases: [ + { + id: "w", + type: "script" as const, + run: "true", + final: true, + effects: [ + { + id: "w1", + kind: "fs.write", + target: { + kind: "path", + path: { + workspace: "project", + subpath: { literalPath: "out/r.md" }, + intent: "create-file", + }, + }, + }, + ], + }, + ], + }; + const without = { + name: "fx-schema-legacy", + phases: [{ id: "w", type: "script" as const, run: "true", final: true }], + }; + const ok1 = validateTaskflow(withEffects); + const ok2 = validateTaskflow(without); + assert.equal(ok1.ok, true, JSON.stringify(ok1.errors)); + assert.equal(ok2.ok, true, JSON.stringify(ok2.errors)); +}); + +test("TaskflowSchema: effects[] is phase-scoped and rejects a flow-level ghost declaration", async () => { + const { validateTaskflow } = await import("../src/schema.ts"); + const result = validateTaskflow({ + name: "fx-flow-level-rejected", + effects: [{ + id: "ghost", + kind: "fs.write", + target: { kind: "path", path: { workspace: "project", intent: "create-file" } }, + }], + phases: [{ id: "w", type: "script", run: "true", final: true }], + }); + assert.equal(result.ok, false); + assert.ok(result.errors.some((error) => /unknown field 'effects'/.test(error)), JSON.stringify(result.errors)); +}); + +test("PhaseSchema/validateTaskflow: rejects non-array effects", async () => { + const { validateTaskflow } = await import("../src/schema.ts"); + const bad = { + name: "fx-schema-bad", + phases: [ + { + id: "w", + type: "script" as const, + run: "true", + effects: { id: "w1", kind: "fs.write" }, + }, + ], + }; + const r = validateTaskflow(bad); + assert.equal(r.ok, false); + assert.ok( + r.errors.some((e) => /effects/i.test(e) || /array/i.test(e)), + JSON.stringify(r.errors), + ); +}); + +test("PhaseSchema/validateTaskflow: rejects open or unknown EffectIR declarations", async () => { + const { validateTaskflow } = await import("../src/schema.ts"); + const phase = { + id: "w", + type: "script" as const, + run: "true", + effects: [{ + id: "x", + kind: "root.shell", + target: { kind: "path", path: { workspace: "project", intent: "create-file" } }, + ambientAuthority: true, + }], + }; + const result = validateTaskflow({ name: "fx-closed", phases: [phase] }); + assert.equal(result.ok, false); + assert.ok(result.errors.some((error) => /effects|kind|union/i.test(error)), JSON.stringify(result.errors)); +}); + +test("FlowIRNodeSchema/isFlowIRNode: accepts optional effects[] and rejects non-array", async () => { + const { Value } = await import("typebox/value"); + const { FlowIRNodeSchema, isFlowIRNode } = await import("../src/flowir/schema.ts"); + const base = { + id: "w", + kind: "script" as const, + inject: [] as string[], + emits: ["w"], + }; + const withFx = { + ...base, + effects: [ + { + id: "w1", + kind: "fs.write", + target: { + kind: "path", + path: { + workspace: "project", + subpath: { literalPath: "out/r.md" }, + intent: "create-file", + }, + }, + }, + ], + }; + assert.equal(isFlowIRNode(base), true); + assert.equal(isFlowIRNode(withFx), true); + assert.equal(Value.Check(FlowIRNodeSchema, base), true); + assert.equal(Value.Check(FlowIRNodeSchema, withFx), true); + + assert.equal(isFlowIRNode({ ...base, effects: "nope" }), false); + assert.equal(Value.Check(FlowIRNodeSchema, { ...base, effects: "nope" }), false); + const unknown = { + ...base, + effects: [{ + id: "x", + kind: "root.shell", + target: { kind: "path", path: { workspace: "project", intent: "create-file" } }, + }], + }; + assert.equal(isFlowIRNode(unknown), false); + assert.equal(Value.Check(FlowIRNodeSchema, unknown), false); + const open = { ...withFx, effects: [{ ...withFx.effects[0], ambientAuthority: true }] }; + assert.equal(isFlowIRNode(open), false); + assert.equal(Value.Check(FlowIRNodeSchema, open), false); +}); + +// --------------------------------------------------------------------------- +// validate + overlap +// --------------------------------------------------------------------------- + +test("validateEffectIR: accepts well-formed fs.write", () => { + const ir: EffectIR = { + effects: [ + { + id: "w1", + kind: "fs.write", + target: { + kind: "path", + path: { + workspace: "project", + subpath: { literalPath: "out/report.md" }, + intent: "create-file", + }, + }, + confidentiality: "internal", + integrity: "project", + }, + ], + }; + const r = validateEffectIR(ir); + assert.equal(r.ok, true, JSON.stringify(r.issues)); +}); + +test("validateEffectIR: rejects unknown kind", () => { + const r = validateEffectIR({ + effects: [{ id: "x", kind: "net.open", target: { kind: "path", path: { workspace: "p", intent: "create-file" } } }], + }); + assert.equal(r.ok, false); + assert.ok(r.issues.some((i) => i.code === "unknown-effect-kind")); +}); + +test("validateEffectIR: rejects undeclared fields outside the closed schema", () => { + const r = validateEffectIR({ + effects: [{ + id: "x", + kind: "fs.write", + target: { kind: "path", path: { workspace: "p", intent: "create-file" } }, + ambientAuthority: true, + }], + }); + assert.equal(r.ok, false); + assert.ok(r.issues.some((issue) => issue.code === "invalid-effect-shape")); +}); + +test("validateEffectIR: rejects secret material on SecretRef", () => { + const r = validateEffectIR({ + effects: [ + { + id: "s1", + kind: "secret.read", + target: { kind: "secret", secret: { secretId: "k", value: "leaked" } }, + }, + ], + }); + assert.equal(r.ok, false); + assert.ok(r.issues.some((i) => i.code === "secret-material-forbidden")); +}); + +test("validateEffectIR: mutating path overlap is an error", () => { + const r = validateEffectIR({ + effects: [ + { + id: "a", + kind: "fs.write", + target: { + kind: "path", + path: { workspace: "project", subpath: { literalPath: "out/a.md" }, intent: "create-file" }, + }, + }, + { + id: "b", + kind: "fs.delete", + target: { + kind: "path", + path: { workspace: "project", subpath: { literalPath: "out" }, intent: "existing-directory" }, + }, + }, + ], + }); + assert.equal(r.ok, false); + assert.ok(r.issues.some((i) => i.code === "mutating-path-overlap")); +}); + +test("pathsOverlap: parent/child", () => { + assert.equal(pathsOverlap("out", "out/a.md"), true); + assert.equal(pathsOverlap("out/a.md", "out/b.md"), false); +}); + +test("validateEffectIR: secret source cannot flow to a public sink", () => { + const r = validateEffectIR({ + effects: [ + { + id: "secret-input", + kind: "secret.read", + target: { kind: "secret", secret: { secretId: "api-key" } }, + }, + { + id: "public-output", + kind: "fs.write", + confidentiality: "public", + target: { + kind: "path", + path: { workspace: "project", subpath: { literalPath: "leak.txt" }, intent: "create-file" }, + }, + }, + ], + }); + assert.equal(r.ok, false); + assert.ok(r.issues.some((i) => i.code === "confidentiality-flow-violation")); +}); + +test("validateEffectIR: untrusted source cannot flow to a verified sink", () => { + const r = validateEffectIR({ + effects: [ + { + id: "untrusted-input", + kind: "fs.read", + integrity: "untrusted", + target: { kind: "path", path: { workspace: "project", subpath: { literalPath: "in.txt" }, intent: "existing-file" } }, + }, + { + id: "verified-output", + kind: "fs.write", + integrity: "verified", + target: { kind: "path", path: { workspace: "project", subpath: { literalPath: "out.txt" }, intent: "create-file" } }, + }, + ], + }); + assert.equal(r.ok, false); + assert.ok(r.issues.some((i) => i.code === "integrity-flow-violation")); +}); + +test("validateEffectIR: confidentiality and integrity-preserving source-to-sink flow is allowed", () => { + const r = validateEffectIR({ + effects: [ + { + id: "secret-input", + kind: "secret.read", + integrity: "verified", + target: { kind: "secret", secret: { secretId: "api-key" } }, + }, + { + id: "protected-output", + kind: "fs.write", + confidentiality: "secret", + integrity: "project", + target: { kind: "path", path: { workspace: "project", subpath: { literalPath: "protected.txt" }, intent: "create-file" } }, + }, + ], + }); + assert.equal(r.ok, true, JSON.stringify(r.issues)); +}); + +test("validateTaskflow: secret data cannot cross a dependency edge into a public sink", async () => { + const { validateTaskflow } = await import("../src/schema.ts"); + const result = validateTaskflow({ + name: "fx-cross-phase-secret", + phases: [ + { + id: "read-secret", + task: "read", + effects: [{ + id: "secret-input", + kind: "secret.read", + target: { kind: "secret", secret: { secretId: "api-key" } }, + }], + }, + { + id: "publish", + task: "publish", + dependsOn: ["read-secret"], + effects: [{ + id: "public-output", + kind: "fs.write", + confidentiality: "public", + target: { + kind: "path", + path: { workspace: "project", subpath: { literalPath: "public.txt" }, intent: "create-file" }, + }, + }], + }, + ], + }); + assert.equal(result.ok, false); + assert.ok(result.errors.some((error) => /read-secret\/secret-input.*publish\/public-output/.test(error)), JSON.stringify(result.errors)); +}); + +test("validateTaskflow: labels propagate through effect-free intermediate phases", async () => { + const { validateTaskflow } = await import("../src/schema.ts"); + const result = validateTaskflow({ + name: "fx-transitive-integrity", + phases: [ + { + id: "read-untrusted", + task: "read", + effects: [{ + id: "input", + kind: "fs.read", + integrity: "untrusted", + target: { + kind: "path", + path: { workspace: "project", subpath: { literalPath: "input.txt" }, intent: "existing-file" }, + }, + }], + }, + { id: "transform", task: "transform", dependsOn: ["read-untrusted"] }, + { + id: "publish-verified", + task: "publish", + dependsOn: ["transform"], + effects: [{ + id: "verified-output", + kind: "fs.write", + integrity: "verified", + target: { + kind: "path", + path: { workspace: "project", subpath: { literalPath: "verified.txt" }, intent: "create-file" }, + }, + }], + }, + ], + }); + assert.equal(result.ok, false); + assert.ok(result.errors.some((error) => /read-untrusted\/input.*publish-verified\/verified-output/.test(error)), JSON.stringify(result.errors)); +}); + +test("validateTaskflow: cross-phase confidentiality and integrity preserving flow is allowed", async () => { + const { validateTaskflow } = await import("../src/schema.ts"); + const result = validateTaskflow({ + name: "fx-cross-phase-allowed", + phases: [ + { + id: "read-secret", + task: "read", + effects: [{ + id: "secret-input", + kind: "secret.read", + integrity: "verified", + target: { kind: "secret", secret: { secretId: "api-key" } }, + }], + }, + { + id: "store-protected", + task: "store", + dependsOn: ["read-secret"], + effects: [{ + id: "protected-output", + kind: "fs.write", + confidentiality: "secret", + integrity: "project", + target: { + kind: "path", + path: { workspace: "project", subpath: { literalPath: "protected.txt" }, intent: "create-file" }, + }, + }], + }, + ], + }); + assert.equal(result.ok, true, JSON.stringify(result.errors)); +}); + +test("precheckDeclaredFsWriteOverlap: parent/child paths are rejected before admission", () => { + const result = precheckDeclaredFsWriteOverlap([ + { effectId: "parent", relativePath: "out", content: "x" }, + { effectId: "child", relativePath: "out/a.md", content: "y" }, + ]); + assert.equal(result.ok, false); +}); + +// --------------------------------------------------------------------------- +// why-* +// --------------------------------------------------------------------------- + +test("verifyTaskflow: overlapping mutating effects are category=effects errors", async () => { + const { verifyTaskflow } = await import("../src/verify.ts"); + const flow = { + name: "fx-overlap", + phases: [ + { + id: "w", + type: "script" as const, + run: "true", + effects: [ + { + id: "a", + kind: "fs.write", + target: { + kind: "path", + path: { workspace: "project", subpath: { literalPath: "out/a.md" }, intent: "create-file" }, + }, + }, + { + id: "b", + kind: "fs.write", + target: { + kind: "path", + path: { workspace: "project", subpath: { literalPath: "out/a.md" }, intent: "create-file" }, + }, + }, + ], + }, + ], + }; + const r = verifyTaskflow(flow as never); + assert.equal(r.ok, false); + const fx = r.issues.filter((i) => i.category === "effects"); + assert.ok(fx.length > 0, "expected effects issues"); + assert.ok(fx.some((i) => /overlap/i.test(i.message))); + assert.equal(fx[0]!.source, "effects-lint"); + assert.equal(fx[0]!.phaseId, "w"); +}); + +test("verifyTaskflow: unknown effect kind is a verify error", async () => { + const { verifyTaskflow } = await import("../src/verify.ts"); + const flow = { + name: "fx-unknown", + phases: [ + { + id: "w", + type: "script" as const, + run: "true", + effects: [ + { + id: "x", + kind: "net.open", + target: { + kind: "path", + path: { workspace: "project", subpath: { literalPath: "x" }, intent: "create-file" }, + }, + }, + ], + }, + ], + }; + const r = verifyTaskflow(flow as never); + assert.equal(r.ok, false); + assert.ok( + r.issues.some( + (i) => i.category === "effects" && i.severity === "error" && /unknown kind/i.test(i.message), + ), + ); +}); + +test("verifyTaskflow: well-formed effects do not fail verify", async () => { + const { verifyTaskflow } = await import("../src/verify.ts"); + const flow = { + name: "fx-ok", + phases: [ + { + id: "w", + type: "script" as const, + run: "true", + final: true, + effects: [ + { + id: "w1", + kind: "fs.write", + target: { + kind: "path", + path: { + workspace: "project", + subpath: { literalPath: "out/report.md" }, + intent: "create-file", + }, + }, + confidentiality: "internal", + integrity: "project", + }, + ], + }, + ], + }; + const r = verifyTaskflow(flow as never); + assert.equal(r.ok, true, JSON.stringify(r.issues)); + assert.equal(r.issues.filter((i) => i.category === "effects").length, 0); +}); + +test("effectsLintVerifier plugin path: still flags overlap when registered", async () => { + const { effectsLintVerifier } = await import("../src/verifiers/effects-lint.ts"); + const { verifyTaskflow } = await import("../src/verify.ts"); + const flow = { + name: "fx-plugin", + phases: [ + { + id: "w", + type: "script" as const, + run: "true", + effects: [ + { + id: "a", + kind: "fs.write", + target: { + kind: "path", + path: { workspace: "project", subpath: { literalPath: "out/a.md" }, intent: "create-file" }, + }, + }, + { + id: "b", + kind: "fs.write", + target: { + kind: "path", + path: { workspace: "project", subpath: { literalPath: "out/a.md" }, intent: "create-file" }, + }, + }, + ], + }, + ], + }; + // Built-in already flags; plugin adds a second source=effects-lint with category=plugin + const r = verifyTaskflow(flow as never, { verifiers: [effectsLintVerifier] }); + assert.equal(r.ok, false); + assert.ok(r.issues.some((i) => i.category === "effects" && /overlap/i.test(i.message))); + assert.ok(r.issues.some((i) => i.category === "plugin" && i.source === "effects-lint" && /overlap/i.test(i.message))); +}); + +test("translateTaskflow: carries phase effects onto FlowIR nodes", async () => { + const { translateTaskflow } = await import("../src/flowir/translate.ts"); + const def = { + name: "fx-hash", + phases: [ + { + id: "w", + type: "script" as const, + run: "true", + effects: [ + { + id: "w1", + kind: "fs.write", + target: { + kind: "path", + path: { workspace: "project", subpath: { literalPath: "out/r.md" }, intent: "create-file" }, + }, + }, + ], + }, + ], + }; + const { ir } = translateTaskflow(def as never); + assert.ok(ir.nodes[0]?.effects?.length === 1); +}); + +test("translateTaskflow: invalid effects are diagnosed and excluded from projected IR", async () => { + const { translateTaskflow } = await import("../src/flowir/translate.ts"); + const result = translateTaskflow({ + name: "fx-translate-closed", + phases: [{ + id: "w", + type: "script", + run: "true", + effects: [{ id: "x", kind: "root.shell", target: { kind: "path", path: { workspace: "p", intent: "create-file" } } }], + }], + } as never); + assert.ok(result.errors.some((error) => error.code.startsWith("effect-"))); + assert.equal(result.ir.nodes[0]?.effects, undefined); +}); + +test("compileTaskflowToFlowIR + hashFlowIR: effects are content-addressed", async () => { + const { compileTaskflowToFlowIR, hashFlowIR } = await import("../src/flowir/index.ts"); + const basePhase = { + id: "w", + type: "script" as const, + run: "true", + }; + const effect = (path: string) => ({ + id: "w1", + kind: "fs.write" as const, + target: { + kind: "path" as const, + path: { workspace: "project", subpath: { literalPath: path }, intent: "create-file" as const }, + }, + }); + const def1 = { + name: "fx-hash", + phases: [{ ...basePhase, effects: [effect("out/r.md")] }], + }; + const def2 = { + name: "fx-hash", + phases: [{ ...basePhase, effects: [effect("out/other.md")] }], + }; + const defNone = { + name: "fx-hash", + phases: [{ ...basePhase }], + }; + const c1 = compileTaskflowToFlowIR(def1 as never); + const c2 = compileTaskflowToFlowIR(def2 as never); + const c0 = compileTaskflowToFlowIR(defNone as never); + assert.ok(c1.canonical.nodes[0]?.effects?.length === 1, "compile must carry effects"); + assert.ok(c2.canonical.nodes[0]?.effects?.length === 1); + assert.equal(c0.canonical.nodes[0]?.effects, undefined); + + const h1 = hashFlowIR(c1.canonical); + const h2 = hashFlowIR(c2.canonical); + const h0 = hashFlowIR(c0.canonical); + assert.notEqual(h1, h2, "effects must affect content hash"); + assert.notEqual(h1, h0, "presence of effects must affect content hash"); + // Stability + assert.equal(h1, hashFlowIR(c1.canonical)); + assert.match(h1, /^ir:[0-9a-f]{64}$/); +}); + +test("compileTaskflowToIR: invalid EffectIR is diagnosed and never content-addressed", async () => { + const { compileTaskflowToFlowIR, compileTaskflowToIR } = await import("../src/flowir/index.ts"); + const def = { + name: "fx-compile-closed", + phases: [{ + id: "w", + type: "script" as const, + run: "true", + effects: [{ + id: "x", + kind: "root.shell", + target: { kind: "path", path: { workspace: "project", intent: "create-file" } }, + }], + }], + }; + const compiled = compileTaskflowToFlowIR(def as never); + assert.equal(compiled.usedFallbackHash, true); + assert.ok(compiled.errors.some((error) => /effect-/.test(error.code))); + assert.equal(compiled.canonical.nodes[0]?.effects, undefined); + const publicResult = await compileTaskflowToIR(def as never); + assert.equal(publicResult.hash, undefined); + assert.equal(publicResult.usedFallbackHash, true); +}); + +test("compileTaskflowToFlowIR: non-array effects fail closed instead of hashing as no effects", async () => { + const { compileTaskflowToFlowIR, compileTaskflowToIR, translateTaskflow } = await import("../src/flowir/index.ts"); + const def = { + name: "fx-effects-not-array", + phases: [{ + id: "w", + type: "script", + run: "true", + effects: { id: "erased-write" }, + }], + }; + const compiled = compileTaskflowToFlowIR(def as never); + assert.equal(compiled.usedFallbackHash, true); + assert.ok(compiled.errors.some((error) => error.code === "effect-effects-not-array"), JSON.stringify(compiled.errors)); + assert.equal(compiled.canonical.nodes[0]?.effects, undefined); + const translated = translateTaskflow(def as never); + assert.ok(translated.errors.some((error) => error.code === "effect-effects-not-array"), JSON.stringify(translated.errors)); + const publicResult = await compileTaskflowToIR(def as never); + assert.equal(publicResult.hash, undefined); +}); + +test("compileTaskflowToFlowIR: nested label-flow violations fail content addressing", async () => { + const { compileTaskflowToFlowIR } = await import("../src/flowir/index.ts"); + const compiled = compileTaskflowToFlowIR({ + name: "fx-compile-composed-label", + phases: [ + { + id: "read-secret", + task: "read", + effects: [{ + id: "secret-input", + kind: "secret.read", + target: { kind: "secret", secret: { secretId: "api-key" } }, + }], + }, + { + id: "child", + type: "flow", + dependsOn: ["read-secret"], + def: { + name: "public-child", + phases: [{ + id: "publish", + type: "script", + run: "true", + effects: [{ + id: "public-output", + kind: "fs.write", + confidentiality: "public", + target: { kind: "path", path: { workspace: "project", subpath: { literalPath: "public.txt" }, intent: "create-file" } }, + }], + final: true, + }], + }, + final: true, + }, + ], + } as never); + assert.equal(compiled.usedFallbackHash, true); + assert.ok(compiled.errors.some((error) => /read-secret\/secret-input.*child\/publish\/public-output/.test(error.message)), JSON.stringify(compiled.errors)); +}); + +test("compileTaskflowToIR: cross-phase label violation is diagnosed and never content-addressed", async () => { + const { compileTaskflowToFlowIR, compileTaskflowToIR, translateTaskflow } = await import("../src/flowir/index.ts"); + const def = { + name: "fx-compile-label-flow", + phases: [ + { + id: "read-secret", + task: "read", + effects: [{ + id: "secret-input", + kind: "secret.read", + target: { kind: "secret", secret: { secretId: "api-key" } }, + }], + }, + { + id: "publish", + type: "script" as const, + run: "true", + dependsOn: ["read-secret"], + effects: [{ + id: "public-output", + kind: "fs.write", + confidentiality: "public", + target: { + kind: "path", + path: { workspace: "project", subpath: { literalPath: "public.txt" }, intent: "create-file" }, + }, + }], + }, + ], + }; + const compiled = compileTaskflowToFlowIR(def as never); + assert.equal(compiled.usedFallbackHash, true); + assert.ok(compiled.errors.some((error) => /confidentiality-flow-violation/.test(error.code))); + assert.ok(compiled.errors.some((error) => /read-secret\/secret-input.*publish\/public-output/.test(error.message))); + const publicResult = await compileTaskflowToIR(def as never); + assert.equal(publicResult.hash, undefined); + const translated = translateTaskflow(def as never); + assert.ok(translated.errors.some((error) => /confidentiality-flow-violation/.test(error.code))); +}); + +test("whyEffect: structured explanation", () => { + const effect: EffectDecl = { + id: "w1", + kind: "fs.write", + purpose: "write report", + confidentiality: "internal", + integrity: "project", + target: { + kind: "path", + path: { workspace: "project", subpath: { literalPath: "out/report.md" }, intent: "create-file" }, + }, + }; + const w = whyEffect({ + effect, + runId: "run_1", + phaseId: "write", + allowed: true, + allowReasons: ["declared on BoundPlan", "validateEffectIR ok"], + intentId: "intent_1", + journalStatus: "committed-content", + status: "committed", + workspaceRoot: "/tmp/proj", + }); + assert.equal(w.effectId, "w1"); + assert.equal(w.authorized.allowed, true); + assert.equal(w.context.confidentiality, "internal"); + assert.ok(w.targetSummary.includes("out/report.md")); + assert.ok(w.reasons.some((r) => r.includes("intent=intent_1"))); +}); + + +test("whyEffectFromFlow: finds declared effect and authorizes when valid", () => { + const flow = { + phases: [ + { + id: "write", + effects: [ + { + id: "w1", + kind: "fs.write", + purpose: "emit report", + confidentiality: "internal", + integrity: "project", + target: { + kind: "path", + path: { + workspace: "project", + subpath: { literalPath: "out/report.md" }, + intent: "create-file", + }, + }, + }, + ], + }, + ], + }; + const r = whyEffectFromFlow({ + flow, + runId: "run_fx_1", + effectId: "w1", + workspaceRoot: "/tmp/proj", + }); + assert.equal(r.ok, true); + if (!r.ok) return; + assert.equal(r.why.effectId, "w1"); + assert.equal(r.why.authorized.allowed, true); + assert.equal(r.why.status, "declared"); + assert.equal(r.phaseId, "write"); + assert.ok(r.why.targetSummary.includes("out/report.md")); + const text = formatWhyEffect(r.why); + assert.ok(text.includes("authorized: yes")); + assert.ok(text.includes("w1")); +}); + +test("whyEffectFromFlow: missing effect fails closed with known list", () => { + const flow = { + phases: [ + { + id: "write", + effects: [ + { + id: "w1", + kind: "fs.write", + target: { + kind: "path", + path: { + workspace: "project", + subpath: { literalPath: "a.txt" }, + intent: "create-file", + }, + }, + }, + ], + }, + ], + }; + const r = whyEffectFromFlow({ flow, runId: "run_x", effectId: "nope" }); + assert.equal(r.ok, false); + if (r.ok) return; + assert.ok(r.error.includes("not found")); + assert.ok(r.error.includes("write/w1") || r.error.includes("w1")); +}); + +test("whyEffectFromFlow: invalid secret material deny (authorized=false)", () => { + const flow = { + phases: [ + { + id: "s", + effects: [ + { + id: "bad-secret", + kind: "secret.read", + target: { + kind: "secret", + secret: { secretId: "x", value: "leaked" }, + }, + }, + ], + }, + ], + }; + const r = whyEffectFromFlow({ flow, runId: "run_s", effectId: "bad-secret" }); + assert.equal(r.ok, true); + if (!r.ok) return; + assert.equal(r.why.authorized.allowed, false); + assert.ok(r.why.authorized.reasons.some((x) => /secret/i.test(x))); +}); + +test("whyEffectFromFlow: ambiguous id requires phaseId", () => { + const effect = { + id: "shared", + kind: "fs.read", + target: { + kind: "path", + path: { + workspace: "project", + subpath: { literalPath: "a.txt" }, + intent: "existing-file", + }, + }, + }; + const flow = { + phases: [ + { id: "a", effects: [effect] }, + { id: "b", effects: [{ ...effect }] }, + ], + }; + const amb = whyEffectFromFlow({ flow, runId: "r", effectId: "shared" }); + assert.equal(amb.ok, false); + if (amb.ok) return; + assert.ok(/ambiguous/i.test(amb.error)); + const ok = whyEffectFromFlow({ flow, runId: "r", effectId: "shared", phaseId: "b" }); + assert.equal(ok.ok, true); + if (!ok.ok) return; + assert.equal(ok.phaseId, "b"); + assert.equal(ok.why.authorized.allowed, true); +}); + +test("whyEffectFromFlow: independent phases do not invent an information-flow dependency", () => { + const flow = { + phases: [ + { + id: "read-secret", + effects: [{ + id: "secret-input", + kind: "secret.read", + target: { kind: "secret", secret: { secretId: "api-key" } }, + }], + }, + { + id: "publish", + effects: [{ + id: "public-output", + kind: "fs.write", + target: { kind: "path", path: { workspace: "project", subpath: { literalPath: "public.txt" }, intent: "create-file" } }, + confidentiality: "public", + }], + }, + ], + }; + const result = whyEffectFromFlow({ flow, runId: "run-independent", phaseId: "publish", effectId: "public-output" }); + assert.equal(result.ok, true); + if (result.ok) assert.equal(result.why.authorized.allowed, true, result.why.authorized.reasons.join("; ")); +}); + +test("whyEffectFromFlow: dependency-connected phases agree with DAG label validation", () => { + const flow = { + phases: [ + { + id: "read-secret", + effects: [{ + id: "secret-input", + kind: "secret.read", + target: { kind: "secret", secret: { secretId: "api-key" } }, + }], + }, + { + id: "publish", + dependsOn: ["read-secret"], + effects: [{ + id: "public-output", + kind: "fs.write", + target: { kind: "path", path: { workspace: "project", subpath: { literalPath: "public.txt" }, intent: "create-file" } }, + confidentiality: "public", + }], + }, + ], + }; + const result = whyEffectFromFlow({ flow, runId: "run-dependent", phaseId: "publish", effectId: "public-output" }); + assert.equal(result.ok, true); + if (result.ok) { + assert.equal(result.why.authorized.allowed, false); + assert.ok(result.why.authorized.reasons.some((reason) => /read-secret\/secret-input.*publish\/public-output/.test(reason))); + } +}); diff --git a/packages/taskflow-core/test/flowir-canonical-hash.test.ts b/packages/taskflow-core/test/flowir-canonical-hash.test.ts index b3bc7c36..04314785 100644 --- a/packages/taskflow-core/test/flowir-canonical-hash.test.ts +++ b/packages/taskflow-core/test/flowir-canonical-hash.test.ts @@ -5,6 +5,7 @@ import { hashFlowIR, hashNode, } from "../src/flowir/canonical-hash.ts"; +import type { EffectDecl } from "../src/effects/types.ts"; import type { FlowIR, FlowIRNode } from "../src/flowir/schema.ts"; function node(id: string, overrides: Partial = {}): FlowIRNode { @@ -303,3 +304,92 @@ test("SENSITIVITY: two distinct IRs do not collide", () => { ]); assert.notEqual(hashFlowIR(a), hashFlowIR(b)); }); + +// --------------------------------------------------------------------------- +// 4. TRUSTED EFFECTS (0.3) — content-addressed when present +// --------------------------------------------------------------------------- + +const sampleEffect = (path: string): EffectDecl => ({ + id: "w1", + kind: "fs.write", + target: { + kind: "path", + path: { workspace: "project", subpath: { literalPath: path }, intent: "create-file" }, + }, + integrity: "project", + confidentiality: "internal", +}); + +test("SENSITIVITY: node effects change hashNode", () => { + const a = node("x", { task: "t" }); + const b = node("x", { task: "t", effects: [sampleEffect("out/a.md")] }); + assert.notEqual(hashNode(a), hashNode(b), "presence of effects must change the node hash"); +}); + +test("SENSITIVITY: different effect targets produce different hashes", () => { + const a = node("x", { effects: [sampleEffect("out/a.md")] }); + const b = node("x", { effects: [sampleEffect("out/b.md")] }); + assert.notEqual(hashNode(a), hashNode(b)); +}); + +test("SENSITIVITY: different effect kinds produce different hashes", () => { + const a = node("x", { + effects: [{ id: "e1", kind: "fs.read" as const, target: { kind: "path", path: { workspace: "project", subpath: { literalPath: "in.md" }, intent: "existing-file" } } }], + }); + const b = node("x", { + effects: [{ id: "e1", kind: "fs.write" as const, target: { kind: "path", path: { workspace: "project", subpath: { literalPath: "in.md" }, intent: "create-file" } } }], + }); + assert.notEqual(hashNode(a), hashNode(b)); +}); + +test("SENSITIVITY: flows differing only in effects produce different hashFlowIR", () => { + const a = ir([node("w", { kind: "script", effects: [sampleEffect("out/a.md")] })]); + const b = ir([node("w", { kind: "script", effects: [sampleEffect("out/b.md")] })]); + assert.notEqual(hashFlowIR(a), hashFlowIR(b)); +}); + +test("DETERMINISM: effects-bearing IR hashes stably across calls and clones", () => { + const a = ir([ + node("w", { + kind: "script", + task: "write", + effects: [sampleEffect("out/report.md")], + }), + ]); + const h1 = hashFlowIR(a); + const h2 = hashFlowIR(a); + const h3 = hashFlowIR(JSON.parse(JSON.stringify(a))); + assert.equal(h1, h2); + assert.equal(h2, h3); + assert.match(h1, /^ir:[0-9a-f]{64}$/); +}); + +test("INDEPENDENCE: effects object key order does not change the hash", () => { + const e1: EffectDecl = { + id: "w1", + kind: "fs.write", + integrity: "project", + confidentiality: "internal", + target: { + kind: "path", + path: { workspace: "project", subpath: { literalPath: "out/r.md" }, intent: "create-file" }, + }, + }; + const e2: EffectDecl = { + target: { + path: { intent: "create-file", subpath: { literalPath: "out/r.md" }, workspace: "project" }, + kind: "path", + }, + confidentiality: "internal", + integrity: "project", + kind: "fs.write", + id: "w1", + }; + assert.equal(hashNode(node("x", { effects: [e1] })), hashNode(node("x", { effects: [e2] }))); +}); + +test("INDEPENDENCE: empty effects array is equivalent to absent effects", () => { + const a = node("x", { task: "t", effects: [] }); + const b = node("x", { task: "t" }); + assert.equal(hashNode(a), hashNode(b)); +}); diff --git a/packages/taskflow-core/test/resource-file-transaction.test.ts b/packages/taskflow-core/test/resource-file-transaction.test.ts new file mode 100644 index 00000000..5d7374df --- /dev/null +++ b/packages/taskflow-core/test/resource-file-transaction.test.ts @@ -0,0 +1,519 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { pathToFileURL } from "node:url"; +import { test } from "node:test"; +import { + finalizePreparedDeclaredFsWrites, + preparePhaseDeclaredFsWrites, +} from "../src/effects/runtime-apply.ts"; +import { whyEffectFromLedger } from "../src/effects/why.ts"; +import { + createResolveOnlyWorkspaceSession, + type ResolveOnlyPhaseBinding, +} from "../src/resources/execution.ts"; +import { + prepareResourceFileTransaction, + type ResolvedFileWriteTarget, +} from "../src/resources/file-transaction.ts"; +import { WriteIntentJournal } from "../src/resources/journal.ts"; +import { + PersistentLeaseCoordinator, + type LeaseAcquireOptions, + type LeaseHandle, + type LeaseRequest, +} from "../src/resources/leases.ts"; +import type { MutationPermit } from "../src/resources/permits.ts"; +import { resolvePathRef } from "../src/resources/resolve.ts"; +import type { PathRef, ScopedCapability } from "../src/resources/schema.ts"; +import type { ExecutionOwner } from "../src/resources/types.ts"; + +function fixture(): { root: string; control: string } { + return { + root: fs.mkdtempSync(path.join(os.tmpdir(), "tfws-file-tx-root-")), + control: fs.mkdtempSync(path.join(os.tmpdir(), "tfws-file-tx-control-")), + }; +} + +function transactionArtifacts(control: string): string[] { + const directory = path.join(control, "file-transactions"); + return fs.existsSync(directory) ? fs.readdirSync(directory) : []; +} + +async function rootBinding(root: string, control: string, leaseTimeoutMs = 500): Promise { + const session = await createResolveOnlyWorkspaceSession({ + invocationRoot: root, + controlDirectory: control, + leaseTimeoutMs, + }); + return session.bindPhase({ + invocationRoot: root, + runId: "run", + phaseId: "phase", + argDefinitions: {}, + argValues: {}, + }); +} + +function writePath(relativePath: string): PathRef { + return { + workspace: "project", + subpath: { literalPath: relativePath }, + access: "read-write", + intent: "create-file", + maxLifetime: { scope: "phase" }, + }; +} + +test("resource file transaction: commits content with durable authority evidence", async () => { + const { root, control } = fixture(); + try { + const bound = await rootBinding(root, control); + const tx = await bound.beginFileWriteTransaction([ + { effectId: "a", path: writePath("out/a.txt") }, + { effectId: "b", path: writePath("out/b.txt") }, + ]); + const result = await tx.commit([ + { effectId: "a", content: "A" }, + { effectId: "b", content: "B" }, + ]); + assert.equal(result.ok, true); + assert.equal(fs.readFileSync(path.join(root, "out/a.txt"), "utf8"), "A"); + assert.equal(fs.readFileSync(path.join(root, "out/b.txt"), "utf8"), "B"); + const journal = new WriteIntentJournal({ directory: control, journalEpoch: 1 }); + const [intent] = await journal.listIntents(); + assert.equal(intent?.status, "committed-content"); + assert.equal(intent?.commitGeneration, 1); + assert.equal(intent?.restorableSnapshotArtifactIds?.length, 2); + assert.equal(intent?.authorizationPrincipalId, "local-host-invocation"); + assert.deepEqual(intent?.scopes.map((scope) => scope.effectId).sort(), ["a", "b"]); + assert.ok(intent?.scopes.every((scope) => scope.capabilityBindingId?.startsWith("binding-"))); + const why = whyEffectFromLedger({ + flow: { + phases: [{ + id: "phase", + effects: [{ + id: "a", + kind: "fs.write", + target: { kind: "path", path: writePath("out/a.txt") }, + }], + }], + }, + runId: "run", + phaseId: "phase", + effectId: "a", + intents: [intent!], + }); + assert.equal(why.ok, true); + if (why.ok) { + assert.equal(why.why.authorized.allowed, true); + assert.equal(why.why.status, "committed"); + assert.equal(why.why.intentId, intent?.intentId); + assert.equal(why.why.authorized.principalId, "local-host-invocation"); + } + const declarationOnly = whyEffectFromLedger({ + flow: { phases: [{ id: "phase", effects: [{ + id: "a", + kind: "fs.write", + target: { kind: "path", path: writePath("out/a.txt") }, + }] }] }, + runId: "run", + phaseId: "phase", + effectId: "a", + intents: [], + }); + assert.equal(declarationOnly.ok, true); + if (declarationOnly.ok) assert.equal(declarationOnly.why.authorized.allowed, false); + assert.deepEqual(transactionArtifacts(control), [], "terminal commit garbage-collects before-images"); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(control, { recursive: true, force: true }); + } +}); + +test("resource file transaction: direct final-path bypass is restored with a known-clean terminal record", async () => { + const { root, control } = fixture(); + try { + const bound = await rootBinding(root, control); + const tx = await bound.beginFileWriteTransaction([ + { effectId: "report", path: writePath("out/report.md") }, + ]); + fs.mkdirSync(path.join(root, "out")); + fs.writeFileSync(path.join(root, "out/report.md"), "BYPASS"); + const result = await tx.commit([{ effectId: "report", content: "DECLARED" }]); + assert.equal(result.ok, false); + if (result.ok) return; + assert.equal(result.code, "declared-path-bypass"); + assert.equal(result.restored, true); + assert.equal(fs.existsSync(path.join(root, "out/report.md")), false); + assert.equal(fs.existsSync(path.join(root, "out")), false, "new empty parents are part of rollback"); + const journal = new WriteIntentJournal({ directory: control, journalEpoch: 1 }); + const [intent] = await journal.listIntents(); + assert.equal(intent?.status, "aborted-restored"); + assert.match(intent?.terminalReason ?? "", /changed outside the resource transaction/); + assert.equal(await journal.getDomainGeneration(intent!.resourceDomainId), 0); + assert.deepEqual(transactionArtifacts(control), [], "terminal abort garbage-collects before-images"); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(control, { recursive: true, force: true }); + } +}); + +test("effects bridge: declaration is admitted before body and committed by resource authority", async () => { + const { root, control } = fixture(); + try { + const bound = await rootBinding(root, control); + const effects = [{ + id: "report", + kind: "fs.write" as const, + target: { kind: "path" as const, path: writePath("report.md") }, + confidentiality: "internal" as const, + integrity: "project" as const, + }]; + const admitted = await preparePhaseDeclaredFsWrites(bound, { effects }); + assert.equal(admitted.ok, true); + if (!admitted.ok) return; + const finalized = await finalizePreparedDeclaredFsWrites(admitted.prepared, "REPORT"); + assert.equal(finalized.ok, true); + assert.equal(fs.readFileSync(path.join(root, "report.md"), "utf8"), "REPORT"); + const [intent] = await new WriteIntentJournal({ directory: control, journalEpoch: 1 }).listIntents(); + assert.equal(intent?.status, "committed-content"); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(control, { recursive: true, force: true }); + } +}); + +test("resource file transaction: PathRef resolver rejects a parent symlink escape before intent creation", async (t) => { + if (process.platform === "win32") return t.skip("symlink privileges are platform-specific"); + const { root, control } = fixture(); + const outside = fs.mkdtempSync(path.join(os.tmpdir(), "tfws-file-tx-outside-")); + try { + fs.symlinkSync(outside, path.join(root, "linked"), "dir"); + const bound = await rootBinding(root, control); + await assert.rejects( + bound.beginFileWriteTransaction([{ effectId: "escape", path: writePath("linked/escape.txt") }]), + /TFWS_PATH_ESCAPE/, + ); + assert.equal(fs.existsSync(path.join(outside, "escape.txt")), false); + const journal = new WriteIntentJournal({ directory: control, journalEpoch: 1 }); + assert.deepEqual(await journal.listIntents(), []); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(control, { recursive: true, force: true }); + fs.rmSync(outside, { recursive: true, force: true }); + } +}); + +test("resource file transaction: overlapping cross-session writer is rejected before a second intent", async () => { + const { root, control } = fixture(); + try { + const first = await rootBinding(root, control, 500); + const second = await rootBinding(root, control, 30); + const open = await first.beginFileWriteTransaction([{ effectId: "a", path: writePath("same.txt") }]); + await assert.rejects( + second.beginFileWriteTransaction([{ effectId: "b", path: writePath("same.txt") }]), + /Lease timeout/, + ); + await open.reject("test cleanup"); + const journal = new WriteIntentJournal({ directory: control, journalEpoch: 1 }); + const intents = await journal.listIntents(); + assert.equal(intents.length, 1); + assert.equal(intents[0]?.status, "aborted-restored"); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(control, { recursive: true, force: true }); + } +}); + +class FailSecondPermitAssertionJournal extends WriteIntentJournal { + #calls = 0; + + override async assertActive(permit: MutationPermit, owner: ExecutionOwner): Promise { + await super.assertActive(permit, owner); + this.#calls++; + if (this.#calls === 2) throw new Error("injected second promotion failure"); + } +} + +class ReleaseFailsAfterDurableUnlockCoordinator extends PersistentLeaseCoordinator { + override async acquire( + requests: readonly LeaseRequest[], + options: LeaseAcquireOptions = {}, + ): Promise { + const lease = await super.acquire(requests, options); + return { + ...lease, + release: async () => { + await lease.release(); + throw new Error("injected post-release cleanup failure"); + }, + }; + } +} + +class ActivationAndInspectionFailJournal extends WriteIntentJournal { + override async activate(): Promise { + throw new Error("injected activation failure"); + } + + override async getIntent(): Promise { + throw new Error("injected journal inspection failure"); + } +} + +function resolvedTargets(root: string, owner: ExecutionOwner): ResolvedFileWriteTarget[] { + const capability: ScopedCapability = { + bindingId: "binding", + resourceDomainId: "domain", + providerInstanceId: "root", + logicalWorkspaceId: "project", + logicalPrefix: "", + physicalScopeRoot: root, + access: "read-write", + version: { identityMode: "path-bound", generation: 0, state: "clean" }, + lifetime: { scope: "phase", runId: owner.runId, phaseId: owner.phaseId, attemptId: owner.attemptId }, + }; + return ["a.txt", "b.txt"].map((relativePath, index) => { + const ref = resolvePathRef( + writePath(relativePath), + { + workspaces: new Map([["project", capability]]), + runId: owner.runId, + phaseId: owner.phaseId, + attemptId: owner.attemptId, + }, + { definitions: {}, values: {} }, + ); + if (!ref.ok) throw new Error(ref.error.redactedMessage); + return { effectId: index === 0 ? "a" : "b", ref: ref.value }; + }); +} + +test("resource file transaction: later promotion failure rolls back every earlier file", async () => { + const { root, control } = fixture(); + const owner: ExecutionOwner = { + runId: "run", + phaseId: "phase", + attemptId: "attempt", + unitId: "unit", + ancestry: [], + }; + try { + const journal = new FailSecondPermitAssertionJournal({ directory: control, journalEpoch: 1 }); + const tx = await prepareResourceFileTransaction({ + controlDirectory: control, + resourceDomainId: "domain", + owner, + targets: resolvedTargets(root, owner), + leases: new PersistentLeaseCoordinator({ directory: control, registryId: "registry" }), + journal, + leaseTimeoutMs: 500, + permitTtlMs: 5_000, + authorizationScopeRoot: root, + }); + const result = await tx.commit([ + { effectId: "a", content: "A" }, + { effectId: "b", content: "B" }, + ]); + assert.equal(result.ok, false); + assert.equal(fs.existsSync(path.join(root, "a.txt")), false); + assert.equal(fs.existsSync(path.join(root, "b.txt")), false); + assert.equal((await journal.listIntents())[0]?.status, "aborted-restored"); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(control, { recursive: true, force: true }); + } +}); + +test("resource file transaction: post-terminal lease cleanup failure never makes commit retryable", async () => { + const { root, control } = fixture(); + const owner: ExecutionOwner = { + runId: "run", + phaseId: "phase", + attemptId: "attempt", + unitId: "unit", + ancestry: [], + }; + const originalWarn = console.warn; + const warnings: string[] = []; + console.warn = (...args: unknown[]) => warnings.push(args.map(String).join(" ")); + try { + const journal = new WriteIntentJournal({ directory: control, journalEpoch: 1 }); + const tx = await prepareResourceFileTransaction({ + controlDirectory: control, + resourceDomainId: "domain", + owner, + targets: resolvedTargets(root, owner).slice(0, 1), + leases: new ReleaseFailsAfterDurableUnlockCoordinator({ directory: control, registryId: "registry" }), + journal, + leaseTimeoutMs: 500, + permitTtlMs: 5_000, + authorizationScopeRoot: root, + }); + const result = await tx.commit([{ effectId: "a", content: "A" }]); + assert.equal(result.ok, true); + assert.equal(fs.readFileSync(path.join(root, "a.txt"), "utf8"), "A"); + assert.equal((await journal.listIntents())[0]?.status, "committed-content"); + assert.ok(warnings.some((warning) => /lease cleanup deferred/.test(warning))); + } finally { + console.warn = originalWarn; + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(control, { recursive: true, force: true }); + } +}); + +test("resource file transaction: post-commit staged cleanup failure remains durable success", async () => { + const { root, control } = fixture(); + const owner: ExecutionOwner = { + runId: "run", + phaseId: "phase", + attemptId: "attempt", + unitId: "unit", + ancestry: [], + }; + const warnings: string[] = []; + const originalWarn = console.warn; + console.warn = (...args: unknown[]) => warnings.push(args.map(String).join(" ")); + let cleanupCalls = 0; + try { + const leases = new PersistentLeaseCoordinator({ directory: control, registryId: "registry" }); + const journal = new WriteIntentJournal({ directory: control, journalEpoch: 1 }); + const options = { + controlDirectory: control, + resourceDomainId: "domain", + owner, + targets: resolvedTargets(root, owner).slice(0, 1), + leases, + journal, + leaseTimeoutMs: 500, + permitTtlMs: 5_000, + authorizationScopeRoot: root, + cleanupStaging: () => { + cleanupCalls++; + throw new Error("injected staged cleanup failure"); + }, + } as Parameters[0] & { cleanupStaging: () => void }; + const tx = await prepareResourceFileTransaction(options); + const result = await tx.commit([{ effectId: "a", content: "A" }]); + assert.equal(result.ok, true); + assert.equal(cleanupCalls, 1); + assert.equal(fs.readFileSync(path.join(root, "a.txt"), "utf8"), "A"); + assert.equal((await journal.listIntents())[0]?.status, "committed-content"); + assert.equal((await leases.list()).length, 0, "cleanup failure must not leak the lease"); + assert.ok(warnings.some((warning) => /staging cleanup deferred/.test(warning))); + } finally { + console.warn = originalWarn; + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(control, { recursive: true, force: true }); + } +}); + +test("resource file transaction: activation plus journal-inspection double fault still releases lease", async () => { + const { root, control } = fixture(); + const owner: ExecutionOwner = { + runId: "run", + phaseId: "phase", + attemptId: "attempt", + unitId: "unit", + ancestry: [], + }; + try { + const leases = new PersistentLeaseCoordinator({ directory: control, registryId: "registry" }); + await assert.rejects( + prepareResourceFileTransaction({ + controlDirectory: control, + resourceDomainId: "domain", + owner, + targets: resolvedTargets(root, owner).slice(0, 1), + leases, + journal: new ActivationAndInspectionFailJournal({ directory: control, journalEpoch: 1 }), + leaseTimeoutMs: 500, + permitTtlMs: 5_000, + authorizationScopeRoot: root, + }), + /injected journal inspection failure/, + ); + assert.equal((await leases.list()).length, 0, "all preparation failures must release the lease"); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(control, { recursive: true, force: true }); + } +}); + +test("resource file transaction: startup recovery restores a process-crashed partial multi-file mutation", async () => { + const { root, control } = fixture(); + try { + const executionModule = pathToFileURL(path.resolve( + path.dirname(new URL(import.meta.url).pathname), + "../src/resources/execution.ts", + )).href; + const child = spawnSync(process.execPath, [ + "--experimental-strip-types", + "--input-type=module", + "-e", + ` + import * as fs from "node:fs"; + import * as path from "node:path"; + import { createResolveOnlyWorkspaceSession } from ${JSON.stringify(executionModule)}; + const root = ${JSON.stringify(root)}; + const session = await createResolveOnlyWorkspaceSession({ invocationRoot: root, controlDirectory: ${JSON.stringify(control)} }); + const bound = await session.bindPhase({ invocationRoot: root, runId: "crashed-run", phaseId: "write", argDefinitions: {}, argValues: {} }); + await bound.beginFileWriteTransaction([ + { effectId: "a", path: { workspace: "project", subpath: { literalPath: "out/a.txt" }, intent: "create-file" } }, + { effectId: "b", path: { workspace: "project", subpath: { literalPath: "out/b.txt" }, intent: "create-file" } }, + ]); + fs.mkdirSync(path.join(root, "out"), { recursive: true }); + fs.writeFileSync(path.join(root, "out/a.txt"), "PARTIAL_CRASH"); + process.exit(0); + `, + ], { encoding: "utf8", timeout: 10_000 }); + assert.equal(child.status, 0, child.stderr); + assert.equal(fs.readFileSync(path.join(root, "out/a.txt"), "utf8"), "PARTIAL_CRASH"); + + await createResolveOnlyWorkspaceSession({ invocationRoot: root, controlDirectory: control }); + assert.equal(fs.existsSync(path.join(root, "out/a.txt")), false); + assert.equal(fs.existsSync(path.join(root, "out/b.txt")), false); + assert.equal(fs.existsSync(path.join(root, "out")), false); + const [intent] = await new WriteIntentJournal({ directory: control, journalEpoch: 1 }).listIntents(); + assert.equal(intent?.status, "aborted-restored"); + assert.match(intent?.terminalReason ?? "", /startup recovery restored/); + assert.equal(intent?.commitGeneration, undefined); + assert.deepEqual(transactionArtifacts(control), [], "startup recovery garbage-collects restored before-images"); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(control, { recursive: true, force: true }); + } +}); + +test("resource file transaction: startup garbage-collects orphan snapshot directories", async () => { + const { root, control } = fixture(); + try { + const orphan = path.join(control, "file-transactions", "orphan-before-intent"); + fs.mkdirSync(orphan, { recursive: true }); + fs.writeFileSync(path.join(orphan, "before.blob"), "SECRET-BEFORE-IMAGE"); + const stale = new Date(Date.now() - 10 * 60_000); + fs.utimesSync(orphan, stale, stale); + await createResolveOnlyWorkspaceSession({ invocationRoot: root, controlDirectory: control }); + assert.deepEqual(transactionArtifacts(control), []); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(control, { recursive: true, force: true }); + } +}); + +test("resource file transaction: startup GC preserves a fresh pre-intent transaction window", async () => { + const { root, control } = fixture(); + try { + const fresh = path.join(control, "file-transactions", "fresh-before-intent"); + fs.mkdirSync(fresh, { recursive: true }); + fs.writeFileSync(path.join(fresh, "before.blob"), "IN-FLIGHT-BEFORE-IMAGE"); + await createResolveOnlyWorkspaceSession({ invocationRoot: root, controlDirectory: control }); + assert.deepEqual(transactionArtifacts(control), ["fresh-before-intent"]); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(control, { recursive: true, force: true }); + } +}); diff --git a/packages/taskflow-core/test/resource-journal.test.ts b/packages/taskflow-core/test/resource-journal.test.ts index a083e818..27cbc53a 100644 --- a/packages/taskflow-core/test/resource-journal.test.ts +++ b/packages/taskflow-core/test/resource-journal.test.ts @@ -256,3 +256,42 @@ test("journal: content commits require exact scopes and trustworthy post-state e fs.rmSync(dir, { recursive: true, force: true }); } }); + +test("journal: restored abort is terminal, generation-neutral, and ledger-backed", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "taskflow-journal-abort-restored-")); + const root = path.join(dir, "repo"); + fs.mkdirSync(root); + try { + const journal = new WriteIntentJournal({ directory: path.join(dir, "control"), journalEpoch: 1 }); + const prepared = await journal.prepare(input(root, { commitMode: "content-snapshot" })); + await journal.activate([prepared.permit], OWNER); + await assert.rejects( + journal.abortRestored(prepared.intent.intentId, [evidence(root, "repo", true)], "rollback"), + /beforeContentId === afterContentId/, + ); + const restored = [{ + ...evidence(root, "repo"), + afterContentId: "before-repo", + }]; + const aborted = await journal.abortRestored( + prepared.intent.intentId, + restored, + "commit rejected; exact pre-state restored", + ["snapshot-before-repo"], + ); + assert.equal(aborted.status, "aborted-restored"); + assert.equal(aborted.commitGeneration, undefined); + assert.equal(aborted.terminalReason, "commit rejected; exact pre-state restored"); + assert.deepEqual(aborted.restorableSnapshotArtifactIds, ["snapshot-before-repo"]); + assert.equal(await journal.getDomainGeneration("repo"), 0); + assert.equal(await journal.permits.stateOf(prepared.permit.permitId), "settled"); + + const next = await journal.prepare(input(root, { + owner: { ...OWNER, attemptId: "after-restored-abort" }, + commitMode: "content-snapshot", + })); + await journal.markUnknown(next.intent.intentId, "test cleanup"); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/packages/taskflow-core/test/runner-process.test.ts b/packages/taskflow-core/test/runner-process.test.ts index c5589580..a3f8717f 100644 --- a/packages/taskflow-core/test/runner-process.test.ts +++ b/packages/taskflow-core/test/runner-process.test.ts @@ -228,7 +228,7 @@ test("runSubagentProcess completion: ignored metadata preserves a terminal candi emit({type:"final",text:"DONE"}); emit({type:"terminal"}); setTimeout(()=>emit({type:"diagnostic",message:"metadata only"}),30); setInterval(()=>{},1000); - `, { idleTimeoutMs: 250, terminalGraceMs: 120, signal: controller.signal }); + `, { idleTimeoutMs: 1_000, terminalGraceMs: 120, signal: controller.signal }); assert.equal(r.output, "DONE"); assert.equal(r.completionSource, "terminal-reap", "ignored metadata must preserve the terminal candidate"); } finally { diff --git a/packages/taskflow-core/test/store.test.ts b/packages/taskflow-core/test/store.test.ts index 9d96e084..d6423c6b 100644 --- a/packages/taskflow-core/test/store.test.ts +++ b/packages/taskflow-core/test/store.test.ts @@ -4,6 +4,7 @@ import { createRequire, syncBuiltinESMExports } from "node:module"; import * as os from "node:os"; import * as path from "node:path"; import { test } from "node:test"; +import { fileURLToPath, pathToFileURL } from "node:url"; import type { Taskflow } from "../src/schema.ts"; import { getFlow, @@ -25,6 +26,16 @@ import { directoryIdentity } from "../src/cwd-bridge.ts"; // Helpers // --------------------------------------------------------------------------- +/** + * File URL for the store module used by child-process scripts below. + * `path.resolve()` yields a backslash path on Windows (e.g. `D:\a\...`) which + * ESM treats as a bare package specifier and fails to resolve at startup + * (ERR_MODULE_NOT_FOUND / ERR_UNSUPPORTED_ESM_URL_SCHEME). `pathToFileURL` + * produces a portable `file:///D:/a/...` specifier that Node resolves on every + * platform. + */ +const STORE_SRC_URL = pathToFileURL(path.resolve("packages/taskflow-core/src/store.ts")).href; + /** Create an isolated temp directory with a `.pi` marker so findProjectFlowsDir finds it. */ function makeTmpCwd(): string { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "taskflow-store-test-")); @@ -36,6 +47,39 @@ function cleanup(dir: string): void { fs.rmSync(dir, { recursive: true, force: true }); } +/** + * Compare filesystem paths by their native spelling rather than their lexical + * spelling. macOS commonly exposes `/var` and `/private/var` as aliases, while + * Windows may return an 8.3 short path from `realpathSync()` and a long path + * from `realpathSync.native()`. The store deliberately uses native realpaths; + * test fault-injection paths must use the same identity or the injected race + * never fires on those runners. + */ +function canonicalTestPath(input: string): string { + const absolute = path.resolve(input); + try { + return fs.realpathSync.native(absolute); + } catch { + try { + return path.join(fs.realpathSync.native(path.dirname(absolute)), path.basename(absolute)); + } catch { + return absolute; + } + } +} + +function sameTestPath(left: string, right: string): boolean { + const a = canonicalTestPath(left); + const b = canonicalTestPath(right); + return process.platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b; +} + +function isAtomicTempPath(candidate: string, target: string): boolean { + const canonicalCandidate = canonicalTestPath(candidate); + const canonicalTarget = canonicalTestPath(target); + return canonicalCandidate.startsWith(`${canonicalTarget}.`) && canonicalCandidate.endsWith(".tmp"); +} + function minimalFlow(name: string): Taskflow { return { name, @@ -302,8 +346,12 @@ test("listFlows: discovers project flows recursively below the flows convention const loaded = getFlow(cwd, "nested-publish"); assert.ok(loaded, "nested flow should be discoverable by its declared name"); assert.equal(loaded.scope, "project"); - assert.equal(loaded.filePath, fs.realpathSync(filePath)); - assert.deepEqual(loaded.sourceDirIdentity, directoryIdentity(nestedDir)); + assert.equal(loaded.filePath, canonicalTestPath(filePath)); + const expectedDirIdentity = directoryIdentity(nestedDir); + assert.ok(expectedDirIdentity); + assert.ok(sameTestPath(loaded.sourceDirIdentity.canonicalPath, nestedDir)); + assert.equal(loaded.sourceDirIdentity.device, expectedDirIdentity.device); + assert.equal(loaded.sourceDirIdentity.inode, expectedDirIdentity.inode); } finally { cleanup(cwd); } @@ -319,7 +367,7 @@ test("listFlows: preserves legacy discovery of hidden top-level JSON definitions const loaded = getFlow(cwd, "hidden-legacy"); assert.ok(loaded, "0.2.9 discovered every top-level *.json except sidecars"); - assert.equal(loaded.filePath, fs.realpathSync(hiddenPath)); + assert.equal(loaded.filePath, canonicalTestPath(hiddenPath)); } finally { cleanup(cwd); } @@ -330,7 +378,7 @@ test("saveFlow: preserves legacy discovery for flow names ending in .flowir", () try { const saved = saveFlow(cwd, minimalFlow("release.flowir")); assert.equal(path.basename(saved.filePath), "release.flowir.json"); - assert.equal(getFlow(cwd, "release.flowir")?.filePath, fs.realpathSync(saved.filePath)); + assert.equal(getFlow(cwd, "release.flowir")?.filePath, canonicalTestPath(saved.filePath)); assert.ok(listFlows(cwd).some((flow) => flow.name === "release.flowir")); } finally { cleanup(cwd); @@ -352,7 +400,7 @@ test("saveFlow: updates an existing nested flow in place without creating a lega }; const saved = saveFlow(cwd, updated, "project"); - assert.equal(saved.filePath, fs.realpathSync(nestedPath)); + assert.equal(saved.filePath, canonicalTestPath(nestedPath)); assert.equal(fs.existsSync(path.join(root, "nested-publish.json")), false); assert.equal(getFlow(cwd, "nested-publish")?.def.description, "updated in place"); } finally { @@ -377,7 +425,7 @@ test("saveFlow: preserves a nested user path even when project scope has the sam fs.writeFileSync(projectPath, JSON.stringify({ ...minimalFlow("shared"), description: "project" }), "utf8"); const saved = saveFlow(cwd, { ...minimalFlow("shared"), description: "user new" }, "user"); - assert.equal(saved.filePath, fs.realpathSync(userPath)); + assert.equal(saved.filePath, canonicalTestPath(userPath)); assert.equal(fs.existsSync(path.join(userRoot, "shared.json")), false); assert.equal(JSON.parse(fs.readFileSync(userPath, "utf8")).description, "user new"); assert.equal(JSON.parse(fs.readFileSync(projectPath, "utf8")).description, "project"); @@ -412,7 +460,7 @@ test("listFlows: supports a symlinked user agent boundary without following syml const found = listFlows(cwd).find((flow) => flow.scope === "user" && flow.name === "portable-user-flow"); assert.ok(found, "the trusted agent-dir boundary may itself be a symlink"); - assert.equal(found.filePath, fs.realpathSync(path.join(userFlows, "portable.json"))); + assert.equal(found.filePath, canonicalTestPath(path.join(userFlows, "portable.json"))); } finally { if (previous === undefined) delete process.env.TASKFLOW_AGENT_DIR; else process.env.TASKFLOW_AGENT_DIR = previous; @@ -515,7 +563,7 @@ test("saveFlow: revalidates a newly created target directory inside the write lo const originalOpenSync = mutableFs.openSync; let swapped = false; mutableFs.openSync = ((...args: Parameters) => { - if (!swapped && path.resolve(String(args[0])) === path.resolve(lockPath)) { + if (!swapped && sameTestPath(String(args[0]), lockPath)) { swapped = true; fs.renameSync(targetDir, displacedDir); fs.mkdirSync(targetDir, { recursive: true }); @@ -539,6 +587,10 @@ test("saveFlow: revalidates a newly created target directory inside the write lo }); test("saveFlow: rejects a nested target directory swapped after validation but before atomic write", (t) => { + if (process.platform === "win32") { + t.skip("Windows does not permit renaming a directory while its temp file handle is open"); + return; + } const cwd = makeTmpCwd(); const outside = fs.mkdtempSync(path.join(os.tmpdir(), "taskflow-nested-save-swap-outside-")); const nestedDir = path.join(cwd, ".pi", "taskflows", "flows", "release"); @@ -555,7 +607,7 @@ test("saveFlow: rejects a nested target directory swapped after validation but b mutableFs.openSync = ((...args: Parameters) => { const target = String(args[0]); const fd = Reflect.apply(originalOpenSync, mutableFs, args) as number; - if (!swapped && target.startsWith(`${nestedPath}.`) && target.endsWith(".tmp")) { + if (!swapped && isAtomicTempPath(target, nestedPath)) { swapped = true; fs.renameSync(nestedDir, displacedDir); try { @@ -577,7 +629,7 @@ test("saveFlow: rejects a nested target directory swapped after validation but b assert.throws( () => saveFlow(cwd, { ...minimalFlow("nested-swap"), description: "must not escape" }), - /saved flow parent directory changed before write/, + /(?:saved flow parent directory changed before write|EPERM)/, ); assert.equal(swapped, true); assert.equal(fs.existsSync(path.join(outside, "publish.json")), false, "no definition may be written outside the trusted directory"); @@ -610,7 +662,7 @@ test("saveFlow: revalidates after temp open and before writing definition bytes" fs.writeFileSync(nestedPath, JSON.stringify(minimalFlow("nested-preopen-swap")), "utf8"); mutableFs.openSync = ((...args: Parameters) => { const target = String(args[0]); - if (!swapped && target.startsWith(`${nestedPath}.`) && target.endsWith(".tmp")) { + if (!swapped && isAtomicTempPath(target, nestedPath)) { swapped = true; fs.renameSync(nestedDir, displacedDir); try { @@ -659,7 +711,7 @@ test("saveFlow: rejects a nested target directory swapped at the atomic rename s mutableFs.renameSync = ((...args: Parameters) => { const source = String(args[0]); const destination = String(args[1]); - if (!swapped && source.startsWith(`${nestedPath}.`) && source.endsWith(".tmp") && destination === nestedPath) { + if (!swapped && isAtomicTempPath(source, nestedPath) && sameTestPath(destination, nestedPath)) { swapped = true; Reflect.apply(originalRenameSync, mutableFs, [nestedDir, displacedDir]); try { @@ -698,7 +750,7 @@ test("listFlows: treats an entry-read failure as a skipped unreadable directory" fs.writeFileSync(path.join(root, "stable.json"), JSON.stringify(minimalFlow("stable")), "utf8"); mutableFs.opendirSync = ((...args: Parameters) => { const handle = Reflect.apply(originalOpendirSync, mutableFs, args) as fs.Dir; - if (path.resolve(String(args[0])) === path.resolve(root)) { + if (sameTestPath(String(args[0]), root)) { handle.readSync = (() => { const error = new Error("simulated directory read failure") as NodeJS.ErrnoException; error.code = "EIO"; @@ -974,7 +1026,7 @@ test("getFlowDiagnosed: reports a corrupt nested flow by filename", async (t) => assert.equal(result.ok, false); if (!result.ok) { assert.equal(result.reason, "unparseable"); - assert.equal(result.path, fs.realpathSync(filePath)); + assert.equal(result.path, canonicalTestPath(filePath)); } } finally { cleanup(cwd); @@ -2008,7 +2060,7 @@ test("M1: concurrent saveRun for different runIds keeps every index entry", asyn try { const N = 8; const script = ` - import { saveRun } from ${JSON.stringify(path.resolve("packages/taskflow-core/src/store.ts"))}; + import { saveRun } from ${JSON.stringify(STORE_SRC_URL)}; const [cwd, runId] = [process.argv[2], process.argv[3]]; saveRun({ runId, flowName: "concurrent", def: { name: "concurrent", phases: [] }, @@ -2065,7 +2117,7 @@ test("L1: a stale lock is stolen cleanly by racing acquirers (no leak, single wi fs.utimesSync(lockPath, old, old); const script = ` - import { saveRun, loadRun } from ${JSON.stringify(path.resolve("packages/taskflow-core/src/store.ts"))}; + import { saveRun, loadRun } from ${JSON.stringify(STORE_SRC_URL)}; const cwd = process.argv[2]; saveRun({ runId: "L1", flowName: "lockflow", def: { name: "lockflow", phases: [] }, @@ -2104,7 +2156,7 @@ test("L1: an old mtime never permits stealing from a live lock owner", async () const releasePath = path.join(cwd, "release"); const script = ` import fs from "node:fs"; - import { withLock } from ${JSON.stringify(path.resolve("packages/taskflow-core/src/store.ts"))}; + import { withLock } from ${JSON.stringify(STORE_SRC_URL)}; const [lockPath, readyPath, releasePath] = process.argv.slice(2); withLock(lockPath, () => { fs.writeFileSync(readyPath, "ready"); @@ -2244,7 +2296,7 @@ test("fix-5: cleanup code filters corrupt updatedAt entries (pattern validated v // // Verify by reading the source: const src = fs.readFileSync( - path.join(path.dirname(new URL(import.meta.url).pathname), "../src/store.ts"), + path.join(path.dirname(fileURLToPath(import.meta.url)), "../src/store.ts"), "utf-8", ); // The cleanup function should filter corrupt entries before sorting. diff --git a/packages/taskflow-core/test/verify-effects.test.ts b/packages/taskflow-core/test/verify-effects.test.ts new file mode 100644 index 00000000..22e0fb4f --- /dev/null +++ b/packages/taskflow-core/test/verify-effects.test.ts @@ -0,0 +1,233 @@ +/** + * Built-in effects verification — validateEffectIR wired into verifyTaskflow. + */ + +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { verifyTaskflow, type VerifiableFlow } from "../src/verify.ts"; +import type { Phase } from "../src/schema.ts"; +import { detectEffectsIssues } from "../src/verifiers/effects-lint.ts"; + +function scriptPhase(id: string, effects: unknown[], overrides?: Partial): Phase { + return { + id, + type: "script", + run: "true", + effects: effects as Phase["effects"], + ...overrides, + }; +} + +function vf(phases: Phase[], extra?: Partial): VerifiableFlow { + return { name: "test", phases, ...extra }; +} + +const writeEffect = (id: string, literalPath: string) => ({ + id, + kind: "fs.write", + target: { + kind: "path", + path: { workspace: "project", subpath: { literalPath }, intent: "create-file" }, + }, +}); + +test("verify: effects category — mutating path overlap is an error", () => { + const flow = vf([ + scriptPhase("w", [writeEffect("a", "out/a.md"), writeEffect("b", "out/a.md")], { final: true }), + ]); + const r = verifyTaskflow(flow); + assert.equal(r.ok, false); + const issues = r.issues.filter((i) => i.category === "effects"); + assert.ok(issues.some((i) => i.severity === "error" && /overlap/i.test(i.message))); + assert.equal(issues[0]?.phaseId, "w"); + assert.equal(issues[0]?.source, "effects-lint"); +}); + +test("verify: effects category — unknown kind is an error", () => { + const flow = vf([ + scriptPhase( + "w", + [ + { + id: "x", + kind: "net.open", + target: { + kind: "path", + path: { workspace: "project", subpath: { literalPath: "x" }, intent: "create-file" }, + }, + }, + ], + { final: true }, + ), + ]); + const r = verifyTaskflow(flow); + assert.equal(r.ok, false); + assert.ok( + r.issues.some( + (i) => i.category === "effects" && i.severity === "error" && /unknown kind/i.test(i.message), + ), + ); +}); + +test("verify: effects category — valid effects leave verify ok", () => { + const flow = vf([ + scriptPhase("w", [writeEffect("w1", "out/report.md")], { + final: true, + }), + ]); + const r = verifyTaskflow(flow); + assert.equal(r.ok, true, JSON.stringify(r.issues)); + assert.equal(r.issues.filter((i) => i.category === "effects").length, 0); +}); + +test("verify: no effects[] — effects detector is a no-op", () => { + const flow = vf([{ id: "a", type: "script", run: "true", final: true }]); + const r = verifyTaskflow(flow); + assert.equal(r.ok, true); + assert.equal(r.issues.filter((i) => i.category === "effects").length, 0); +}); + +test("detectEffectsIssues: pure helper returns category effects", () => { + const flow = vf([ + scriptPhase("p", [ + { + id: "bad", + kind: "not-a-kind", + target: { kind: "path", path: { workspace: "p", intent: "create-file" } }, + }, + ]), + ]); + const issues = detectEffectsIssues(flow); + assert.ok(issues.length > 0); + assert.ok(issues.every((i) => i.category === "effects")); + assert.ok(issues.some((i) => /unknown kind/i.test(i.message))); +}); + +test("verify: independent source and sink phases do not create a false information-flow edge", () => { + const flow = vf([ + { + id: "read-secret", + task: "read", + effects: [{ + id: "secret-input", + kind: "secret.read", + target: { kind: "secret", secret: { secretId: "api-key" } }, + }], + }, + scriptPhase("public-output", [{ + ...writeEffect("write", "public.txt"), + confidentiality: "public", + }], { final: true }), + ]); + const result = verifyTaskflow(flow); + assert.equal(result.issues.some((issue) => /flow to sink/.test(issue.message)), false, JSON.stringify(result.issues)); +}); + +test("verify: dependency-connected source and sink enforce information-flow labels", () => { + const flow = vf([ + { + id: "read-secret", + task: "read", + effects: [{ + id: "secret-input", + kind: "secret.read", + target: { kind: "secret", secret: { secretId: "api-key" } }, + }], + }, + scriptPhase("public-output", [{ + ...writeEffect("write", "public.txt"), + confidentiality: "public", + }], { dependsOn: ["read-secret"], final: true }), + ]); + const result = verifyTaskflow(flow); + assert.equal(result.ok, false); + assert.ok(result.issues.some((issue) => /read-secret\/secret-input.*public-output\/write/.test(issue.message)), JSON.stringify(result.issues)); +}); + +test("verify: parent secret cannot flow into a flow.def child public sink", () => { + const flow = vf([ + { + id: "read-secret", + task: "read", + effects: [{ + id: "secret-input", + kind: "secret.read", + target: { kind: "secret", secret: { secretId: "api-key" } }, + }], + }, + { + id: "child", + type: "flow", + dependsOn: ["read-secret"], + def: { + name: "public-child", + phases: [scriptPhase("publish", [{ + ...writeEffect("write", "public.txt"), + confidentiality: "public", + }], { final: true })], + }, + final: true, + }, + ]); + const result = verifyTaskflow(flow); + assert.equal(result.ok, false); + assert.ok(result.issues.some((issue) => /read-secret\/secret-input.*child\/publish\/write/.test(issue.message)), JSON.stringify(result.issues)); +}); + +test("verify: expand child secret source cannot flow into a parent public sink", () => { + const flow = vf([ + { + id: "child", + type: "expand", + def: { + name: "secret-child", + phases: [{ + id: "read", + task: "read", + effects: [{ + id: "secret-output", + kind: "secret.read", + target: { kind: "secret", secret: { secretId: "api-key" } }, + }], + final: true, + }], + }, + }, + scriptPhase("publish", [{ + ...writeEffect("write", "public.txt"), + confidentiality: "public", + }], { dependsOn: ["child"], final: true }), + ]); + const result = verifyTaskflow(flow); + assert.equal(result.ok, false); + assert.ok(result.issues.some((issue) => /child\/read\/secret-output.*publish\/write/.test(issue.message)), JSON.stringify(result.issues)); +}); + +test("verify: unresolved dynamic definitions stay tainted in both directions", () => { + const intoDynamic = vf([ + { + id: "read-secret", + task: "read", + effects: [{ + id: "secret-input", + kind: "secret.read", + target: { kind: "secret", secret: { secretId: "api-key" } }, + }], + }, + { id: "dynamic", type: "flow", def: "{steps.plan.json}", dependsOn: ["read-secret"], final: true }, + ]); + const intoResult = verifyTaskflow(intoDynamic); + assert.equal(intoResult.ok, false); + assert.ok(intoResult.issues.some((issue) => /read-secret\/secret-input.*dynamic\//.test(issue.message)), JSON.stringify(intoResult.issues)); + + const outOfDynamic = vf([ + { id: "dynamic", type: "expand", def: "{steps.plan.json}" }, + scriptPhase("publish", [{ + ...writeEffect("write", "public.txt"), + confidentiality: "public", + }], { dependsOn: ["dynamic"], final: true }), + ]); + const outResult = verifyTaskflow(outOfDynamic); + assert.equal(outResult.ok, false); + assert.ok(outResult.issues.some((issue) => /dynamic\/.*publish\/write/.test(issue.message)), JSON.stringify(outResult.issues)); +}); diff --git a/packages/taskflow-dsl/package.json b/packages/taskflow-dsl/package.json index b3b9050f..44c04589 100644 --- a/packages/taskflow-dsl/package.json +++ b/packages/taskflow-dsl/package.json @@ -1,6 +1,6 @@ { "name": "taskflow-dsl", - "version": "0.2.10", + "version": "0.3.0-beta.1", "description": "Compile-time TypeScript DSL frontend for taskflow: erase .tf.ts runes to Taskflow JSON, then FlowIR via taskflow-core.", "keywords": [ "taskflow", diff --git a/packages/taskflow-hosts/package.json b/packages/taskflow-hosts/package.json index 7d19b257..fae4542f 100644 --- a/packages/taskflow-hosts/package.json +++ b/packages/taskflow-hosts/package.json @@ -1,6 +1,6 @@ { "name": "taskflow-hosts", - "version": "0.2.10", + "version": "0.3.0-beta.1", "description": "Shared host-runner collection for taskflow — the codex, claude, opencode, grok, and hermes SubagentRunner implementations + their argv builders and event-stream parsers. The per-host MCP servers, plugin scaffolds, and bins live in codex-taskflow / claude-taskflow / opencode-taskflow / grok-taskflow / hermes-taskflow; this package holds just the runners so a new host can be added in one place.", "homepage": "https://github.com/heggria/taskflow#readme", "author": "heggria ", diff --git a/packages/taskflow-mcp-core/package.json b/packages/taskflow-mcp-core/package.json index 300ea547..77d2edf0 100644 --- a/packages/taskflow-mcp-core/package.json +++ b/packages/taskflow-mcp-core/package.json @@ -1,6 +1,6 @@ { "name": "taskflow-mcp-core", - "version": "0.2.10", + "version": "0.3.0-beta.1", "description": "Host-neutral MCP server for taskflow: a dependency-free stdio JSON-RPC server exposing the taskflow_* tools, plus the DAG SVG/outline renderer. Shared by the codex/claude/opencode/grok/hermes adapters — depends only on taskflow-core.", "keywords": [ "taskflow", diff --git a/packages/taskflow-mcp-core/src/mcp/background.ts b/packages/taskflow-mcp-core/src/mcp/background.ts index 41a7e8c8..78d26d14 100644 --- a/packages/taskflow-mcp-core/src/mcp/background.ts +++ b/packages/taskflow-mcp-core/src/mcp/background.ts @@ -276,19 +276,51 @@ export function cancelMcpBackgroundRun(cwd: string, runId: string, reason?: stri return requestDetachedCancel(cwd, runId, reason); } +function hasActiveAuthenticatedWorkerLease(state: RunState): boolean { + if ( + state.status === "running" || + state.detachedControlVersion !== DETACHED_CONTROL_VERSION || + !state.detachedInstanceId || + typeof state.pid !== "number" + ) return false; + const registry = readDetachedProcessRegistry(state.cwd, state.runId); + if (!registry || registry.instanceId !== state.detachedInstanceId || registry.ownerPid !== state.pid) return false; + const liveness = probeProcess(state.pid); + const heartbeatFresh = Date.now() - registry.heartbeatAt <= HEARTBEAT_STALE_MS; + if (liveness !== "dead" && heartbeatFresh) return true; + if (liveness === "alive") killProcessTree(state.pid, "SIGKILL"); + if (liveness === "alive" || liveness === "dead") { + terminateDetachedProcessTrees(state.cwd, state.runId, state.detachedInstanceId); + clearDetachedProcessRegistry(state.cwd, state.runId, state.detachedInstanceId); + return false; + } + // Unknown liveness (for example EPERM) cannot safely authorize signalling + // or claim that the authenticated worker has become quiescent. + return true; +} + +export interface McpBackgroundWaitResult { + state: RunState | null; + quiescent: boolean; + reason: "quiescent" | "timeout" | "aborted" | "not-found"; +} + export async function waitForMcpBackgroundRun( cwd: string, runId: string, timeoutMs: number, signal?: AbortSignal, -): Promise { +): Promise { const deadline = Date.now() + Math.max(0, timeoutMs); let state = refreshDetachedRun(cwd, runId); - while (state?.status === "running" && Date.now() < deadline && !signal?.aborted) { + while (state && (state.status === "running" || hasActiveAuthenticatedWorkerLease(state))) { + if (signal?.aborted) return { state, quiescent: false, reason: "aborted" }; + if (Date.now() >= deadline) return { state, quiescent: false, reason: "timeout" }; await new Promise((resolve) => setTimeout(resolve, Math.min(WAIT_POLL_MS, Math.max(1, deadline - Date.now())))); state = refreshDetachedRun(cwd, runId); } - return state; + if (!state) return { state: null, quiescent: false, reason: "not-found" }; + return { state, quiescent: true, reason: "quiescent" }; } /** diff --git a/packages/taskflow-mcp-core/src/mcp/server.ts b/packages/taskflow-mcp-core/src/mcp/server.ts index 55ba176d..efdd9f94 100644 --- a/packages/taskflow-mcp-core/src/mcp/server.ts +++ b/packages/taskflow-mcp-core/src/mcp/server.ts @@ -24,7 +24,7 @@ * - taskflow_peek : inspect a stored run's intermediate phase output * - taskflow_trace : read a run's append-only event trace * - taskflow_replay : re-evaluate a recorded trace under alternate knobs (zero tokens) - * - taskflow_why_stale / taskflow_recompute / taskflow_reconcile_workspace + * - taskflow_why_stale / taskflow_why_effect / taskflow_recompute / taskflow_reconcile_workspace * - taskflow_save / taskflow_search */ @@ -101,6 +101,8 @@ import { readMapOf, declaredReadMapOfDef, formatWhyStale, + whyEffectFromDurableJournal, + formatWhyEffect, recomputeTaskflow, type RecomputeReport, preflightTaskflow, @@ -649,6 +651,23 @@ const TOOLS: McpTool[] = [ required: ["runId"], }, }, + { + name: "taskflow_why_effect", + title: "Explain why a declared effect is authorized", + description: + "Given a runId + effectId (+ optional phaseId): explain authorization and lifecycle from the durable resource intent ledger. Zero tokens and read-only. A declaration without matching principal/capability/intent evidence is unauthorized.", + inputSchema: { + type: "object", + additionalProperties: false, + properties: { + runId: { type: "string", description: "The run whose flow definition declares the effect." }, + effectId: { type: "string", description: "Effect id as declared on the phase (or phaseId/effectId composite)." }, + phaseId: { type: "string", description: "Optional phase scope when the same effect id appears on multiple phases." }, + json: { type: "boolean", description: "Return the full WhyEffect record as JSON." }, + }, + required: ["runId", "effectId"], + }, + }, { name: "taskflow_recompute", title: "Re-run a stored run's stale frontier (dry-run only)", @@ -807,6 +826,16 @@ function resolveFlowWithSource(cwd: string, params: { name?: string; define?: un throw new RpcError(RPC.INVALID_PARAMS, "Provide either `name` (a saved flow) or `define` (an inline flow)."); } +/** Store-backed saved-flow loader for `flow{use}` resolution in pre-run + * validation. Mirrors the runtime `loadFlow` lookup; returns undefined for + * names the store cannot resolve so runtime admission stays authoritative. */ +function savedFlowLoader(cwd: string): (name: string) => Taskflow | undefined { + return (name: string) => { + const r = getFlowDiagnosed(cwd, name); + return r.ok ? r.value.def : undefined; + }; +} + function resolveFlow(cwd: string, params: { name?: string; define?: unknown; defineFile?: unknown }): Taskflow { return resolveFlowWithSource(cwd, params).def; } @@ -891,13 +920,14 @@ export function makeToolHandlers( : undefined; const resolvedFlow = resolveFlowWithSource(cwd, args); const def = resolvedFlow.def; - const structural = validateTaskflow(def); + const loadSaved = savedFlowLoader(cwd); + const structural = validateTaskflow(def, { resolveFlow: loadSaved }); if (!structural.ok) return textContent(`Flow is invalid:\n- ${structural.errors.join("\n- ")}`, true); const providedArgs = args.args && typeof args.args === "object" && !Array.isArray(args.args) ? args.args as Record : {}; const resolvedArgs = resolveArgs(def, providedArgs); - const invocation = validateTaskflow(def, { args: resolvedArgs, cwd }); + const invocation = validateTaskflow(def, { args: resolvedArgs, cwd, resolveFlow: loadSaved }); if (!invocation.ok) return textContent(`Flow invocation is invalid:\n- ${invocation.errors.join("\n- ")}`, true); const usageAccounting = runner.usageAccounting; if (def.budget && usageAccounting === "unavailable") { @@ -1063,7 +1093,15 @@ export function makeToolHandlers( if (action === "status") return textContent(formatBackgroundRun(state, true)); if (action === "wait") { const timeoutMs = Math.max(0, Math.min(300_000, typeof args.timeoutMs === "number" ? Math.floor(args.timeoutMs) : 30_000)); - state = await waitForMcpBackgroundRun(cwd, runId, timeoutMs, context?.signal) ?? state; + const waited = await waitForMcpBackgroundRun(cwd, runId, timeoutMs, context?.signal); + state = waited.state ?? state; + if (!waited.quiescent) { + const reason = waited.reason === "aborted" ? "Wait was aborted" : "Wait timed out"; + const activity = state.status === "running" + ? "the run is still active" + : `the ${state.status} result is persisted but its detached worker is still finalizing`; + return textContent(`${reason}; ${activity}. Retry taskflow_runs wait.\nRun ${state.runId} · pid ${state.pid ?? "unknown"} · cwd ${state.cwd}`); + } return textContent(formatBackgroundRun(state, true), state.status !== "running" && state.status !== "completed"); } if (action === "cancel") { @@ -1115,10 +1153,12 @@ export function makeToolHandlers( } const child = forkRunForResume(prev, { overrides, cwd, host }); const settings = readSubagentSettings(); - const { agents } = discoverAgents(cwd, "both", settings.modelRoles, settings.taskflow); + const agentScope = child.def.agentScope ?? "both"; + const { agents } = discoverAgents(cwd, agentScope, settings.modelRoles, settings.taskflow); const deps: RuntimeDeps = { cwd, agents, + globalThinking: settings.globalThinking, runTask: runner.runTask, signal: context?.signal, usageAccounting: runner.usageAccounting, @@ -1201,6 +1241,26 @@ export function makeToolHandlers( return textContent(formatWhyStale(run.runId, run.flowName, reads, seeds, declared)); }, + taskflow_why_effect: async (args) => { + const runId = String(args.runId ?? ""); + const effectId = String(args.effectId ?? ""); + if (!runId) return textContent("taskflow_why_effect requires `runId`.", true); + if (!effectId) return textContent("taskflow_why_effect requires `effectId`.", true); + const runR = loadRunDiagnosed(cwd, runId); + if (!runR.ok) return textContent(describeLoadFailure(runR, `Run "${runId}"`), true); + const run = runR.value; + const result = await whyEffectFromDurableJournal({ + flow: run.def, + runId: run.runId, + effectId, + phaseId: typeof args.phaseId === "string" ? args.phaseId : undefined, + workspaceRoot: run.cwd, + }); + if (!result.ok) return textContent(result.error, true); + if (args.json === true) return textContent(JSON.stringify(result.why, null, 2)); + return textContent(formatWhyEffect(result.why)); + }, + taskflow_recompute: async (args, context) => { // MCP exposes recompute as DRY-RUN ONLY (never spends tokens). To actually // re-execute, hosts use the Pi adapter's /tf recompute --apply. diff --git a/packages/taskflow-mcp-core/test/background-runs.test.ts b/packages/taskflow-mcp-core/test/background-runs.test.ts index 55d7edc5..520119e8 100644 --- a/packages/taskflow-mcp-core/test/background-runs.test.ts +++ b/packages/taskflow-mcp-core/test/background-runs.test.ts @@ -6,8 +6,10 @@ import * as os from "node:os"; import * as path from "node:path"; import { pathToFileURL } from "node:url"; import { + clearDetachedProcessRegistry, DETACHED_CONTROL_VERSION, detachedProcessRegistryPath, + heartbeatDetachedProcessRegistry, loadRun, newRunId, probeProcess, @@ -16,6 +18,7 @@ import { type SubagentRunner, type Taskflow, } from "taskflow-core"; +import { waitForMcpBackgroundRun } from "../src/mcp/background.ts"; import { makeToolHandlers } from "taskflow-mcp-core/server"; interface TextResult { @@ -77,6 +80,103 @@ function runningBackgroundState(cwd: string, name: string): RunState { }; } +test("mcp background: wait holds a terminal result until the authenticated worker lease is released", async () => { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "tf-mcp-wait-quiescent-")); + const state = runningBackgroundState(cwd, "wait-quiescent"); + state.status = "completed"; + state.detachedControlVersion = DETACHED_CONTROL_VERSION; + state.detachedInstanceId = "wait-quiescent-instance"; + saveRun(state); + heartbeatDetachedProcessRegistry(cwd, state.runId, state.detachedInstanceId, state.pid); + + const release = setTimeout(() => { + clearDetachedProcessRegistry(cwd, state.runId, state.detachedInstanceId); + }, 150); + const startedAt = Date.now(); + try { + const waited = await waitForMcpBackgroundRun(cwd, state.runId, 1_000); + assert.equal(waited.quiescent, true); + assert.equal(waited.reason, "quiescent"); + assert.equal(waited.state?.status, "completed"); + assert.equal(fs.existsSync(detachedProcessRegistryPath(cwd, state.runId)), false); + assert.ok(Date.now() - startedAt >= 100, "wait must not return while the worker can still write cleanup state"); + } finally { + clearTimeout(release); + clearDetachedProcessRegistry(cwd, state.runId, state.detachedInstanceId); + removeTempDir(cwd); + } +}); + +test("mcp background: a timed-out wait reports finalizing instead of completed", async () => { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "tf-mcp-wait-timeout-")); + const state = runningBackgroundState(cwd, "wait-timeout"); + state.status = "completed"; + state.detachedControlVersion = DETACHED_CONTROL_VERSION; + state.detachedInstanceId = "wait-timeout-instance"; + saveRun(state); + heartbeatDetachedProcessRegistry(cwd, state.runId, state.detachedInstanceId, state.pid); + try { + const tools = makeToolHandlers(cwd, unusedForegroundRunner, { + host: "test", + detachedRunner: { module: fixtureModule(), exportName: "instantRunner" }, + }); + const waited = await tools.taskflow_runs({ action: "wait", runId: state.runId, timeoutMs: 0 }) as TextResult; + assert.equal(waited.isError, false); + assert.match(waited.content[0]!.text, /still finalizing/i); + assert.doesNotMatch(waited.content[0]!.text, /✓ completed/); + assert.equal(fs.existsSync(detachedProcessRegistryPath(cwd, state.runId)), true); + } finally { + clearDetachedProcessRegistry(cwd, state.runId, state.detachedInstanceId); + removeTempDir(cwd); + } +}); + +test("mcp background: an aborted wait does not claim terminal worker quiescence", async () => { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "tf-mcp-wait-abort-")); + const state = runningBackgroundState(cwd, "wait-abort"); + state.status = "completed"; + state.detachedControlVersion = DETACHED_CONTROL_VERSION; + state.detachedInstanceId = "wait-abort-instance"; + saveRun(state); + heartbeatDetachedProcessRegistry(cwd, state.runId, state.detachedInstanceId, state.pid); + const controller = new AbortController(); + controller.abort(); + try { + const waited = await waitForMcpBackgroundRun(cwd, state.runId, 1_000, controller.signal); + assert.equal(waited.quiescent, false); + assert.equal(waited.reason, "aborted"); + assert.equal(waited.state?.status, "completed"); + } finally { + clearDetachedProcessRegistry(cwd, state.runId, state.detachedInstanceId); + removeTempDir(cwd); + } +}); + +test("mcp background: a dead terminal worker cannot leave a permanent finalizing lease", async () => { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "tf-mcp-wait-dead-terminal-")); + const worker = spawn(process.execPath, ["-e", ""], { stdio: "ignore" }); + const pid = worker.pid!; + await new Promise((resolve) => worker.once("exit", () => resolve())); + assert.equal(probeProcess(pid), "dead"); + const state = runningBackgroundState(cwd, "wait-dead-terminal"); + state.status = "completed"; + state.pid = pid; + state.detachedControlVersion = DETACHED_CONTROL_VERSION; + state.detachedInstanceId = "wait-dead-terminal-instance"; + saveRun(state); + heartbeatDetachedProcessRegistry(cwd, state.runId, state.detachedInstanceId, pid); + try { + const waited = await waitForMcpBackgroundRun(cwd, state.runId, 0); + assert.equal(waited.quiescent, true); + assert.equal(waited.reason, "quiescent"); + assert.equal(waited.state?.status, "completed"); + assert.equal(fs.existsSync(detachedProcessRegistryPath(cwd, state.runId)), false); + } finally { + clearDetachedProcessRegistry(cwd, state.runId, state.detachedInstanceId); + removeTempDir(cwd); + } +}); + test("mcp background: run returns immediately and wait returns durable final output", async () => { const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "tf-mcp-background-")); const restoreAgentDir = usePrivateAgentDir(cwd); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7a2d6c25..7b1418da 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -33,6 +33,9 @@ importers: charterarc: specifier: workspace:* version: link:packages/charterarc + taskflow-control: + specifier: workspace:* + version: link:packages/taskflow-control taskflow-core: specifier: workspace:* version: link:packages/taskflow-core @@ -137,6 +140,15 @@ importers: specifier: '*' version: 1.3.3 + packages/taskflow-control: + dependencies: + taskflow-core: + specifier: workspace:* + version: link:../taskflow-core + typebox: + specifier: '*' + version: 1.3.10 + packages/taskflow-core: dependencies: typebox: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 7e4c2ec4..055ad5b9 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -4,6 +4,7 @@ packages: - "packages/taskflow-core" - "packages/charterarc" + - "packages/taskflow-control" - "packages/taskflow-mcp-core" - "packages/taskflow-hosts" - "packages/pi-taskflow" diff --git a/skills-src/taskflow/core.md b/skills-src/taskflow/core.md index 880ebb61..5b5ef582 100644 --- a/skills-src/taskflow/core.md +++ b/skills-src/taskflow/core.md @@ -210,6 +210,7 @@ back cleanly: precedence is `define` (inline) > `defineFile` (disk) > `name` | `timeout` | max ms per subagent call (>= 1000). On expiry the subagent is aborted and the phase fails with a `timedOut` marker — deterministic, **never retried**. Caps EACH call, so a map/parallel/race/loop/tournament phase's wall time is per item/iteration/variant (a tournament's judge call gets its own cap too). Script phases keep their own child-process timeout (default 60s, max 300s). Not supported on approval/flow/expand. Pair with `optional: true` + a downstream fallback phase to degrade instead of failing the run. | | `expect` | output contract for `output: "json"` phases (agent/gate/reduce/loop): a JSON-Schema-like shape `{type, properties, required, items, enum}` validated the moment the subagent finishes. A violation fails the phase with per-path diagnostics (e.g. `$.score: required key is missing`) and is retryable under the phase's explicit `retry`. `verify`/`compile` also statically warn when a `{steps.X.json.field}` ref names a field absent from X's declared contract. | | `idempotent` | side-effect classification. Default `true` (safe to cache + auto-retry). Set `false` on phases with **irreversible side effects** (webhook POSTs, deploys, DB writes, file mutations): transient provider errors are **not** auto-retried (an explicit `retry{}` IS still honored — it's your declaration that repeats are acceptable) and the result is **never cached** in any scope (within-run resume, cross-run, `incremental` — the phase re-runs every time). The phase state records `sideEffect: true` (rendered as ⚡). | +| `effects` | **[0.3 Trusted Effects]** declared side-effect bag for this phase — typed `fs.read` / `fs.write` / `fs.delete` / `secret.read` / `service.call` declarations with PathRef/SecretRef/ServiceRef targets and optional confidentiality/integrity labels. See **Trusted Effects** below. | | `optional` | fail-soft — a failed/blocked phase won't abort the run; downstream sees empty output. Pair with a fallback phase guarded by `when`. | | `cache` | per-phase reuse policy (`run-only` default / `cross-run` / `off`). See `configuration.md` §8. | @@ -505,6 +506,84 @@ output is exact. "input": "{steps.analyze.output}", "dependsOn": ["analyze"], "final": true } ``` +### Trusted Effects (`effects[]` — declared side effects, 0.3) + +> **One-line authority: the model proposes content; the resources runtime is +> the only commit authority.** A phase declares *what* it intends to touch; +> for admitted declared `fs.write` targets the runtime runs the +> resource-controlled **file transaction** — durable snapshot → persistent +> lease → journal intent/permit → stage → **Commit** or **Restore+Reject** — +> and no other code finalizes declared content. + +Trusted Effects (0.3 MVP) adds an optional `effects[]` bag to **any** phase: a +closed vocabulary of typed side-effect declarations. `verify` / `compile` +statically check the bag (unknown kinds, malformed targets, and illegal +label flows surface as `[effects]` issues), and a run admits every declared +target through PathRef resolution — lease, durable intent, mutation permit — +**before** the phase body executes. Start from the runnable example +**`examples/trusted-effects-write.json`** (a `script` phase that declares one +`fs.write` and commits it via the resource transaction — no LLM involved). + +Each effect: + +| field | meaning | +|-------|---------| +| `id` | stable id within the flow — the handle the why-* audit explains | +| `kind` | `fs.read` · `fs.write` · `fs.delete` · `secret.read` · `service.call` | +| `target` | `{ kind: "path", path: }`, or the `secret` / `service` handle shapes | +| `confidentiality` | optional label `public` · `internal` · `secret` — a higher label must not flow to a lower sink | +| `integrity` | optional label `untrusted` · `project` · `verified` — lower integrity must not overwrite higher | +| `purpose` | free-text note surfaced by the why-* explainers (**not** authority) | + +**PathRef shape** — the FS target, always relative to a workspace scope: + +```jsonc +"target": { + "kind": "path", + "path": { + "workspace": "project", // scope the path resolves in + "subpath": { "literalPath": "out/report.md" }, // or { "argPath": "out" } / { "segments": [ { "segment": "out" } ] } + "intent": "create-file" // create-file | create-directory | existing-file | existing-directory | executable + } +} +``` + +**Phase output is the payload.** With one declared `fs.write`, the phase's +output becomes the staged file content (see the example: `process.stdout.write` += the report). With several `fs.write` effects, the phase must emit JSON +mapping each effect id to its content (`{ "report": "…", "backup": "…" }`). +Commit promotes each file atomically; a later failure restores every admitted +file to its durable pre-state, and a direct write by the agent/script to a +**declared final path** is detected and restored — only the resource +transaction may finalize declared content. + +**Only `fs.write` has a bound runtime backend in this cut.** The other kinds +are valid to declare and verify, but fail **closed** (no bound resource +backend): `fs.delete` is not supported by the file transaction, and +`secret.read` / `service.call` have no vault/network adapters in 0.3 — do not +author a flow expecting them to do anything yet. + + +**Audit with `taskflow_why_effect` (zero tokens, read-only).** Pass `runId` + +`effectId` (add `phaseId` to disambiguate a repeated id; `json: true` for the +full record) to explain a declared effect's authorization and lifecycle from +the durable resource-intent ledger — principal, capability binding, intent id, +journal status, and lifecycle (`declared` / `staged` / `committed` / +`rejected` / `unknown`). **Declaration alone is not authorization**: if no +durable intent admitted the effect for this run/phase, `authorized.allowed` is +`false` (fail-closed). + + +**What this is NOT (honesty baseline):** + +- **No FileBroker sandbox.** Every host's PathRef support is *resolve-only*; + this is not an OS sandbox, and no host claims a FileBroker guarantee. +- **Undeclared paths are not protected.** Only writes to *declared* final + targets are detected and restored; writes outside the declared set remain + host-policy dependent. +- **`secret.read` / `service.call` are type-only fail-closed** (see above) — + valid declarations, no backend in the MVP. + ### Race phases (first success wins) A `race` phase runs static `branches[]` concurrently and **returns the first diff --git a/skills-src/taskflow/entry.claude.md b/skills-src/taskflow/entry.claude.md index a671f3b1..18089559 100644 --- a/skills-src/taskflow/entry.claude.md +++ b/skills-src/taskflow/entry.claude.md @@ -26,6 +26,7 @@ runs as an isolated `claude -p` session. | `taskflow_trace` | Read a run's append-only event timeline. | | `taskflow_replay` | Replay recorded decisions offline with optional overrides — zero model calls. | | `taskflow_why_stale` | Explain why phases are stale from observed and declared dependencies — zero tokens. | +| `taskflow_why_effect` | Explain why a declared effect is authorized, from the durable resource-intent ledger (`runId` + `effectId`, optional `phaseId`; `json: true` for the full record). Declaration alone is not authorization — zero tokens, read-only. | | `taskflow_recompute` | Compute the stale frontier (**dry-run only** over MCP; never executes phases). | | `taskflow_reconcile_workspace` | After inspection/repair, accept a failed resolve-only workspace. Requires host `TASKFLOW_WORKSPACE_RECONCILE_MODE=explicit`; never restores files. | | `taskflow_save` | Save a reusable flow and optional library metadata. | diff --git a/skills-src/taskflow/entry.codex.md b/skills-src/taskflow/entry.codex.md index e6acee94..75fcf62c 100644 --- a/skills-src/taskflow/entry.codex.md +++ b/skills-src/taskflow/entry.codex.md @@ -25,6 +25,7 @@ the Codex form (`taskflow_verify`). | `taskflow_trace` | Read a run's append-only event timeline. | | `taskflow_replay` | Replay recorded decisions offline with optional overrides — zero model calls. | | `taskflow_why_stale` | Explain why phases are stale from observed and declared dependencies — zero tokens. | +| `taskflow_why_effect` | Explain why a declared effect is authorized, from the durable resource-intent ledger (`runId` + `effectId`, optional `phaseId`; `json: true` for the full record). Declaration alone is not authorization — zero tokens, read-only. | | `taskflow_recompute` | Compute the stale frontier (**dry-run only** over MCP; never executes phases). | | `taskflow_reconcile_workspace` | After inspection/repair, accept a failed resolve-only workspace. Requires host `TASKFLOW_WORKSPACE_RECONCILE_MODE=explicit`; never restores files. | | `taskflow_save` | Save a reusable flow and optional library metadata. | diff --git a/skills-src/taskflow/entry.grok.md b/skills-src/taskflow/entry.grok.md index 0a08490f..608bfe4d 100644 --- a/skills-src/taskflow/entry.grok.md +++ b/skills-src/taskflow/entry.grok.md @@ -35,6 +35,7 @@ kernel enforcement is unavailable. | `taskflow_trace` | Read a run's append-only event timeline. | | `taskflow_replay` | Replay recorded decisions offline with optional overrides — zero model calls. | | `taskflow_why_stale` | Explain why phases are stale from observed and declared dependencies — zero tokens. | +| `taskflow_why_effect` | Explain why a declared effect is authorized, from the durable resource-intent ledger (`runId` + `effectId`, optional `phaseId`; `json: true` for the full record). Declaration alone is not authorization — zero tokens, read-only. | | `taskflow_recompute` | Compute the stale frontier (**dry-run only** over MCP; never executes phases). | | `taskflow_reconcile_workspace` | After inspection/repair, accept a failed resolve-only workspace. Requires host `TASKFLOW_WORKSPACE_RECONCILE_MODE=explicit`; never restores files. | | `taskflow_save` | Save a reusable flow and optional library metadata. | diff --git a/skills-src/taskflow/entry.hermes.md b/skills-src/taskflow/entry.hermes.md index c7b8afe7..e2d07f92 100644 --- a/skills-src/taskflow/entry.hermes.md +++ b/skills-src/taskflow/entry.hermes.md @@ -28,6 +28,7 @@ session. | `taskflow_trace` | Read a run's append-only event timeline. | | `taskflow_replay` | Replay recorded decisions offline with optional overrides — zero model calls. | | `taskflow_why_stale` | Explain why phases are stale from observed and declared dependencies — zero tokens. | +| `taskflow_why_effect` | Explain why a declared effect is authorized, from the durable resource-intent ledger (`runId` + `effectId`, optional `phaseId`; `json: true` for the full record). Declaration alone is not authorization — zero tokens, read-only. | | `taskflow_recompute` | Compute the stale frontier (**dry-run only** over MCP; never executes phases). | | `taskflow_reconcile_workspace` | After inspection/repair, accept a failed resolve-only workspace. Requires host `TASKFLOW_WORKSPACE_RECONCILE_MODE=explicit`; never restores files. | | `taskflow_save` | Save a reusable flow and optional library metadata. | diff --git a/skills-src/taskflow/entry.opencode.md b/skills-src/taskflow/entry.opencode.md index 3a719a8a..5141bea1 100644 --- a/skills-src/taskflow/entry.opencode.md +++ b/skills-src/taskflow/entry.opencode.md @@ -26,6 +26,7 @@ the OpenCode form (`taskflow_verify`). Each phase's subagent runs as an isolated | `taskflow_trace` | Read a run's append-only event timeline. | | `taskflow_replay` | Replay recorded decisions offline with optional overrides — zero model calls. | | `taskflow_why_stale` | Explain why phases are stale from observed and declared dependencies — zero tokens. | +| `taskflow_why_effect` | Explain why a declared effect is authorized, from the durable resource-intent ledger (`runId` + `effectId`, optional `phaseId`; `json: true` for the full record). Declaration alone is not authorization — zero tokens, read-only. | | `taskflow_recompute` | Compute the stale frontier (**dry-run only** over MCP; never executes phases). | | `taskflow_reconcile_workspace` | After inspection/repair, accept a failed resolve-only workspace. Requires host `TASKFLOW_WORKSPACE_RECONCILE_MODE=explicit`; never restores files. | | `taskflow_save` | Save a reusable flow and optional library metadata. | diff --git a/website/app/[lang]/layout.tsx b/website/app/[lang]/layout.tsx index 74013124..fd8614d3 100644 --- a/website/app/[lang]/layout.tsx +++ b/website/app/[lang]/layout.tsx @@ -10,16 +10,16 @@ export function generateStaticParams() { const site = { en: { - title: "taskflow — Verify before spend", + title: "taskflow 0.3.0-beta.1 — Trusted Effects beta", brand: "taskflow", description: - "Verify before spend. Resume across sessions. Recompute only what changed. taskflow is the compiled runtime for coding-agent orchestration.", + "Declare coding-agent effects, verify typed paths, and commit admitted filesystem changes through one resource authority. 0.3.0-beta.1; beta channel and not GA.", }, "zh-cn": { - title: "taskflow — 花 token 前先验证", + title: "taskflow 0.3.0-beta.1 — Trusted Effects beta", brand: "taskflow", description: - "花 token 前先验证,跨会话续跑,只重算变化部分。taskflow 是面向 coding-agent 编排的 compiled runtime。", + "声明 coding-agent effect,验证类型化路径,让已准入的文件修改经过唯一 resource authority。0.3.0-beta.1,beta channel,尚未 GA。", }, } as const; diff --git a/website/app/[lang]/page.tsx b/website/app/[lang]/page.tsx index 7e6222f1..f05ad37b 100644 --- a/website/app/[lang]/page.tsx +++ b/website/app/[lang]/page.tsx @@ -25,155 +25,155 @@ const copy = { localeZh: "中文", }, hero: { - eyebrow: "taskflow 0.2.10", + eyebrow: "taskflow 0.3.0-beta.1 · Trusted Effects beta", title: [ - "Plan before spend.", - "Close the loop after.", - "Recompute only what changed.", + "Declare the effect.", + "Verify the path.", + "Commit through one authority.", ], - sub: "taskflow turns multi-agent coding work into a compiled runtime: declared graphs, zero-token preflight, isolated execution, hooks when runs finish, deterministic replay, and incremental recompute across Pi, Codex, Claude Code, OpenCode, Grok, and Hermes.", - noteKicker: "Compiled runtime for coding agents", + sub: "taskflow turns coding-agent work into a verifiable runtime: explicit graphs, typed effect declarations, isolated execution, resource-controlled filesystem commits, and ledger-backed explanations across six hosts.", + noteKicker: "0.3.0-beta.1 · beta channel · not GA", noteBody: - "0.2.7 makes the day-to-day loop complete: taskflow_plan before any model call, hooks and analytics after, savings numbers on recompute — without flooding the host with transcripts.", + "An agent can propose content. For admitted declared targets, the resources transaction is the only finalizer. The ControlHost scaffold exists; stores, approvals, receipts, and WebUI remain future follow-on stages, not shipped GA claims.", micro: - "Intermediates stay in the runtime. Only the result returns to the host.", + "Resolve-only is not an OS sandbox. Undeclared writes remain host-policy dependent.", hosts: "Pi · Codex · Claude Code · OpenCode · Grok · Hermes", primary: "Read the docs", secondary: "Install", tertiary: "GitHub", }, bench: { - eyebrow: "Compiler Bench", - title: "One surface. Four invariants.", - sub: "Declared graph, verified execution, isolated return, minimal recompute.", - aria: "Compiler Bench showing a declared task graph, host return, verification checks, resumable execution, and incremental recompute.", - modeVerify: "verify", - modeRun: "run", - modeRecompute: "recompute", - graphLabel: "Declared graph", - hostLabel: "Host return", - hostTitle: "Prioritized risk summary", + eyebrow: "Trusted Effects Bench", + title: "One contract. Four checkpoints.", + sub: "Declare, admit, transact, explain.", + aria: "Trusted Effects bench showing an effect declaration, path admission, resource transaction, and ledger explanation.", + modeVerify: "admit", + modeRun: "commit", + modeRecompute: "explain", + graphLabel: "Trusted Effects contract", + hostLabel: "Evidence return", + hostTitle: "Declared report write", hostBody: - "Auth boundary verified. 7 phases reused from cache. Re-ran only the changed frontier before returning the final answer.", - verifyLabel: "Verify", - resumeLabel: "Resume", - recomputeLabel: "Recompute", + "PathRef admitted. Resource intent committed. why-effect can explain the principal, capability, lifecycle, and generation.", + verifyLabel: "Admit", + resumeLabel: "Commit", + recomputeLabel: "Explain", verifyRows: [ - { key: "cycles", value: "0" }, - { key: "dead ends", value: "0" }, - { key: "refs", value: "resolved" }, - { key: "budget", value: "pass" }, + { key: "effect", value: "fs.write" }, + { key: "PathRef", value: "resolved" }, + { key: "labels", value: "pass" }, + { key: "overlap", value: "checked" }, ], resumeRows: [ - { key: "run state", value: "detached + resumable" }, - { key: "trace", value: "stored" }, - { key: "cache hits", value: "7 phases" }, - { key: "host output", value: "final only" }, + { key: "snapshot", value: "durable" }, + { key: "intent", value: "journaled" }, + { key: "lifecycle", value: "commit | restore" }, + { key: "authority", value: "resources" }, ], recomputeRows: [ - { key: "changed inputs", value: "1 file" }, - { key: "stale frontier", value: "2 nodes" }, - { key: "reused", value: "discover + 6 reviews" }, - { key: "new spend", value: "minimal" }, + { key: "why-effect", value: "read-only" }, + { key: "principal", value: "derived" }, + { key: "capability", value: "bound" }, + { key: "status", value: "committed" }, ], }, install: { - label: "Install on the host you already use.", + label: "0.3.0-beta.1 host installs — select the beta channel.", copy: "Copy", copied: "Copied", guide: "Guide", }, capabilities: { - title: "A runtime, not a prompt ritual.", - sub: "The page should prove contract, not list features.", + title: "A mutation boundary, not a prompt promise.", + sub: "The candidate makes side effects explicit without claiming a sandbox it does not have.", items: [ { - title: "Plan", - body: "taskflow_plan binds args, projects phase order, and reports a worst-case agent-call bound — zero tokens, before any spawn.", + title: "Declare", + body: "EffectIR names the kind, target, purpose, confidentiality, and integrity of a phase effect.", }, { - title: "Verify", - body: "Static checks happen before any model call: cycles, dead ends, dangling refs, impossible budgets.", + title: "Admit", + body: "PathRef resolution, label flow, and mutating-path overlap are checked before the resource transaction proceeds.", }, { - title: "Close the loop", - body: "Hooks notify when runs complete or fail; approval timeouts stop infinite HITL; analytics summarize the last N runs.", + title: "Explain", + body: "A durable ledger backs why-authorized, why-context, and why-effect explanations without spending model tokens.", }, ], }, ledger: { - title: "0.2 is the compiler turn. 0.2.7 closes the loop.", - sub: "The graph is compiled, planned before spend, resumed, replayed, hooked on completion, and incrementally recomputed.", + title: "0.3 is the trusted-effects turn.", + sub: "The 0.2 runtime remains the graph engine; the candidate adds a typed, inspectable boundary around declared filesystem effects.", items: [ { - tag: "0.2.7", - title: "Plan before spend", - body: "taskflow_plan + budget bound + savings line on recompute — see the contract before you pay.", + tag: "EffectIR", + title: "Effects become data", + body: "Closed effect kinds, typed refs, and labels travel through validation, FlowIR, hashing, and runtime admission.", }, { - tag: "0.2.7", - title: "Close the loop after", - body: "Flow hooks, approval timeout/onExpire, and read-only analytics — without transcript leakage.", + tag: "Resources", + title: "One mutation authority", + body: "Snapshot, lease, intent, stage, commit — or restore and reject when the transaction cannot complete.", }, { - tag: "S4", - title: "TypeScript DSL compiles to FlowIR", - body: "Author in .tf.ts, then erase into a canonical intermediate form.", + tag: "why-*", + title: "Evidence is queryable", + body: "why-effect explains authorization and lifecycle from durable resource records, not from model prose.", }, { - tag: "Core", - title: "Verify + trace + replay + detached runs", - body: "The runtime can check structure before spend and persist the whole operating envelope.", + tag: "Boundary", + title: "Security claims stay narrow", + body: "Declared targets are protected by the MVP path; undeclared writes and OS-level sandboxing remain outside the claim.", }, { - tag: "Cache", - title: "Cross-run content addressing", - body: "Unchanged work is reused instead of repurchased — and reported as reused N / rerun M.", + tag: "0.3-C", + title: "Control Plane follows", + body: "The ControlHost scaffold exists; stores, receipts, approvals, and WebUI are future 0.3-C stages, not shipped 0.3 GA surface yet.", }, { tag: "Hosts", - title: "Six host adapters · 19 MCP tools", - body: "Pi, Codex, Claude Code, OpenCode, Grok, and Hermes share one engine.", + title: "Six hosts · one flow contract", + body: "Pi, Codex, Claude Code, OpenCode, Grok, and Hermes share the taskflow runtime while retaining host-specific policy.", }, ], }, authoring: { - title: "Same runtime. Three surfaces.", - sub: "JSON for transport. TypeScript for authoring. FlowIR for the compiled contract.", + title: "Same runtime. Three contract surfaces.", + sub: "JSON for transport. TypeScript for authoring. FlowIR and EffectIR for the compiled contract.", json: "JSON", ts: "TypeScript", flowir: "FlowIR", noteTitle: "What stays invariant", notes: [ "The graph is explicit and versionable.", - "Plan and verification happen before spend.", - "Phase identity can be fingerprinted and cached.", - "The host still receives only finalOutput.", + "Effects are declared, not inferred from prose.", + "The resources layer is the only finalizer for admitted declared targets.", + "The host still receives only finalOutput unless evidence is requested.", ], }, difference: { - title: "What changes when the graph is data.", - sub: "Not a category lecture — an operating difference.", + title: "What changes when effects are data.", + sub: "Not a sandbox claim — an operating boundary.", rows: [ { - label: "plan", - a: "zero-token preflight + versioned", - b: "re-derived in prose", + label: "authority", + a: "one resource finalizer", + b: "ambient command writes", }, { - label: "spend", - a: "planned / verified first", - b: "discovered during execution", + label: "target", + a: "typed PathRef + admission", + b: "implicit path string", }, - { label: "failure", a: "resumed + hooks notify", b: "restarted" }, - { label: "change", a: "minimally recomputed + savings", b: "broadly rerun" }, + { label: "failure", a: "restore + reject", b: "partial mutation" }, + { label: "evidence", a: "ledger-backed why-effect", b: "model explanation" }, ], left: "taskflow", right: "ad-hoc", }, cta: { - title: "Build the graph once. Rerun it precisely.", - body: "Plan before spend. Resume across sessions. Close the loop after. Return only the result.", + title: "Make the side effect explicit.", + body: "Declare the target, verify the boundary, commit through one authority, and keep the claim honest.", primary: "Read the docs", secondary: "Install", }, @@ -187,142 +187,142 @@ const copy = { localeZh: "中文", }, hero: { - eyebrow: "taskflow 0.2.10", - title: ["花 token 前先计划。", "跑完闭环通知。", "只重算变化部分。"], - sub: "taskflow 把多代理编程工作变成可编译的运行时:声明式图、零 token preflight、隔离执行、跑完 hooks、确定性 replay,以及跨 Pi、Codex、Claude Code、OpenCode、Grok、Hermes 的增量重算。", - noteKicker: "面向 coding agents 的 compiled runtime", + eyebrow: "taskflow 0.3.0-beta.1 · Trusted Effects beta", + title: ["声明 effect。", "验证路径。", "让一个 authority 负责提交。"], + sub: "taskflow 把 coding-agent 工作变成可验证的运行时:显式任务图、类型化 effect 声明、隔离执行、受 resources 控制的文件提交,以及覆盖六个宿主的 ledger-backed 解释。", + noteKicker: "0.3.0-beta.1 · beta channel · 尚未 GA", noteBody: - "0.2.7 补上日常闭环:taskflow_plan 在任何模型调用前;hooks 与 analytics 在跑完之后;recompute 带上省钱数字——且从不把 transcript 灌进宿主。", - micro: "中间过程留在运行时里。回到宿主的,只有结果。", + "智能体可以提出内容。对于已准入的已声明目标,resources transaction 是唯一最终提交者。ControlHost 目前是脚手架;store、审批、receipt 与 WebUI 仍是后续阶段,不是已交付的 GA 表面。", + micro: "Resolve-only 不是 OS sandbox。未声明写入仍取决于宿主策略。", hosts: "Pi · Codex · Claude Code · OpenCode · Grok · Hermes", primary: "阅读文档", secondary: "安装", tertiary: "GitHub", }, bench: { - eyebrow: "Compiler Bench", - title: "一个台面,四个不变量。", - sub: "声明式图、可验证执行、隔离回传、最小重算。", - aria: "Compiler Bench:展示声明式任务图、宿主回传、验证检查、可续跑执行与增量重算。", - modeVerify: "verify", - modeRun: "run", - modeRecompute: "recompute", - graphLabel: "声明式图", - hostLabel: "宿主回传", - hostTitle: "优先级风险摘要", + eyebrow: "Trusted Effects Bench", + title: "一份合同,四个检查点。", + sub: "声明、准入、事务、解释。", + aria: "Trusted Effects Bench:展示 effect 声明、路径准入、resource transaction 与 ledger 解释。", + modeVerify: "准入", + modeRun: "提交", + modeRecompute: "解释", + graphLabel: "Trusted Effects 合同", + hostLabel: "Evidence 回传", + hostTitle: "已声明的报告写入", hostBody: - "Auth 边界已验证。7 个阶段命中缓存。只重跑变化前沿后,把最终答案带回宿主。", - verifyLabel: "验证", - resumeLabel: "续跑", - recomputeLabel: "重算", + "PathRef 已准入。Resource intent 已提交。why-effect 可以解释 principal、capability、lifecycle 与 generation。", + verifyLabel: "准入", + resumeLabel: "提交", + recomputeLabel: "解释", verifyRows: [ - { key: "环路", value: "0" }, - { key: "死路", value: "0" }, - { key: "引用", value: "已解析" }, - { key: "预算", value: "通过" }, + { key: "effect", value: "fs.write" }, + { key: "PathRef", value: "已解析" }, + { key: "labels", value: "通过" }, + { key: "重叠", value: "已检查" }, ], resumeRows: [ - { key: "运行态", value: "detached + resumable" }, - { key: "trace", value: "已持久化" }, - { key: "缓存命中", value: "7 个阶段" }, - { key: "宿主输出", value: "仅 final" }, + { key: "snapshot", value: "durable" }, + { key: "intent", value: "已记账" }, + { key: "lifecycle", value: "commit | restore" }, + { key: "authority", value: "resources" }, ], recomputeRows: [ - { key: "变化输入", value: "1 个文件" }, - { key: "陈旧前沿", value: "2 个节点" }, - { key: "复用", value: "discover + 6 个 review" }, - { key: "新增花费", value: "最小" }, + { key: "why-effect", value: "只读" }, + { key: "principal", value: "已推导" }, + { key: "capability", value: "已绑定" }, + { key: "status", value: "committed" }, ], }, install: { - label: "装到你已经在用的宿主上。", + label: "0.3.0-beta.1 宿主安装;请显式选择 beta channel。", copy: "复制", copied: "已复制", guide: "指南", }, capabilities: { - title: "这是一套运行时,不是一次 prompt 仪式。", - sub: "这里要证明合同,而不是罗列功能。", + title: "这是修改边界,不是 prompt 承诺。", + sub: "candidate 把副作用显式化,但不声称不存在的 sandbox。", items: [ { - title: "计划", - body: "taskflow_plan 绑定参数、投影 phase 序、给出 worst-case agent 调用上界——零 token,任何 spawn 之前。", + title: "声明", + body: "EffectIR 写出阶段 effect 的 kind、target、purpose、confidentiality 与 integrity。", }, { - title: "验证", - body: "在任何模型调用前完成静态检查:环路、死路、悬空引用、不可能的预算。", + title: "准入", + body: "Resource transaction 继续前,先检查 PathRef 解析、标签流与 mutating-path 重叠。", }, { - title: "闭环", - body: "hooks 在完成/失败时通知;approval 超时不再永挂;analytics 汇总最近 N 次运行。", + title: "解释", + body: "Durable ledger 支撑 why-authorized、why-context 与 why-effect,不消耗模型 token。", }, ], }, ledger: { - title: "0.2 是编译器转身。0.2.7 补上闭环。", - sub: "图会被编译,会在花费前被 plan,会续跑、replay、跑完通知,也会增量重算。", + title: "0.3 是 Trusted Effects 转身。", + sub: "0.2 运行时仍是图引擎;candidate 在声明的文件 effect 周围增加类型化、可检查的边界。", items: [ { - tag: "0.2.7", - title: "花 token 前先计划", - body: "taskflow_plan + 预算上界 + recompute 省钱一行——付钱前先看见合同。", + tag: "EffectIR", + title: "让 effect 成为数据", + body: "封闭 effect kind、类型化 ref 与 labels 贯穿校验、FlowIR、哈希与运行时准入。", }, { - tag: "0.2.7", - title: "跑完闭环", - body: "flow hooks、approval 超时/onExpire、只读 analytics——不泄露 transcript。", + tag: "Resources", + title: "只有一个修改权威", + body: "Snapshot、lease、intent、stage、commit;事务不能完成时就 restore and reject。", }, { - tag: "S4", - title: "TypeScript DSL 编译到 FlowIR", - body: "在 .tf.ts 中编写,再擦除成规范化中间表示。", + tag: "why-*", + title: "Evidence 可以查询", + body: "why-effect 从 durable resource records 解释授权与生命周期,而不是复述模型 prose。", }, { - tag: "Core", - title: "Verify + trace + replay + detached runs", - body: "运行时能在花费前检查结构,并持久化完整的运行包络。", + tag: "Boundary", + title: "安全声明保持窄", + body: "MVP 路径保护已声明目标;未声明写入与 OS-level sandbox 不在声明范围内。", }, { - tag: "Cache", - title: "跨 run 内容寻址复用", - body: "未变化的工作被复用——并汇报为 reused N / rerun M。", + tag: "0.3-C", + title: "Control Plane 在后面", + body: "ControlHost 目前是脚手架;store、receipt、审批与 WebUI 属于后续 0.3-C 阶段,还不是已发布的 0.3 GA 表面。", }, { tag: "Hosts", - title: "六个宿主 · 19 个 MCP 工具", - body: "Pi、Codex、Claude Code、OpenCode、Grok、Hermes 共用同一套引擎。", + title: "六个宿主 · 一份 flow 合同", + body: "Pi、Codex、Claude Code、OpenCode、Grok、Hermes 共用 taskflow runtime,同时保留宿主策略差异。", }, ], }, authoring: { - title: "同一运行时,三种表面。", - sub: "JSON 用于传输。TypeScript 用于编写。FlowIR 用于编译合同。", + title: "同一运行时,三种合同表面。", + sub: "JSON 用于传输。TypeScript 用于编写。FlowIR 与 EffectIR 用于编译合同。", json: "JSON", ts: "TypeScript", flowir: "FlowIR", noteTitle: "不变的东西", notes: [ "图是显式的、可版本化的。", - "计划与验证先于花费发生。", - "阶段身份可以被指纹化和缓存。", - "回到宿主的仍只有 finalOutput。", + "Effect 是声明出来的,不是从 prose 猜出来的。", + "Resources 层是已声明准入目标的唯一最终提交者。", + "除非请求 evidence,回到宿主的仍只有 finalOutput。", ], }, difference: { - title: "当图成为数据,事情会怎么变。", - sub: "不是品类讲解,而是运行差异。", + title: "当 effect 成为数据,事情会怎么变。", + sub: "不是 sandbox 宣言,而是运行边界。", rows: [ - { label: "plan", a: "0 token 预演 + 声明版本化", b: "每次重推为 prose" }, - { label: "spend", a: "先 plan / verify", b: "运行中才发现" }, - { label: "failure", a: "可续跑 + hooks 通知", b: "从头再来" }, - { label: "change", a: "最小重算 + 省钱数字", b: "大范围重跑" }, + { label: "authority", a: "一个 resource finalizer", b: "ambient command writes" }, + { label: "target", a: "typed PathRef + admission", b: "隐式 path string" }, + { label: "failure", a: "restore + reject", b: "partial mutation" }, + { label: "evidence", a: "ledger-backed why-effect", b: "model explanation" }, ], left: "taskflow", right: "ad-hoc", }, cta: { - title: "图只搭一次,之后精确重跑。", - body: "先 plan,再验证,能续跑,跑完能喊,只把结果带回宿主。", + title: "把副作用写进合同。", + body: "声明目标,验证边界,让一个 authority 负责提交,并诚实地写出安全边界。", primary: "阅读文档", secondary: "安装", }, @@ -341,7 +341,7 @@ export default async function HomePage({ "@context": "https://schema.org", "@type": "SoftwareApplication", name: "taskflow", - softwareVersion: "0.2.10", + softwareVersion: "0.3.0-beta.1", description: t.hero.sub, applicationCategory: "DeveloperApplication", operatingSystem: "Any", @@ -497,7 +497,7 @@ export default async function HomePage({
-

taskflow 0.2

+

taskflow 0.3 · candidate

{t.cta.title}

{t.cta.body}

diff --git a/website/app/page.tsx b/website/app/page.tsx index 9e7daf36..a9031001 100644 --- a/website/app/page.tsx +++ b/website/app/page.tsx @@ -1,8 +1,8 @@ import type { Metadata } from "next"; -const title = "taskflow — Declarative DAG Orchestration for Coding Agents"; +const title = "taskflow 0.3 — Trusted Effects for Coding Agents"; const description = - "A declarative, verifiable graph of task nodes for coding-agent subagents. Fan out, gate, loop, resume, and save as a command."; + "Declare coding-agent effects, verify typed paths, and commit admitted filesystem changes through one resource authority. 0.3.0-beta.1; beta channel and not GA."; const canonical = "https://heggria.github.io/taskflow/en/"; export const metadata: Metadata = { diff --git a/website/components/home/compiler-bench.tsx b/website/components/home/compiler-bench.tsx index 4402d97c..5ae80fed 100644 --- a/website/components/home/compiler-bench.tsx +++ b/website/components/home/compiler-bench.tsx @@ -3,19 +3,19 @@ import { useMemo, useState } from "react"; const GRAPH_NODES = [ - { id: "input", title: "Input", meta: "args / files", x: 12, y: 50 }, - { id: "compile", title: "Compile", meta: "FlowIR build", x: 37, y: 26 }, + { id: "input", title: "Declare", meta: "EffectIR / PathRef", x: 12, y: 50 }, + { id: "compile", title: "Admit", meta: "labels · overlap", x: 37, y: 26 }, { id: "verify", - title: "Verify", - meta: "cycles · refs · budget", + title: "Authorize", + meta: "principal · capability", x: 37, y: 74, }, - { id: "fanout", title: "Fan-out", meta: "parallel review", x: 62, y: 26 }, - { id: "gate", title: "Gate", meta: "quality / policy", x: 62, y: 74 }, - { id: "cache", title: "Cache", meta: "content addressed", x: 87, y: 26 }, - { id: "final", title: "Return", meta: "finalOutput", x: 87, y: 74 }, + { id: "fanout", title: "Stage", meta: "snapshot · intent", x: 62, y: 26 }, + { id: "gate", title: "Commit", meta: "or restore + reject", x: 62, y: 74 }, + { id: "cache", title: "Ledger", meta: "durable evidence", x: 87, y: 26 }, + { id: "final", title: "Explain", meta: "why-effect", x: 87, y: 74 }, ] as const; type GraphNode = (typeof GRAPH_NODES)[number]; diff --git a/website/components/home/install-rail.tsx b/website/components/home/install-rail.tsx index 1192bdb9..59ae4261 100644 --- a/website/components/home/install-rail.tsx +++ b/website/components/home/install-rail.tsx @@ -9,7 +9,7 @@ const HOSTS: { id: HostId; label: string; command: string; guide: string }[] = [ { id: "pi", label: "Pi", - command: "pi install npm:pi-taskflow", + command: "pi install npm:pi-taskflow@beta", guide: "/docs/getting-started", }, { @@ -30,21 +30,21 @@ const HOSTS: { id: HostId; label: string; command: string; guide: string }[] = [ id: "opencode", label: "OpenCode", command: - "opencode mcp add taskflow -- npx -y -p opencode-taskflow opencode-taskflow-mcp", + "opencode mcp add taskflow -- npx -y -p opencode-taskflow@beta opencode-taskflow-mcp", guide: "/docs/getting-started", }, { id: "grok", label: "Grok", command: - "grok mcp add taskflow -- npx -y -p grok-taskflow grok-taskflow-mcp", + "grok mcp add taskflow -- npx -y -p grok-taskflow@beta grok-taskflow-mcp", guide: "/docs/getting-started", }, { id: "hermes", label: "Hermes", command: - "hermes mcp add taskflow --command npx --args -y -p hermes-taskflow@0.2.10 hermes-taskflow-mcp", + "hermes mcp add taskflow --command npx --args -y -p hermes-taskflow@beta hermes-taskflow-mcp", guide: "/docs/guides/hermes", }, ]; diff --git a/website/content/docs/en/compiler-runtime/typescript-dsl.mdx b/website/content/docs/en/compiler-runtime/typescript-dsl.mdx index 2f4cee4c..7533ffb3 100644 --- a/website/content/docs/en/compiler-runtime/typescript-dsl.mdx +++ b/website/content/docs/en/compiler-runtime/typescript-dsl.mdx @@ -8,7 +8,7 @@ description: Compile-time .tf.ts authoring — erase runes to Taskflow JSON, the S4 adds a **compile-time** TypeScript frontend. You author `*.tf.ts` with **runes** (`agent`, `map`, `race`, …). A CLI erases them to ordinary Taskflow JSON. Hosts still run **JSON** via `taskflow_run` / `/tf run` — there is **no** interpret path and **no** host auto-build of `.tf.ts`. - **Package status.** `taskflow-dsl` lives in the monorepo (`packages/taskflow-dsl`). It is **not** required for JSON authors. Package manifests are `0.2.10` on this release line; install from npm after the `v0.2.10` publish job, or use a workspace / local path from this monorepo. + **Package status.** `taskflow-dsl` lives in the monorepo (`packages/taskflow-dsl`). It is **not** required for JSON authors. Package manifests target `0.3.0-beta.1` on npm's `beta` channel; install with `npm install taskflow-dsl@beta`, or use a workspace / local path from this monorepo. ## Workflow diff --git a/website/content/docs/en/getting-started.mdx b/website/content/docs/en/getting-started.mdx index 4907a046..096bcfcc 100644 --- a/website/content/docs/en/getting-started.mdx +++ b/website/content/docs/en/getting-started.mdx @@ -5,6 +5,10 @@ description: Run your first taskflow in under five minutes. taskflow lets you describe multi-step agent work as a declarative graph. Instead of writing a script that calls subagents one by one, you declare the nodes and edges — and the runtime handles fan-out, retries, caching, and resume. + + This guide covers the stable 0.2.x host installation path. For **0.3.0-beta.1**, start with the [Trusted Effects overview](/en/docs/trusted-effects) and select npm's `beta` channel; beta is not GA. + + The fastest way to see it is to run something. ## A minimal taskflow @@ -77,7 +81,7 @@ If you have not installed taskflow yet, pick your host: ```bash title="Install pi-taskflow" - pi install npm:pi-taskflow + pi install npm:pi-taskflow@beta ``` @@ -94,7 +98,7 @@ If you have not installed taskflow yet, pick your host: ```bash title="Register OpenCode MCP" - opencode mcp add taskflow -- npx -y -p opencode-taskflow opencode-taskflow-mcp + opencode mcp add taskflow -- npx -y -p opencode-taskflow@beta opencode-taskflow-mcp ``` @@ -108,7 +112,7 @@ If you have not installed taskflow yet, pick your host: ```bash title="Register Grok Build MCP" export PI_TASKFLOW_GROK_READONLY_SANDBOX_PROFILE=taskflow-readonly export PI_TASKFLOW_GROK_MUTATING_SANDBOX_PROFILE=taskflow-workspace - grok mcp add taskflow -- npx -y -p grok-taskflow grok-taskflow-mcp + grok mcp add taskflow -- npx -y -p grok-taskflow@beta grok-taskflow-mcp # Public plugin source is not published yet. Checkout-only: # grok plugin install ./packages/grok-taskflow/plugin --trust ``` @@ -116,7 +120,7 @@ If you have not installed taskflow yet, pick your host: ```bash title="Register Hermes Agent MCP" - hermes mcp add taskflow --command npx --args -y -p hermes-taskflow@0.2.10 hermes-taskflow-mcp + hermes mcp add taskflow --command npx --args -y -p hermes-taskflow@beta hermes-taskflow-mcp ``` See the [Hermes Agent guide](/en/docs/guides/hermes) for read-only isolation and explicit mutating opt-in. diff --git a/website/content/docs/en/guides/grok-build.mdx b/website/content/docs/en/guides/grok-build.mdx index bf4ad461..40c36692 100644 --- a/website/content/docs/en/guides/grok-build.mdx +++ b/website/content/docs/en/guides/grok-build.mdx @@ -12,7 +12,7 @@ This page walks through install, verify, first run, permissions, and long-runnin ### Published MCP package (recommended) ```bash title="Register taskflow MCP" -grok mcp add taskflow -- npx -y -p grok-taskflow@0.2.10 grok-taskflow-mcp +grok mcp add taskflow -- npx -y -p grok-taskflow@beta grok-taskflow-mcp ``` Requires **Node.js ≥ 22.19.0**. The MCP protocol code has no MCP SDK dependency. A public Grok plugin marketplace/source is not published yet; do not substitute an imaginary source string. @@ -170,7 +170,7 @@ MCP-driven runs are non-interactive, so an `approval` phase **auto-rejects** (fa ```bash title="Manual MCP registration" pnpm add -g grok-taskflow grok mcp add taskflow -- grok-taskflow-mcp -# or: grok mcp add taskflow -- npx -y -p grok-taskflow@0.2.10 grok-taskflow-mcp +# or: grok mcp add taskflow -- npx -y -p grok-taskflow@beta grok-taskflow-mcp ``` ## Remove diff --git a/website/content/docs/en/guides/hermes.mdx b/website/content/docs/en/guides/hermes.mdx index 716dbed4..a9489079 100644 --- a/website/content/docs/en/guides/hermes.mdx +++ b/website/content/docs/en/guides/hermes.mdx @@ -12,7 +12,7 @@ The full reference lives in [`docs/hermes-mcp.md`](https://github.com/heggria/ta ### Published MCP package (after npm publish) ```bash title="Register taskflow MCP" -hermes mcp add taskflow --command npx --args -y -p hermes-taskflow@0.2.10 hermes-taskflow-mcp +hermes mcp add taskflow --command npx --args -y -p hermes-taskflow@beta hermes-taskflow-mcp ``` Or paste into `~/.hermes/config.yaml` / `$HERMES_HOME/config.yaml`: @@ -21,7 +21,7 @@ Or paste into `~/.hermes/config.yaml` / `$HERMES_HOME/config.yaml`: mcp_servers: taskflow: command: "npx" - args: ["-y", "-p", "hermes-taskflow@0.2.10", "hermes-taskflow-mcp"] + args: ["-y", "-p", "hermes-taskflow@beta", "hermes-taskflow-mcp"] env: # Required for mutating agent phases (terminal / file write / coding). # PI_TASKFLOW_HERMES_UNSAFE_YOLO: "1" # required for mutating agent phases diff --git a/website/content/docs/en/guides/opencode.mdx b/website/content/docs/en/guides/opencode.mdx index 54c451ad..9b901aab 100644 --- a/website/content/docs/en/guides/opencode.mdx +++ b/website/content/docs/en/guides/opencode.mdx @@ -16,7 +16,7 @@ OpenCode has no git-based plugin marketplace, so you register the MCP server dir ### Option A: the CLI ```bash title="Register the MCP server via the CLI" -opencode mcp add taskflow -- npx -y -p opencode-taskflow opencode-taskflow-mcp +opencode mcp add taskflow -- npx -y -p opencode-taskflow@beta opencode-taskflow-mcp ``` ### Option B: edit opencode.json @@ -27,7 +27,7 @@ opencode mcp add taskflow -- npx -y -p opencode-taskflow opencode-taskflow-mcp "mcp": { "taskflow": { "type": "local", - "command": ["npx", "-y", "-p", "opencode-taskflow", "opencode-taskflow-mcp"], + "command": ["npx", "-y", "-p", "opencode-taskflow@beta", "opencode-taskflow-mcp"], "enabled": true } }, diff --git a/website/content/docs/en/guides/pi.mdx b/website/content/docs/en/guides/pi.mdx index abadb5db..08a791a0 100644 --- a/website/content/docs/en/guides/pi.mdx +++ b/website/content/docs/en/guides/pi.mdx @@ -14,7 +14,7 @@ Requires **Node.js ≥ 22.19.0**. taskflow is a Pi extension. Install it once: ```bash title="Install pi-taskflow" -pi install npm:pi-taskflow +pi install npm:pi-taskflow@beta ``` That's it. The extension registers a `taskflow` tool that the model can call automatically, plus a `/tf` command for you. No model-side configuration is required to start. diff --git a/website/content/docs/en/index.mdx b/website/content/docs/en/index.mdx index 76ce4e6a..7457e78c 100644 --- a/website/content/docs/en/index.mdx +++ b/website/content/docs/en/index.mdx @@ -1,130 +1,115 @@ --- -title: taskflow Documentation -description: Start with taskflow 0.2.7 — plan before spend, close the loop after — or jump into the compiler/runtime reference. +title: taskflow 0.3 Documentation +description: "Trusted Effects for coding-agent workflows: declare effects, verify typed paths, and commit admitted filesystem changes through one resource authority." --- -taskflow is a declarative orchestration runtime for coding-agent subagents. You define a graph, **plan and verify it before token spend**, execute each phase in isolation, and return only the final result to the host conversation. +> **0.3.0-beta.1 — beta channel candidate, not GA.** This page describes the Trusted Effects MVP prepared for the beta release. The 0.3-C Control Plane remains a follow-on candidate track. -In **0.2**, that graph became a compiled contract: TypeScript authoring, FlowIR, replay, and incremental recompute. **0.2.7** closes the daily loop: zero-token `plan`, completion hooks, approval timeouts, and read-only analytics. +taskflow is a declarative runtime for coding-agent workflows. It turns a graph into a verifiable execution contract, runs phases in isolation, and keeps intermediate transcripts out of the host conversation. The 0.3 candidate adds **Trusted Effects**: a typed declaration and resource-controlled commit path for admitted filesystem effects. -## Choose your path +## Start with the right path - - Choose a host, install taskflow, and run a first planned + verified flow. + + Learn what `effects[]`, `PathRef`, labels, resource transactions, and `why-*` evidence mean — including the limits of resolve-only execution. - - Plan before spend, hooks after finish, savings on recompute. + + Exercise the checked-in filesystem-write vertical slice without a live model. - - TypeScript DSL, FlowIR, deterministic replay, background runs, and minimal recompute. + + Install a host and run a declarative DAG with agent phases, gates, approvals, and resume. - - The tradeoff behind declarative, inspectable, resumable agent orchestration. + + TypeScript authoring, FlowIR, trace, replay, background runs, and incremental recompute. - - Evaluating the current line? Read **[0.2.7: plan before spend](/en/docs/blog/plan-before-spend-0.2.7)** then the **[compiler/runtime](/en/docs/compiler-runtime)** overview — not the reference front to back. + + Trusted Effects is not an OS sandbox. The 0.3 MVP protects admitted declared filesystem targets through the resources path. Under resolve-only execution, writes to undeclared paths remain host-policy dependent; SecretRef and ServiceRef have no live vault or network backend in this cut. -## Install on your host - - - - ```bash title="Install the Pi extension" - pi install npm:pi-taskflow - ``` - - Continue with the [Pi guide](/en/docs/guides/pi). - - - ```bash title="Install the Codex plugin" - codex plugin marketplace add heggria/taskflow - codex plugin add taskflow@taskflow - ``` - - Continue with the [Codex guide](/en/docs/guides/codex). - - - ```bash title="Install the Claude Code plugin" - claude plugin marketplace add heggria/taskflow - claude plugin install claude-taskflow@taskflow - ``` - - Continue with the [Claude Code guide](/en/docs/guides/claude-code). - - - ```bash title="Register the OpenCode MCP server" - opencode mcp add taskflow -- npx -y -p opencode-taskflow opencode-taskflow-mcp - ``` - - Continue with the [OpenCode guide](/en/docs/guides/opencode). - - - Follow the current package and local-build instructions in the [Grok Build guide](/en/docs/guides/grok-build). - - - ```bash title="Register the Hermes MCP server" - hermes mcp add taskflow --command npx --args -y -p hermes-taskflow@0.2.10 hermes-taskflow-mcp - ``` - - Continue with the [Hermes Agent guide](/en/docs/guides/hermes). - - - -## Learn the model - - - - DAGs, phase types, interpolation, verification, isolation, and resume. - - - Exact flow fields, phase requirements, control flow, caching, budgets, and scorers. - - - Host walkthroughs, templates, dynamic planning, tournaments, and case studies. - - - The Pi command surface, MCP tools, and task/tasks/chain shortcuts. - - - -## The compiler/runtime path +## The 0.3 contract - **Verify before spend.** Start with `/tf verify` or `taskflow_verify`; structural errors cost zero tokens. + **Declare.** Attach a closed `effects[]` list to a phase. Each effect names its kind, typed target, purpose, and optional confidentiality/integrity labels. - **Compile the contract.** Inspect FlowIR and the content hash with `/tf ir`. + **Verify and admit.** Validate the EffectIR, resolve `PathRef`, check information-flow labels and mutating-path overlap, then bind the declared target to a resource intent. - **Resume or replay intentionally.** Resume unfinished work; replay a finished trace only for zero-token what-if decisions. + **Stage and commit.** The resources layer snapshots, leases, journals, stages, and atomically commits the declared filesystem target — or restores and rejects when the transaction cannot complete. - **Recompute the stale frontier.** Use `why-stale` before applying a minimal recompute. + **Explain.** Query `taskflow_why_effect` or the core `why-*` APIs for structured, ledger-backed reasons. Declaration alone is never presented as authorization. -## More resources +## Install on your host + +The 0.3 beta is available from npm's `beta` channel, or from this repository for source-level work. Stable host guides continue to use the published 0.2.x line unless you explicitly select the beta channel. -These resources remain available, but are intentionally outside the primary documentation path: + + Every taskflow package requires **Node.js 22.19.0 or newer**. The candidate's source and fixture checks use pnpm from the repository checkout. + - - Ready-to-run flows organized with the practical guides. + + Native extension, `/tf` commands, run views, and interactive approvals. + + + Plugin and stdio MCP delivery. + + + Plugin and stdio MCP delivery. + + + MCP configuration and generated skill. - - Compare taskflow with imperative workflows, built-in subagents, and LangGraph. + + MCP configuration with explicit host profiles. - - Longer essays and host-specific workflow articles. + + MCP delivery with explicit child toolsets and isolation policy. + + + +## Learn the foundation + + + + DAGs, phase types, interpolation, verification, isolation, and resume. + + + Flow fields, control flow, budgets, caching, and approvals. + + + JSON, TypeScript DSL, FlowIR, trace, replay, and recompute. + + + Host commands and the current 20-tool MCP surface, including `taskflow_why_effect`. + + + +## Release boundary + +The current 0.3 line has two related tracks: + +- **Trusted Effects MVP:** the release-bound product definition. It covers EffectIR, typed refs, labels, resource-controlled filesystem transactions, overlap admission, host-baseline honesty, and ledger-backed `why-*` explainers. +- **0.3-C Control Plane:** a follow-on track whose current code is a ControlHost/proposed-contract scaffold. Project stores, coordination, approvals, receipts, and evidence UI are later stages; none is the 0.3 MVP GA definition, and the WebUI is not shipped in this candidate. + +Read the [MVP freeze in the repository](https://github.com/heggria/taskflow/blob/rc/0.3.0-trusted-effects/docs/internal/0.3.0-trusted-effects-mvp.md) and the [0.3-C plan](https://github.com/heggria/taskflow/blob/rc/0.3.0-trusted-effects/docs/internal/0.3-c-control-plane-plan.md) for the normative scope. + +## More resources + + + + Runnable flow definitions, including the Trusted Effects write fixture. - - Contribute examples, report issues, and join discussions. + + Candidate notes, release boundaries, and the 0.2 history. - - See examples of how teams use taskflow. + + Source, issues, CI, and contribution workflow. diff --git a/website/content/docs/en/meta.json b/website/content/docs/en/meta.json index 64d1e74f..7e6dc79e 100644 --- a/website/content/docs/en/meta.json +++ b/website/content/docs/en/meta.json @@ -2,6 +2,7 @@ "title": "Documentation", "pages": [ "index", + "trusted-effects", "getting-started", "what-is-taskflow", "compiler-runtime", diff --git a/website/content/docs/en/trusted-effects.mdx b/website/content/docs/en/trusted-effects.mdx new file mode 100644 index 00000000..47568aa2 --- /dev/null +++ b/website/content/docs/en/trusted-effects.mdx @@ -0,0 +1,42 @@ +--- +title: Trusted Effects +--- + +> **0.3.0-beta.1 — beta channel candidate, not GA.** This page describes the Trusted Effects MVP prepared for the beta release; npm publication is still a release gate. + +Trusted Effects makes a phase's side effects explicit. The model may propose content, but for an admitted declared filesystem target, the **resources transaction is the only finalizer**. + +## The vertical slice + +The checked-in [`examples/trusted-effects-write.json`](https://github.com/heggria/taskflow/blob/rc/0.3.0-trusted-effects/examples/trusted-effects-write.json) declares one `fs.write` effect for `out/report.md`. The no-LLM fixture exercises the same resource-controlled path: + +```bash +pnpm exec node --conditions=development --experimental-strip-types --test \ + packages/taskflow-core/test/effects-e2e-fixture.test.ts +``` + +## The contract + +| Concept | Meaning | +|---|---| +| `EffectIR` | Closed effect vocabulary carried by the flow/phase contract | +| `PathRef` | Typed workspace-relative target; no bare string authority | +| `SecretRef` / `ServiceRef` | Typed handles that fail closed until a real backend is bound | +| Labels | Fixed confidentiality/integrity checks across the phase DAG | +| Resource transaction | Durable snapshot → lease → intent/permit → stage → commit or restore/reject | +| `why-effect` | Read-only, ledger-backed explanation of authorization and lifecycle | + +## What this does not claim + +- It is not a FileBroker or a full OS sandbox. +- Resolve-only hosts cannot prevent every write to an undeclared path. +- SecretRef has no vault backend in this cut. +- ServiceRef has no live network adapter in this cut. +- ControlHost is currently a scaffold; stores, approvals, receipts, and WebUI belong to later 0.3-C stages. None is proof that 0.3 is released or GA. + +## Read next + +- [Core concepts](/en/docs/concepts) +- [Compiler & runtime](/en/docs/compiler-runtime) +- [0.3 MVP freeze in the repository](https://github.com/heggria/taskflow/blob/rc/0.3.0-trusted-effects/docs/internal/0.3.0-trusted-effects-mvp.md) +- [Host support baseline](https://github.com/heggria/taskflow/blob/rc/0.3.0-trusted-effects/conformance/workspace/host-support-baseline.json) diff --git a/website/content/docs/zh-cn/compiler-runtime/typescript-dsl.mdx b/website/content/docs/zh-cn/compiler-runtime/typescript-dsl.mdx index 09352c04..6dbfa123 100644 --- a/website/content/docs/zh-cn/compiler-runtime/typescript-dsl.mdx +++ b/website/content/docs/zh-cn/compiler-runtime/typescript-dsl.mdx @@ -8,7 +8,7 @@ description: 编译期 .tf.ts 写法 —— rune erase 成 Taskflow JSON,再 S4 增加**编译期** TypeScript 前端:用 rune(`agent`、`map`、`race`…)写 `*.tf.ts`,CLI erase 成普通 Taskflow JSON。宿主仍通过 `taskflow_run` / `/tf run` 跑 **JSON**——**没有**解释执行路径,也**没有**宿主对 `.tf.ts` 的自动 build。 - **包状态。** `taskflow-dsl` 在 monorepo 的 `packages/taskflow-dsl`。纯 JSON 作者不需要它。本发布线 manifest 为 `0.2.10`;`v0.2.10` 发布任务完成后可从 npm 安装,或使用本 monorepo 的 workspace / 本地 path。 + **Package status.** `taskflow-dsl` lives in the monorepo (`packages/taskflow-dsl`). It is **not** required for JSON authors. Package manifests target `0.3.0-beta.1` on npm's `beta` channel; after publication, install with `npm install taskflow-dsl@beta`, or use a workspace / local path from this monorepo. ## 工作流 diff --git a/website/content/docs/zh-cn/getting-started.mdx b/website/content/docs/zh-cn/getting-started.mdx index 9443a502..d5da5a6a 100644 --- a/website/content/docs/zh-cn/getting-started.mdx +++ b/website/content/docs/zh-cn/getting-started.mdx @@ -5,6 +5,10 @@ description: 五分钟内运行你的第一个 taskflow。 taskflow 让你把多步骤的 agent 工作描述成一张声明式图。你不需要写一个一个调用子代理的脚本,只需声明节点和边——运行时会替你处理 fan-out、重试、缓存和续跑。 + + 本指南介绍稳定的 0.2.x 宿主安装路径。对于 **0.3.0-beta.1**,请从 [Trusted Effects 总览](/zh-cn/docs/trusted-effects) 开始,并显式选择 npm 的 `beta` channel;beta 尚未 GA。 + + 要最快地感受它,先跑一个看看。 ## 一个最小的 taskflow @@ -77,7 +81,7 @@ taskflow 让你把多步骤的 agent 工作描述成一张声明式图。你不 ```bash title="安装 pi-taskflow" - pi install npm:pi-taskflow + pi install npm:pi-taskflow@beta ``` @@ -94,7 +98,7 @@ taskflow 让你把多步骤的 agent 工作描述成一张声明式图。你不 ```bash title="注册 OpenCode MCP" - opencode mcp add taskflow -- npx -y -p opencode-taskflow opencode-taskflow-mcp + opencode mcp add taskflow -- npx -y -p opencode-taskflow@beta opencode-taskflow-mcp ``` @@ -108,7 +112,7 @@ taskflow 让你把多步骤的 agent 工作描述成一张声明式图。你不 ```bash title="注册 Grok Build MCP" export PI_TASKFLOW_GROK_READONLY_SANDBOX_PROFILE=taskflow-readonly export PI_TASKFLOW_GROK_MUTATING_SANDBOX_PROFILE=taskflow-workspace - grok mcp add taskflow -- npx -y -p grok-taskflow grok-taskflow-mcp + grok mcp add taskflow -- npx -y -p grok-taskflow@beta grok-taskflow-mcp # 公共 plugin source 尚未发布。仅 checkout 可用: # grok plugin install ./packages/grok-taskflow/plugin --trust ``` @@ -116,7 +120,7 @@ taskflow 让你把多步骤的 agent 工作描述成一张声明式图。你不 ```bash title="注册 Hermes Agent MCP" - hermes mcp add taskflow --command npx --args -y -p hermes-taskflow@0.2.10 hermes-taskflow-mcp + hermes mcp add taskflow --command npx --args -y -p hermes-taskflow@beta hermes-taskflow-mcp ``` 只读隔离与显式可写 opt-in 说明见 [Hermes Agent 指南](/zh-cn/docs/guides/hermes)。 diff --git a/website/content/docs/zh-cn/guides/grok-build.mdx b/website/content/docs/zh-cn/guides/grok-build.mdx index c25434d4..399fdad5 100644 --- a/website/content/docs/zh-cn/guides/grok-build.mdx +++ b/website/content/docs/zh-cn/guides/grok-build.mdx @@ -12,7 +12,7 @@ description: 将 taskflow 作为 Grok Build 插件安装,通过 MCP 编排多 ### 已发布 MCP 包(推荐) ```bash title="注册 taskflow MCP" -grok mcp add taskflow -- npx -y -p grok-taskflow@0.2.10 grok-taskflow-mcp +grok mcp add taskflow -- npx -y -p grok-taskflow@beta grok-taskflow-mcp ``` 要求 **Node.js ≥ 22.19.0**。MCP 协议代码不依赖 MCP SDK。公共 Grok plugin marketplace/source 尚未发布;不要代入一个不存在的 source。 diff --git a/website/content/docs/zh-cn/guides/hermes.mdx b/website/content/docs/zh-cn/guides/hermes.mdx index 8187dbce..3325742e 100644 --- a/website/content/docs/zh-cn/guides/hermes.mdx +++ b/website/content/docs/zh-cn/guides/hermes.mdx @@ -12,7 +12,7 @@ description: 在 Hermes Agent 上通过 MCP 安装 taskflow,用隔离的 Herme ### 已发布 npm 包(发布后) ```bash title="注册 taskflow MCP" -hermes mcp add taskflow --command npx --args -y -p hermes-taskflow@0.2.10 hermes-taskflow-mcp +hermes mcp add taskflow --command npx --args -y -p hermes-taskflow@beta hermes-taskflow-mcp ``` 或写入 `~/.hermes/config.yaml` / `$HERMES_HOME/config.yaml`: @@ -21,7 +21,7 @@ hermes mcp add taskflow --command npx --args -y -p hermes-taskflow@0.2.10 hermes mcp_servers: taskflow: command: "npx" - args: ["-y", "-p", "hermes-taskflow@0.2.10", "hermes-taskflow-mcp"] + args: ["-y", "-p", "hermes-taskflow@beta", "hermes-taskflow-mcp"] env: # 需要写文件/终端的 mutating phase 必须打开 # PI_TASKFLOW_HERMES_UNSAFE_YOLO: "1" # required for mutating agent phases diff --git a/website/content/docs/zh-cn/guides/opencode.mdx b/website/content/docs/zh-cn/guides/opencode.mdx index c75a431b..511a50dd 100644 --- a/website/content/docs/zh-cn/guides/opencode.mdx +++ b/website/content/docs/zh-cn/guides/opencode.mdx @@ -16,7 +16,7 @@ OpenCode 没有基于 git 的插件市场,所以你直接注册 MCP 服务器 ### 方式 A:CLI ```bash title="通过 CLI 注册 MCP 服务器" -opencode mcp add taskflow -- npx -y -p opencode-taskflow opencode-taskflow-mcp +opencode mcp add taskflow -- npx -y -p opencode-taskflow@beta opencode-taskflow-mcp ``` ### 方式 B:编辑 opencode.json @@ -27,7 +27,7 @@ opencode mcp add taskflow -- npx -y -p opencode-taskflow opencode-taskflow-mcp "mcp": { "taskflow": { "type": "local", - "command": ["npx", "-y", "-p", "opencode-taskflow", "opencode-taskflow-mcp"], + "command": ["npx", "-y", "-p", "opencode-taskflow@beta", "opencode-taskflow-mcp"], "enabled": true } }, diff --git a/website/content/docs/zh-cn/guides/pi.mdx b/website/content/docs/zh-cn/guides/pi.mdx index bb9ff52e..2c2c94b7 100644 --- a/website/content/docs/zh-cn/guides/pi.mdx +++ b/website/content/docs/zh-cn/guides/pi.mdx @@ -14,7 +14,7 @@ description: 在 Pi 编程智能体上安装、运行、保存和续跑 taskflow taskflow 是一个 Pi 扩展。装一次就行: ```bash title="安装 pi-taskflow" -pi install npm:pi-taskflow +pi install npm:pi-taskflow@beta ``` 就这样。这个扩展注册了一个模型可以自动调用的 `taskflow` 工具,外加一个给你的 `/tf` 命令。启动不需要任何模型侧的配置。 diff --git a/website/content/docs/zh-cn/index.mdx b/website/content/docs/zh-cn/index.mdx index 6c37a45b..0d1c89ac 100644 --- a/website/content/docs/zh-cn/index.mdx +++ b/website/content/docs/zh-cn/index.mdx @@ -1,130 +1,115 @@ --- -title: taskflow 文档 -description: 从 taskflow 0.2.7 开始——花 token 前先计划,跑完闭环——或直接进入编译器 / 运行时参考。 +title: taskflow 0.3 文档 +description: "面向 coding-agent 工作流的 Trusted Effects:声明 effect,验证类型化路径,让已准入的文件修改经过唯一 resource authority。" --- -taskflow 是面向 coding-agent 子代理的声明式编排运行时。你定义一张图,在花 token 前 **plan 并验证**,让每个阶段隔离执行,并且只把最终结果返回宿主对话。 +> **0.3.0-beta.1——beta channel candidate,尚未 GA。** 这个页面描述为 beta 发布准备的 Trusted Effects MVP;0.3-C Control Plane 仍是后续 candidate 轨道。 -在 **0.2** 中,这张图成为了编译合同:TypeScript 编写、FlowIR、replay、增量重算。**0.2.7** 补上日常闭环:零 token `plan`、完成 hooks、审批超时、只读 analytics。 +taskflow 是面向 coding-agent 工作流的声明式运行时。它把任务图变成可验证的执行合同,让阶段隔离运行,并把中间 transcript 留在宿主对话之外。0.3 candidate 增加了 **Trusted Effects**:为已准入的文件 effect 提供类型化声明与受 resources 控制的提交路径。 -## 选择你的路径 +## 先选择正确路径 - - 选择宿主、安装 taskflow,并运行第一张先 plan 再验证的任务图。 + + 理解 `effects[]`、`PathRef`、labels、resource transaction 与 `why-*` evidence,同时看清 resolve-only 执行的边界。 - - 花 token 前先计划、跑完 hooks、recompute 省钱数字。 + + 不连接 live model,执行仓库内的文件写入 vertical slice。 - - TypeScript DSL、FlowIR、确定性 replay、后台运行与最小重算。 + + 安装宿主,运行带 agent 阶段、gate、审批与 resume 的声明式 DAG。 - - 理解声明式、可检查、可续跑 agent 编排背后的取舍。 + + TypeScript 编写、FlowIR、trace、replay、后台运行与增量重算。 - - 评估当前版本?先读 **[0.2.7:花 token 前先计划](/zh-cn/docs/blog/plan-before-spend-0.2.7)**,再看 **[编译器 / 运行时](/zh-cn/docs/compiler-runtime)** 总览——不要从头通读 Reference。 + + Trusted Effects 不是 OS sandbox。0.3 MVP 通过 resources 路径保护已准入的已声明文件目标。在 resolve-only 执行下,未声明路径的写入仍取决于宿主策略;这一版没有 SecretRef 的 live vault 后端或 ServiceRef 的网络后端。 -## 在你的宿主上安装 - - - - ```bash title="安装 Pi 扩展" - pi install npm:pi-taskflow - ``` - - 继续阅读 [Pi 指南](/zh-cn/docs/guides/pi)。 - - - ```bash title="安装 Codex 插件" - codex plugin marketplace add heggria/taskflow - codex plugin add taskflow@taskflow - ``` - - 继续阅读 [Codex 指南](/zh-cn/docs/guides/codex)。 - - - ```bash title="安装 Claude Code 插件" - claude plugin marketplace add heggria/taskflow - claude plugin install claude-taskflow@taskflow - ``` - - 继续阅读 [Claude Code 指南](/zh-cn/docs/guides/claude-code)。 - - - ```bash title="注册 OpenCode MCP server" - opencode mcp add taskflow -- npx -y -p opencode-taskflow opencode-taskflow-mcp - ``` - - 继续阅读 [OpenCode 指南](/zh-cn/docs/guides/opencode)。 - - - 按 [Grok Build 指南](/zh-cn/docs/guides/grok-build)中的当前 package / 本地构建说明安装。 - - - ```bash title="注册 Hermes MCP server" - hermes mcp add taskflow --command npx --args -y -p hermes-taskflow@0.2.10 hermes-taskflow-mcp - ``` - - 继续阅读 [Hermes Agent 指南](/zh-cn/docs/guides/hermes)。 - - - -## 理解核心模型 - - - - DAG、阶段类型、插值、验证、隔离与续跑。 - - - 精确的 flow 字段、阶段约束、控制流、缓存、预算与 scorers。 - - - 宿主走查、模板、动态规划、锦标赛与案例研究。 - - - Pi 命令、MCP 工具与 task/tasks/chain 快捷形式。 - - - -## Compiler / Runtime 路径 +## 0.3 合同 - **花 token 前先验证。** 从 `/tf verify` 或 `taskflow_verify` 开始,结构错误零 token。 + **声明。** 给 phase 附加封闭的 `effects[]` 列表。每个 effect 写明 kind、类型化 target、purpose,以及可选的 confidentiality/integrity labels。 - **编译执行合同。** 使用 `/tf ir` 检查 FlowIR 与内容哈希。 + **验证并准入。** 校验 EffectIR,解析 `PathRef`,检查 information-flow labels 与 mutating-path 重叠,再把声明目标绑定到 resource intent。 - **有意地区分 resume 与 replay。** Resume 继续未完成工作;replay 只在零 token 下重判已完成轨迹。 + **Stage 并提交。** Resources 层负责 snapshot、lease、journal、stage 与原子提交;事务无法完成时就 restore and reject。 - **只重算 stale frontier。** 应用最小重算之前,先运行 `why-stale`。 + **解释。** 通过 `taskflow_why_effect` 或 core `why-*` API 查看结构化、ledger-backed 原因。声明本身永远不被当作授权。 -## 更多资源 +## 在你的宿主上使用 + +0.3 beta 可从 npm 的 `beta` channel 安装,也可从仓库源码运行。若未显式选择 beta,稳定宿主指南仍使用已发布的 0.2.x package pin。 + + + 所有 taskflow package 都要求 **Node.js 22.19.0 或更高版本**。Candidate 的源码与 fixture 检查使用仓库内的 pnpm。 + + + + + 原生扩展、`/tf` 命令、运行视图与交互式审批。 + + + Plugin 与 stdio MCP 交付。 + + + Plugin 与 stdio MCP 交付。 + + + MCP 配置与生成的 skill。 + + + 带显式宿主 profile 的 MCP 配置。 + + + 带显式子代理 toolset 与隔离策略的 MCP 交付。 + + -这些资源仍然可以访问,但不再占据主文档学习路径: +## 理解基础层 - - 与实用指南放在一起的可运行 flow 模板。 + + DAG、阶段类型、插值、验证、隔离与续跑。 + + + Flow 字段、控制流、预算、缓存与审批。 - - 对比命令式 workflow、内置 subagent 与 LangGraph。 + + JSON、TypeScript DSL、FlowIR、trace、replay 与 recompute。 - - 长篇设计文章与宿主工作流实践。 + + 宿主命令与当前 20 个 MCP 工具,包括 `taskflow_why_effect`。 + + + +## 发布边界 + +当前 0.3 有两条相关轨道: + +- **Trusted Effects MVP:** 面向发布的产品定义。覆盖 EffectIR、类型化 ref、labels、受 resources 控制的文件 transaction、重叠准入、诚实的宿主基线与 ledger-backed `why-*` explainers。 +- **0.3-C Control Plane:** 后续实现轨道;当前代码是 ControlHost / 拟议合同的脚手架。项目 store、协调、审批、receipts 与 evidence UI 属于后续阶段,不是 0.3 MVP 的 GA 定义,WebUI 也尚未在当前 candidate 交付。 + +规范范围请读仓库中的 [MVP 冻结定义](https://github.com/heggria/taskflow/blob/rc/0.3.0-trusted-effects/docs/internal/0.3.0-trusted-effects-mvp.md) 与 [0.3-C 计划](https://github.com/heggria/taskflow/blob/rc/0.3.0-trusted-effects/docs/internal/0.3-c-control-plane-plan.md)。 + +## 更多资源 + + + + 可运行的 flow 定义,包括 Trusted Effects 文件写入 fixture。 - - 贡献案例、报告问题并加入讨论。 + + Candidate 说明、发布边界与 0.2 历史。 - - 查看团队使用 taskflow 的示例。 + + 源码、issue、CI 与贡献流程。 diff --git a/website/content/docs/zh-cn/meta.json b/website/content/docs/zh-cn/meta.json index c6fd27a7..12bbbf4e 100644 --- a/website/content/docs/zh-cn/meta.json +++ b/website/content/docs/zh-cn/meta.json @@ -2,6 +2,7 @@ "title": "文档", "pages": [ "index", + "trusted-effects", "getting-started", "what-is-taskflow", "compiler-runtime", diff --git a/website/content/docs/zh-cn/trusted-effects.mdx b/website/content/docs/zh-cn/trusted-effects.mdx new file mode 100644 index 00000000..5d14fa74 --- /dev/null +++ b/website/content/docs/zh-cn/trusted-effects.mdx @@ -0,0 +1,42 @@ +--- +title: Trusted Effects +--- + +> **0.3.0-beta.1——beta channel candidate,尚未 GA。** 本页描述为 beta 发布准备的 Trusted Effects MVP;npm 发布仍是发版闸门。 + +Trusted Effects 把阶段的副作用写进合同。模型可以提出内容,但对于已准入的已声明文件目标,**resources transaction 是唯一最终提交者**。 + +## Vertical slice + +仓库内的 [`examples/trusted-effects-write.json`](https://github.com/heggria/taskflow/blob/rc/0.3.0-trusted-effects/examples/trusted-effects-write.json) 声明了一个对 `out/report.md` 的 `fs.write` effect。无 LLM fixture 会执行同一条受 resources 控制的路径: + +```bash +pnpm exec node --conditions=development --experimental-strip-types --test \ + packages/taskflow-core/test/effects-e2e-fixture.test.ts +``` + +## 合同 + +| 概念 | 含义 | +|---|---| +| `EffectIR` | 由 flow/phase 合同携带的封闭 effect 词汇 | +| `PathRef` | 类型化的 workspace-relative 目标,不提供裸字符串 authority | +| `SecretRef` / `ServiceRef` | 在绑定真实后端前失败关闭的类型化句柄 | +| Labels | 跨 phase DAG 的固定 confidentiality/integrity 检查 | +| Resource transaction | Durable snapshot → lease → intent/permit → stage → commit 或 restore/reject | +| `why-effect` | 只读、ledger-backed 的授权与生命周期解释 | + +## 它不声称什么 + +- 它不是 FileBroker,也不是完整 OS sandbox。 +- Resolve-only 宿主无法阻止所有对未声明路径的写入。 +- 这一版没有 SecretRef vault 后端。 +- 这一版没有 ServiceRef live network adapter。 +- ControlHost 当前是脚手架;store、审批、receipts 与 WebUI 属于后续的 0.3-C 阶段。它们不证明 0.3 已发布或 GA。 + +## 接下来阅读 + +- [核心概念](/zh-cn/docs/concepts) +- [编译器与运行时](/zh-cn/docs/compiler-runtime) +- [仓库中的 0.3 MVP 冻结定义](https://github.com/heggria/taskflow/blob/rc/0.3.0-trusted-effects/docs/internal/0.3.0-trusted-effects-mvp.md) +- [宿主支持基线](https://github.com/heggria/taskflow/blob/rc/0.3.0-trusted-effects/conformance/workspace/host-support-baseline.json)