From e6efcd482cb4a7deb62cfa3b6c27b4e459e08d86 Mon Sep 17 00:00:00 2001 From: heggria Date: Fri, 7 Aug 2026 17:45:18 +0800 Subject: [PATCH 01/38] feat(effects): add resource-controlled trusted writes Compile closed EffectIR with transitive label checks, route declared writes through the existing resource authority, recover failed and crashed transactions, and expose ledger-backed why-effect evidence. --- CHANGELOG.md | 28 + .../workspace/host-support-baseline.json | 78 +- docs/internal/0.3.0-agent-goal.md | 119 +++ docs/internal/0.3.0-ga-scoreboard.md | 76 ++ docs/internal/0.3.0-release-plan.md | 35 + docs/internal/0.3.0-trusted-effects-mvp.md | 132 +++ examples/trusted-effects-write.json | 34 + .../claude-taskflow/test/mcp-server.test.ts | 4 +- packages/codex-taskflow/test/e2e-codex.mts | 100 +- .../test/e2e-mcp-comprehensive.mts | 58 +- .../codex-taskflow/test/mcp-server.test.ts | 4 +- .../grok-taskflow/test/mcp-server.test.ts | 2 + .../opencode-taskflow/test/mcp-server.test.ts | 4 +- packages/taskflow-core/src/effects/index.ts | 9 + .../src/effects/runtime-apply.ts | 263 ++++++ packages/taskflow-core/src/effects/schema.ts | 82 ++ packages/taskflow-core/src/effects/types.ts | 130 +++ .../taskflow-core/src/effects/validate.ts | 445 +++++++++ packages/taskflow-core/src/effects/why.ts | 445 +++++++++ .../taskflow-core/src/exec/kernel-policy.ts | 4 + packages/taskflow-core/src/exec/step.ts | 6 + .../src/flowir/canonical-hash.ts | 7 +- packages/taskflow-core/src/flowir/compile.ts | 51 +- packages/taskflow-core/src/flowir/index.ts | 2 +- packages/taskflow-core/src/flowir/meta.ts | 6 + packages/taskflow-core/src/flowir/schema.ts | 19 + .../taskflow-core/src/flowir/translate.ts | 30 +- packages/taskflow-core/src/index.ts | 2 + .../taskflow-core/src/resources/execution.ts | 110 ++- .../src/resources/file-transaction.ts | 649 +++++++++++++ .../taskflow-core/src/resources/journal.ts | 206 ++++- packages/taskflow-core/src/resources/types.ts | 4 + packages/taskflow-core/src/runtime.ts | 197 +++- .../src/runtime/phases/script.ts | 6 + packages/taskflow-core/src/schema.ts | 28 + .../src/verifiers/effects-lint.ts | 95 ++ packages/taskflow-core/src/verifiers/index.ts | 4 +- packages/taskflow-core/src/verify.ts | 4 + .../test/effects-agent-te.test.ts | 309 +++++++ .../test/effects-deliverables.test.ts | 207 +++++ .../test/effects-e2e-fixture.test.ts | 164 ++++ .../test/effects-gateway-bypass.test.ts | 342 +++++++ .../test/effects-trusted.test.ts | 871 ++++++++++++++++++ .../test/flowir-canonical-hash.test.ts | 90 ++ .../test/resource-file-transaction.test.ts | 394 ++++++++ .../test/resource-journal.test.ts | 39 + .../taskflow-core/test/verify-effects.test.ts | 160 ++++ packages/taskflow-mcp-core/src/mcp/server.ts | 41 +- 48 files changed, 6014 insertions(+), 81 deletions(-) create mode 100644 docs/internal/0.3.0-agent-goal.md create mode 100644 docs/internal/0.3.0-ga-scoreboard.md create mode 100644 docs/internal/0.3.0-release-plan.md create mode 100644 docs/internal/0.3.0-trusted-effects-mvp.md create mode 100644 examples/trusted-effects-write.json create mode 100644 packages/taskflow-core/src/effects/index.ts create mode 100644 packages/taskflow-core/src/effects/runtime-apply.ts create mode 100644 packages/taskflow-core/src/effects/schema.ts create mode 100644 packages/taskflow-core/src/effects/types.ts create mode 100644 packages/taskflow-core/src/effects/validate.ts create mode 100644 packages/taskflow-core/src/effects/why.ts create mode 100644 packages/taskflow-core/src/resources/file-transaction.ts create mode 100644 packages/taskflow-core/src/verifiers/effects-lint.ts create mode 100644 packages/taskflow-core/test/effects-agent-te.test.ts create mode 100644 packages/taskflow-core/test/effects-deliverables.test.ts create mode 100644 packages/taskflow-core/test/effects-e2e-fixture.test.ts create mode 100644 packages/taskflow-core/test/effects-gateway-bypass.test.ts create mode 100644 packages/taskflow-core/test/effects-trusted.test.ts create mode 100644 packages/taskflow-core/test/resource-file-transaction.test.ts create mode 100644 packages/taskflow-core/test/verify-effects.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index fed732a5..8f89f5e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,34 @@ All notable changes to taskflow are documented here. This project follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) format. +## [0.3.0] — Unreleased (Trusted Effects candidate) + +> **Version plan:** keep published packages at **0.2.8** until a human cuts `v0.3.0`. +> Branch: `codex/0.3.0-trusted-effects-candidate`. **Not GA until tag + publish.** + +### 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/flow `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` + +### 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. +- **Not released; not GA.** + ## [0.2.8] — 2026-08-10 ### Fixed 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/internal/0.3.0-agent-goal.md b/docs/internal/0.3.0-agent-goal.md new file mode 100644 index 00000000..7a1ea520 --- /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:** `feat/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..cd9f6912 --- /dev/null +++ b/docs/internal/0.3.0-ga-scoreboard.md @@ -0,0 +1,76 @@ +# 0.3 Trusted Effects — GA Scoreboard + +> Living ledger. **Do not claim GA** unless L6 is PASS with human tag evidence. + +**Last updated:** 2026-08-07 (live Codex Trusted Effects closure) +**Branch:** `feat/0.3.0-trusted-effects` +**Current evidence:** focused effects/resources/FlowIR suite 79/79; full monorepo typecheck PASS; full unit suite 2199/2199 PASS; full build PASS; built Codex MCP host E2E 16/16 PASS; live Codex CLI three-phase A→B→C run PASS with read-only final agent, resource-controlled `fs.write`, and ledger readback. + +--- + +## Acceptance Gate + +| Level | Status | Evidence | Notes | +|-------|--------|----------|-------| +| L1 local | **PASS** | focused 79/79 + monorepo typecheck PASS | dirty shared worktree remains | +| L2 contract | **PASS** | full unit suite 2199/2199 + full build PASS | local contract only | +| L3 browser/electron | **N/A** | — | | +| L4 real-environment | **PASS (scoped)** | live Codex CLI A→B→C + declared `fs.write` + ledger readback; built MCP E2E 16/16 | guarantee covers admitted declared targets; no FileBroker/sandbox claim | +| L5 released | **FAIL** | no tag/publish | human gate | +| L6 ga | **FAIL** | L5 missing | **NOT GA** | + +**Highest proven:** **L4 for the live Codex host plus built Codex MCP fixture**. +**User-facing claim allowed:** *The branch has a local/contract-green resource-controlled Trusted Effects candidate with live Codex and built MCP proof for admitted declared FS-write targets; clean-candidate, remote CI, release, and GA gates remain open.* +**G5 status:** **contract pass for admitted declared targets** — PathRef escape, direct-write restore, crash recovery, multi-file rollback, gate fast path, cross-run overlap, 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** | phase-local and transitive DAG confidentiality/integrity checks in schema, verifier, compiler, and runtime | +| 4 | resource transaction | **pass** | durable snapshot/intent/permit + Commit-or-Restore + process-crash recovery | +| 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** | live and built-host readback derives principal/capability/intent/generation from resource ledger | + +--- + +## 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 | + +--- + +## Commands last green + +```text +PI_TASKFLOW_BUILTIN_AGENTS_DIR= node --conditions=development \ + --experimental-strip-types --test \ + 'packages/taskflow-core/test/effects*.test.ts' \ + packages/taskflow-core/test/verify-effects.test.ts +# → 79/79 pass (effects/resources/FlowIR focused set) + +pnpm run typecheck +# → PASS + +pnpm test +# → 2199/2199 PASS + +pnpm run build +# → PASS across core, CharterArc, MCP, hosts, DSL, and five delivery packages + +pnpm run test:e2e-codex-mcp-full +# → build PASS + 16/16 built Codex MCP checks, including fs.write + ledger why-effect + +pnpm run test:e2e-codex +# → live Codex A→B→C PASS; read-only final agent + fs.write + ledger why-effect +``` 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..75674066 --- /dev/null +++ b/docs/internal/0.3.0-release-plan.md @@ -0,0 +1,35 @@ +# 0.3.0 Trusted Effects — release plan (human gate) + +## Current state + +| Item | Value | +|------|--------| +| Branch | `feat/0.3.0-trusted-effects` | +| Highest proven | **L4** (local + contract + built MCP + live Codex Trusted Effects fixture) | +| Package versions on npm | still **0.2.7** | +| Tag | **none** | + +## Human checklist for L5 → L6 + +1. Review scoreboard: `docs/internal/0.3.0-ga-scoreboard.md` (all 8 deliverables **pass**, L4 **pass**). +2. Run locally: + ```bash + PI_TASKFLOW_BUILTIN_AGENTS_DIR= node --conditions=development \ + --experimental-strip-types --test \ + 'packages/taskflow-core/test/effects*.test.ts' \ + packages/taskflow-core/test/verify-effects.test.ts + pnpm run typecheck + pnpm test + pnpm run build + pnpm run test:e2e-codex-mcp-full + pnpm run test:e2e-codex + ``` +3. First isolate a clean candidate from the current shared dirty worktree; then push the exact candidate SHA and confirm CI green on that SHA. +4. Bump workspace package versions to `0.3.0` (root + publishable packages) via existing release scripts (`RELEASE.md`). +5. Move CHANGELOG `## [0.3.0] — Unreleased` → dated release section. +6. Tag `v0.3.0` and publish **only with explicit human authority**. +7. Update scoreboard L5/L6 to PASS with tag URL + publish receipt. + +## Agent stop line + +Agents **must not** tag or npm publish. Stop at candidate + truthful scoreboard (L5/L6 FAIL until step 6–7). 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/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/packages/claude-taskflow/test/mcp-server.test.ts b/packages/claude-taskflow/test/mcp-server.test.ts index 01cc6042..76218aa8 100644 --- a/packages/claude-taskflow/test/mcp-server.test.ts +++ b/packages/claude-taskflow/test/mcp-server.test.ts @@ -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/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 2be47274..e036b67f 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); assert.ok(fs.existsSync(bin), `built bin not found at ${bin} — run: npm run build -w codex-taskflow`); @@ -40,7 +43,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) => { @@ -88,7 +91,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"); @@ -242,7 +245,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 @@ -276,5 +326,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 04066a72..571dfdcb 100644 --- a/packages/codex-taskflow/test/mcp-server.test.ts +++ b/packages/codex-taskflow/test/mcp-server.test.ts @@ -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"); @@ -245,7 +245,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"], ); }); diff --git a/packages/grok-taskflow/test/mcp-server.test.ts b/packages/grok-taskflow/test/mcp-server.test.ts index 20119e47..dcf016cd 100644 --- a/packages/grok-taskflow/test/mcp-server.test.ts +++ b/packages/grok-taskflow/test/mcp-server.test.ts @@ -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/opencode-taskflow/test/mcp-server.test.ts b/packages/opencode-taskflow/test/mcp-server.test.ts index 9fbcc72c..b2a6ad54 100644 --- a/packages/opencode-taskflow/test/mcp-server.test.ts +++ b/packages/opencode-taskflow/test/mcp-server.test.ts @@ -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/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..400eda05 --- /dev/null +++ b/packages/taskflow-core/src/effects/validate.ts @@ -0,0 +1,445 @@ +/** + * 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[] = []; + 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: "error", + code: "confidentiality-flow-violation", + message: `source effect '${sourceLabel}' (${sourceConf}) cannot flow to sink '${sinkLabel}' (${sinkConf})`, + effectId: sinkLabel, + }); + } + if (INTEGRITY_RANK[sourceIntegrity] < INTEGRITY_RANK[sinkIntegrity]) { + issues.push({ + severity: "error", + code: "integrity-flow-violation", + message: `source effect '${sourceLabel}' (${sourceIntegrity}) cannot satisfy sink '${sinkLabel}' integrity (${sinkIntegrity})`, + 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; +} + +/** + * 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") : []), + ], +): EffectValidationResult { + const issues: EffectValidationIssue[] = []; + const byId = new Map(); + const ownSources = new Map>(); + for (const phase of phases) { + if (typeof phase.id !== "string" || !phase.id) continue; + byId.set(phase.id, phase); + const sources = new Map(); + for (const effect of Array.isArray(phase.effects) ? phase.effects : []) { + if (!isObject(effect) || typeof effect.id !== "string" || !isEffectKind(effect.kind)) continue; + const valid = effect as ValidEffectRecord; + if (SOURCE_KINDS.has(valid.kind)) sources.set(`${phase.id}/${valid.id}`, valid); + } + ownSources.set(phase.id, sources); + } + 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, phase] of byId) { + const upstreamSources = [...(reachable.get(phaseId) ?? [])] + .filter(([sourceId]) => !sourceId.startsWith(`${phaseId}/`)); + if (upstreamSources.length === 0) continue; + for (const raw of Array.isArray(phase.effects) ? phase.effects : []) { + if (!isObject(raw) || typeof raw.id !== "string" || !isEffectKind(raw.kind)) continue; + const sink = raw as ValidEffectRecord; + if (!SINK_KINDS.has(sink.kind)) continue; + for (const [sourceId, source] of upstreamSources) { + issues.push(...labelFlowIssues(source, sink, sourceId, `${phaseId}/${sink.id}`)); + } + } + } + return { ok: !issues.some((issue) => issue.severity === "error"), 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..23ba3e8f --- /dev/null +++ b/packages/taskflow-core/src/effects/why.ts @@ -0,0 +1,445 @@ +/** + * 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, 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 { + effects?: unknown; + phases?: ReadonlyArray<{ id?: string; effects?: 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 flow-level + each phase (original ids preserved). */ +export function collectDeclaredEffects(flow: WhyEffectFlowLike): LocatedEffect[] { + const out: LocatedEffect[] = []; + const flowLevel = flow.effects; + if (Array.isArray(flowLevel)) { + for (const raw of flowLevel) { + const e = asEffectDecl(raw); + if (e) out.push({ effect: e, bagId: e.id }); + else if (isObject(raw) && typeof raw.id === "string") { + // Keep a stub so validation can still surface shape errors via bag. + out.push({ + effect: raw as unknown as EffectDecl, + bagId: raw.id, + }); + } + } + } + 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 bagEffects = located.map((l) => ({ + ...l.effect, + id: l.bagId, + })); + const validation = validateEffectIR({ effects: bagEffects }); + const effectIssues = validation.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 f8caa1da..e3c52371 100644 --- a/packages/taskflow-core/src/exec/step.ts +++ b/packages/taskflow-core/src/exec/step.ts @@ -581,6 +581,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 4fafe2a9..577170ba 100644 --- a/packages/taskflow-core/src/flowir/compile.ts +++ b/packages/taskflow-core/src/flowir/compile.ts @@ -13,7 +13,7 @@ * @see ./translate.ts (stub; still used for sidecar field list parity) */ -import { collectRefs, PHASE_TYPES, type Phase, type PhaseType, type Taskflow } from "../schema.ts"; +import { collectRefs, dependenciesOf, PHASE_TYPES, type Phase, type PhaseType, type Taskflow } from "../schema.ts"; import { cwdArgName } from "../cwd-bridge.ts"; import { normalizeCond } from "./cond.ts"; import type { @@ -30,6 +30,8 @@ import type { FlowIRNode, TaskflowIRMeta, } from "./meta.ts"; +import type { EffectDecl } from "../effects/types.ts"; +import { validateEffectFlow, 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 (Array.isArray(effectsRaw) && effectsRaw.length > 0) { + 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) { + // 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,16 @@ export function compileTaskflowToFlowIR(def: Taskflow): CompileTaskflowToFlowIRR nodes.push(node); } + const effectFlow = validateEffectFlow(def.phases ?? [], (phase) => dependenciesOf(phase as Phase)); + 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, @@ -262,6 +296,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, @@ -276,7 +311,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..23fba363 100644 --- a/packages/taskflow-core/src/flowir/translate.ts +++ b/packages/taskflow-core/src/flowir/translate.ts @@ -14,7 +14,9 @@ * @see docs/internal/overstory-convergence-roadmap.md §3 (M1) */ -import { collectRefs, type Phase, type Taskflow } from "../schema.ts"; +import { collectRefs, dependenciesOf, type Phase, type Taskflow } from "../schema.ts"; +import type { EffectDecl } from "../effects/types.ts"; +import { validateEffectFlow, 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,40 @@ export function translateTaskflow(def: Taskflow): { sidecarPhases[phase.id] = sidecarForPhase(phase); + const effectsRaw = phase.effects; + let effects: EffectDecl[] | undefined; + if (Array.isArray(effectsRaw) && effectsRaw.length > 0) { + 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) 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 = validateEffectFlow(def.phases, (phase) => dependenciesOf(phase as Phase)); + 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..596f5c86 100644 --- a/packages/taskflow-core/src/resources/execution.ts +++ b/packages/taskflow-core/src/resources/execution.ts @@ -27,8 +27,13 @@ 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 { + 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 +90,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 +294,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 +327,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,6 +363,13 @@ 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, + }), }); } @@ -468,6 +486,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 +807,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..bdeac541 --- /dev/null +++ b/packages/taskflow-core/src/resources/file-transaction.ts @@ -0,0 +1,649 @@ +/** + * 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; +} + +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)); +} + +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): void { + const parent = path.dirname(filePath); + 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 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; + #settled = false; + + constructor(input: { + snapshots: readonly Snapshot[]; + mutation: PreparedMutation; + lease: LeaseHandle; + journal: WriteIntentJournal; + stagingDirectory: string; + onDeferredLeaseRelease?: (lease: LeaseHandle) => 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; + } + + async commit(payloads: readonly FileWritePayload[]): Promise { + this.#assertOpen(); + 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); + } 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 = path.join(this.#stagingDirectory, "staged", `${snapshot.effectId}-${crypto.randomUUID()}.blob`); + 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); + } + 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; + 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(); + try { + return await this.#rejectAndRestore("commit-rejected", reason || "phase rejected"); + } finally { + await this.#finish(); + } + } + + 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; + 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`); + } + + async #finish(): Promise { + try { + fs.rmSync(path.join(this.#stagingDirectory, "staged"), { recursive: true, force: true }); + } finally { + if (!(await releaseBestEffort(this.#lease))) { + this.#onDeferredLeaseRelease?.(this.#lease); + 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}`); + 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, + }); + } catch (error) { + 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"); + } + if (lease && !(await releaseBestEffort(lease))) { + options.onDeferredLeaseRelease?.(lease); + console.warn(`[taskflow] resource transaction lease cleanup deferred for lease ${lease.leaseId}`); + } + throw 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 7e2318dd..8cac87bf 100644 --- a/packages/taskflow-core/src/runtime.ts +++ b/packages/taskflow-core/src/runtime.ts @@ -819,6 +819,29 @@ function flowTreeUsesCwdBridge( return false; } +function flowTreeUsesDeclaredEffects( + def: Taskflow, + loadFlow: RuntimeDeps["loadFlow"], + seenUses = new Set(), +): boolean { + if (def.phases.some((phase) => Array.isArray((phase as { effects?: unknown }).effects) && + ((phase as { effects?: unknown[] }).effects?.length ?? 0) > 0)) return true; + if (!loadFlow) return false; + for (const phase of def.phases) { + if ((phase.type ?? "agent") !== "flow" || !phase.use) continue; + if (seenUses.has(phase.use)) continue; + seenUses.add(phase.use); + 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 @@ -1138,9 +1161,132 @@ async function executePhaseImpl( } return ps; }; + const executeInnerWithDeclaredEffects = async (innerDeps: RuntimeDeps): Promise => { + const effects = (phase as { effects?: unknown }).effects; + if (!Array.isArray(effects) || effects.length === 0) { + return executePhaseInner(phase, state, innerDeps, prior, emitProgress, _retryDepth, innerOpts); + } + 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, @@ -1242,7 +1388,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`, @@ -1287,25 +1433,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 { @@ -1320,7 +1458,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}`; @@ -1449,6 +1587,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"; } @@ -4212,11 +4357,25 @@ 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 { validateEffectFlow } = await import("./effects/validate.ts"); + const labelFlow = validateEffectFlow(def.phases, (phase) => dependenciesOf(phase as Phase)); + 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); @@ -4228,7 +4387,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) || @@ -4236,7 +4395,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; @@ -4252,7 +4411,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, @@ -4319,7 +4478,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 3ed1fdd3..ca2f1831 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 b7ec1840..10c8de92 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 { validateEffectFlow } 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(), { @@ -1521,6 +1534,21 @@ export function validateTaskflow(def: unknown, opts: ValidationOptions = {}): Va } } + // Cycle detection (Kahn) + try { + const labelFlow = validateEffectFlow( + flow.phases as Phase[], + (phase) => dependenciesOf(phase as Phase), + ); + 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/verifiers/effects-lint.ts b/packages/taskflow-core/src/verifiers/effects-lint.ts new file mode 100644 index 00000000..08266984 --- /dev/null +++ b/packages/taskflow-core/src/verifiers/effects-lint.ts @@ -0,0 +1,95 @@ +/** + * Built-in effects verifier — static EffectIR checks (0.3 Trusted Effects MVP). + * + * Collects phase-level `effects[]` (and optional flow-level effects if present), + * 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 { validateEffectFlow, validateEffectIR } from "../effects/validate.ts"; +import { dependenciesOf } from "../schema.ts"; +import type { EffectDecl } from "../effects/types.ts"; +import type { + TaskflowVerifier, + VerifiableFlow, + VerificationIssue, + VerifierIssue, +} from "../verify.ts"; + +function phaseEffects(p: Phase): EffectDecl[] { + const raw = (p as Phase & { effects?: unknown }).effects; + if (!Array.isArray(raw)) return []; + return raw as EffectDecl[]; +} + +function flowLevelEffects(flow: VerifiableFlow): EffectDecl[] { + const raw = (flow as VerifiableFlow & { effects?: unknown }).effects; + if (!Array.isArray(raw)) return []; + return raw as EffectDecl[]; +} + +/** + * Collect all declared effects from a flow (flow-level + 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): VerificationIssue[] { + const phases = Array.isArray(flow.phases) ? flow.phases : []; + const scopedResults: Array<{ phaseId?: string; result: ReturnType }> = []; + const topLevel = flowLevelEffects(flow); + if (topLevel.length > 0) scopedResults.push({ result: validateEffectIR({ effects: topLevel }) }); + for (const rawPhase of phases) { + if (!rawPhase || typeof rawPhase !== "object") continue; + const phase = rawPhase as Phase; + const effects = phaseEffects(phase); + if (effects.length > 0) { + scopedResults.push({ phaseId: phase.id, result: validateEffectIR({ effects }) }); + } + } + if (scopedResults.length === 0) return []; + + const flowResult = validateEffectFlow(phases as Phase[], (phase) => dependenciesOf(phase as Phase)); + 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..18ec90db 100644 --- a/packages/taskflow-core/src/verify.ts +++ b/packages/taskflow-core/src/verify.ts @@ -12,6 +12,7 @@ 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"; // --------------------------------------------------------------------------- // Types @@ -26,6 +27,7 @@ export type IssueCategory = | "ref-integrity" | "guard-contradiction" | "contract" + | "effects" | "plugin"; export interface VerificationIssue { @@ -638,6 +640,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 phase/flow carries effects[] + issues.push(...detectEffectsIssues(safeFlow)); // 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/effects-agent-te.test.ts b/packages/taskflow-core/test/effects-agent-te.test.ts new file mode 100644 index 00000000..9314e18d --- /dev/null +++ b/packages/taskflow-core/test/effects-agent-te.test.ts @@ -0,0 +1,309 @@ +/** + * 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: 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-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-trusted.test.ts b/packages/taskflow-core/test/effects-trusted.test.ts new file mode 100644 index 00000000..9817a42a --- /dev/null +++ b/packages/taskflow-core/test/effects-trusted.test.ts @@ -0,0 +1,871 @@ +/** + * 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("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("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); +}); 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..6e2e9bdb --- /dev/null +++ b/packages/taskflow-core/test/resource-file-transaction.test.ts @@ -0,0 +1,394 @@ +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-")), + }; +} + +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.equal(fs.readdirSync(path.join(control, "file-transactions")).length, 1); + } 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); + } 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"); + }, + }; + } +} + +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: 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); + } 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/verify-effects.test.ts b/packages/taskflow-core/test/verify-effects.test.ts new file mode 100644 index 00000000..42eb9c52 --- /dev/null +++ b/packages/taskflow-core/test/verify-effects.test.ts @@ -0,0 +1,160 @@ +/** + * 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("verify: flow-level effects[] are validated", () => { + const flow = vf([{ id: "a", type: "script", run: "true", final: true }], { + // flow-level effects (optional extension surface) + ...({ + effects: [ + writeEffect("a", "out/x.md"), + writeEffect("b", "out/x.md"), + ], + } as Partial), + }); + const r = verifyTaskflow(flow); + assert.equal(r.ok, false); + assert.ok(r.issues.some((i) => i.category === "effects" && /overlap/i.test(i.message))); +}); + +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)); +}); diff --git a/packages/taskflow-mcp-core/src/mcp/server.ts b/packages/taskflow-mcp-core/src/mcp/server.ts index 0af9190b..1f45c473 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 */ @@ -99,6 +99,8 @@ import { readMapOf, declaredReadMapOfDef, formatWhyStale, + whyEffectFromDurableJournal, + formatWhyEffect, recomputeTaskflow, type RecomputeReport, preflightTaskflow, @@ -647,6 +649,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)", @@ -1143,6 +1162,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. From f2cee92add503a130b4f7fc038fe96b567cc9f9d Mon Sep 17 00:00:00 2001 From: heggria Date: Fri, 7 Aug 2026 17:49:40 +0800 Subject: [PATCH 02/38] docs: record clean 0.3 candidate evidence --- docs/internal/0.3.0-ga-scoreboard.md | 17 +++++++++-------- docs/internal/0.3.0-release-plan.md | 3 ++- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/docs/internal/0.3.0-ga-scoreboard.md b/docs/internal/0.3.0-ga-scoreboard.md index cd9f6912..f876d3e2 100644 --- a/docs/internal/0.3.0-ga-scoreboard.md +++ b/docs/internal/0.3.0-ga-scoreboard.md @@ -2,9 +2,10 @@ > Living ledger. **Do not claim GA** unless L6 is PASS with human tag evidence. -**Last updated:** 2026-08-07 (live Codex Trusted Effects closure) -**Branch:** `feat/0.3.0-trusted-effects` -**Current evidence:** focused effects/resources/FlowIR suite 79/79; full monorepo typecheck PASS; full unit suite 2199/2199 PASS; full build PASS; built Codex MCP host E2E 16/16 PASS; live Codex CLI three-phase A→B→C run PASS with read-only final agent, resource-controlled `fs.write`, and ledger readback. +**Last updated:** 2026-08-07 (clean candidate + live Codex Trusted Effects closure) +**Branch:** `codex/0.3.0-trusted-effects-candidate` +**Code candidate:** `f55abde1` (evidence-only follow-up excluded) +**Current evidence:** focused effects/resources/FlowIR suite 82/82; full monorepo typecheck PASS; full unit suite 2181/2181 PASS; full build PASS; built Codex MCP host E2E 16/16 PASS; live Codex CLI three-phase A→B→C run PASS with read-only final agent, resource-controlled `fs.write`, and ledger readback. All evidence was rerun in the clean candidate worktree. --- @@ -12,15 +13,15 @@ | Level | Status | Evidence | Notes | |-------|--------|----------|-------| -| L1 local | **PASS** | focused 79/79 + monorepo typecheck PASS | dirty shared worktree remains | -| L2 contract | **PASS** | full unit suite 2199/2199 + full build PASS | local contract only | +| L1 local | **PASS** | focused 82/82 + monorepo typecheck PASS | clean candidate worktree | +| L2 contract | **PASS** | full unit suite 2181/2181 + full build PASS | clean-candidate contract only | | L3 browser/electron | **N/A** | — | | | L4 real-environment | **PASS (scoped)** | live Codex CLI A→B→C + declared `fs.write` + ledger readback; built MCP E2E 16/16 | guarantee covers admitted declared targets; no FileBroker/sandbox claim | | L5 released | **FAIL** | no tag/publish | human gate | | L6 ga | **FAIL** | L5 missing | **NOT GA** | **Highest proven:** **L4 for the live Codex host plus built Codex MCP fixture**. -**User-facing claim allowed:** *The branch has a local/contract-green resource-controlled Trusted Effects candidate with live Codex and built MCP proof for admitted declared FS-write targets; clean-candidate, remote CI, release, and GA gates remain open.* +**User-facing claim allowed:** *The clean candidate has local/contract-green resource-controlled Trusted Effects with live Codex and built MCP proof for admitted declared FS-write targets; remote CI, release, and GA gates remain open.* **G5 status:** **contract pass for admitted declared targets** — PathRef escape, direct-write restore, crash recovery, multi-file rollback, gate fast path, cross-run overlap, 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. --- @@ -57,13 +58,13 @@ PI_TASKFLOW_BUILTIN_AGENTS_DIR= node --conditions=development \ --experimental-strip-types --test \ 'packages/taskflow-core/test/effects*.test.ts' \ packages/taskflow-core/test/verify-effects.test.ts -# → 79/79 pass (effects/resources/FlowIR focused set) +# → 82/82 pass (effects/resources/FlowIR focused set) pnpm run typecheck # → PASS pnpm test -# → 2199/2199 PASS +# → 2181/2181 PASS pnpm run build # → PASS across core, CharterArc, MCP, hosts, DSL, and five delivery packages diff --git a/docs/internal/0.3.0-release-plan.md b/docs/internal/0.3.0-release-plan.md index 75674066..d5f8ae2f 100644 --- a/docs/internal/0.3.0-release-plan.md +++ b/docs/internal/0.3.0-release-plan.md @@ -4,7 +4,8 @@ | Item | Value | |------|--------| -| Branch | `feat/0.3.0-trusted-effects` | +| Branch | `codex/0.3.0-trusted-effects-candidate` | +| Code candidate | `f55abde1` (evidence-only follow-up excluded) | | Highest proven | **L4** (local + contract + built MCP + live Codex Trusted Effects fixture) | | Package versions on npm | still **0.2.7** | | Tag | **none** | From 5529ae12d67319d0d22f0020619c2670ff3a3d11 Mon Sep 17 00:00:00 2001 From: heggria Date: Fri, 7 Aug 2026 17:53:19 +0800 Subject: [PATCH 03/38] docs: record remote candidate CI evidence --- docs/internal/0.3.0-ga-scoreboard.md | 7 ++++--- docs/internal/0.3.0-release-plan.md | 3 ++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/internal/0.3.0-ga-scoreboard.md b/docs/internal/0.3.0-ga-scoreboard.md index f876d3e2..9281578e 100644 --- a/docs/internal/0.3.0-ga-scoreboard.md +++ b/docs/internal/0.3.0-ga-scoreboard.md @@ -5,7 +5,7 @@ **Last updated:** 2026-08-07 (clean candidate + live Codex Trusted Effects closure) **Branch:** `codex/0.3.0-trusted-effects-candidate` **Code candidate:** `f55abde1` (evidence-only follow-up excluded) -**Current evidence:** focused effects/resources/FlowIR suite 82/82; full monorepo typecheck PASS; full unit suite 2181/2181 PASS; full build PASS; built Codex MCP host E2E 16/16 PASS; live Codex CLI three-phase A→B→C run PASS with read-only final agent, resource-controlled `fs.write`, and ledger readback. All evidence was rerun in the clean candidate worktree. +**Current evidence:** focused effects/resources/FlowIR suite 82/82; full monorepo typecheck PASS; full unit suite 2181/2181 PASS; full build PASS; built Codex MCP host E2E 16/16 PASS; live Codex CLI three-phase A→B→C run PASS with read-only final agent, resource-controlled `fs.write`, and ledger readback. All local evidence was rerun in the clean candidate worktree. Draft PR #117 exact-SHA CI run 31167592775 passed all 10 matrix jobs plus GitHub CodeQL on `1478510f`. --- @@ -14,14 +14,14 @@ | Level | Status | Evidence | Notes | |-------|--------|----------|-------| | L1 local | **PASS** | focused 82/82 + monorepo typecheck PASS | clean candidate worktree | -| L2 contract | **PASS** | full unit suite 2181/2181 + full build PASS | clean-candidate contract only | +| L2 contract | **PASS** | full unit suite 2181/2181 + full build PASS; PR #117 exact-SHA CI green | Node 22/24, Linux/macOS/Windows supervisor, packed consumer, website, MCP E2E, CodeQL | | L3 browser/electron | **N/A** | — | | | L4 real-environment | **PASS (scoped)** | live Codex CLI A→B→C + declared `fs.write` + ledger readback; built MCP E2E 16/16 | guarantee covers admitted declared targets; no FileBroker/sandbox claim | | L5 released | **FAIL** | no tag/publish | human gate | | L6 ga | **FAIL** | L5 missing | **NOT GA** | **Highest proven:** **L4 for the live Codex host plus built Codex MCP fixture**. -**User-facing claim allowed:** *The clean candidate has local/contract-green resource-controlled Trusted Effects with live Codex and built MCP proof for admitted declared FS-write targets; remote CI, release, and GA gates remain open.* +**User-facing claim allowed:** *The clean candidate has local/contract-green resource-controlled Trusted Effects with live Codex, built MCP, and remote exact-SHA CI proof for admitted declared FS-write targets; release and GA gates remain open.* **G5 status:** **contract pass for admitted declared targets** — PathRef escape, direct-write restore, crash recovery, multi-file rollback, gate fast path, cross-run overlap, 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. --- @@ -48,6 +48,7 @@ | 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` | --- diff --git a/docs/internal/0.3.0-release-plan.md b/docs/internal/0.3.0-release-plan.md index d5f8ae2f..6710bec9 100644 --- a/docs/internal/0.3.0-release-plan.md +++ b/docs/internal/0.3.0-release-plan.md @@ -7,6 +7,7 @@ | Branch | `codex/0.3.0-trusted-effects-candidate` | | Code candidate | `f55abde1` (evidence-only follow-up excluded) | | Highest proven | **L4** (local + contract + built MCP + live Codex Trusted Effects fixture) | +| Remote candidate | Draft PR #117; exact-SHA CI run 31167592775 green on `1478510f` | | Package versions on npm | still **0.2.7** | | Tag | **none** | @@ -25,7 +26,7 @@ pnpm run test:e2e-codex-mcp-full pnpm run test:e2e-codex ``` -3. First isolate a clean candidate from the current shared dirty worktree; then push the exact candidate SHA and confirm CI green on that SHA. +3. ~~Isolate a clean candidate, push the exact candidate SHA, and confirm CI green.~~ Done on Draft PR #117; the evidence-only follow-up must also finish exact-SHA CI before release authorization. 4. Bump workspace package versions to `0.3.0` (root + publishable packages) via existing release scripts (`RELEASE.md`). 5. Move CHANGELOG `## [0.3.0] — Unreleased` → dated release section. 6. Tag `v0.3.0` and publish **only with explicit human authority**. From 467bc2874754fd06da5494958c353188a06dae21 Mon Sep 17 00:00:00 2001 From: heggria Date: Sat, 8 Aug 2026 01:29:18 +0800 Subject: [PATCH 04/38] fix(effects): close composition and transaction gaps --- .../taskflow-core/src/effects/validate.ts | 183 ++++++++++- packages/taskflow-core/src/effects/why.ts | 33 +- packages/taskflow-core/src/flowir/compile.ts | 10 +- .../taskflow-core/src/flowir/translate.ts | 10 +- .../taskflow-core/src/resources/execution.ts | 2 + .../src/resources/file-transaction.ts | 103 ++++++- packages/taskflow-core/src/runtime.ts | 77 +++-- packages/taskflow-core/src/schema.ts | 7 +- .../src/verifiers/effects-lint.ts | 30 +- packages/taskflow-core/src/verify.ts | 2 +- .../test/effects-agent-te.test.ts | 89 ++++++ .../test/effects-composition-cache.test.ts | 284 ++++++++++++++++++ .../test/effects-trusted.test.ts | 135 +++++++++ .../test/resource-file-transaction.test.ts | 127 +++++++- .../taskflow-core/test/verify-effects.test.ts | 103 ++++++- 15 files changed, 1072 insertions(+), 123 deletions(-) create mode 100644 packages/taskflow-core/test/effects-composition-cache.test.ts diff --git a/packages/taskflow-core/src/effects/validate.ts b/packages/taskflow-core/src/effects/validate.ts index 400eda05..29f83408 100644 --- a/packages/taskflow-core/src/effects/validate.ts +++ b/packages/taskflow-core/src/effects/validate.ts @@ -71,6 +71,11 @@ function labelFlowIssues( 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; const sourceConf = confidentialityOf(source); const sinkConf = confidentialityOf(sink); const sourceIntegrity = integrityOf(source); @@ -361,6 +366,83 @@ export interface EffectFlowPhaseLike { 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; +} + +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): PhaseEffectSummary { + return { + sources: [{ + label: `${phaseLabel}/`, + effect: { + id: "", + kind: "secret.read", + confidentiality: "secret", + integrity: "untrusted", + __unknownBoundary: true, + target: { kind: "secret", secret: { secretId: "" } }, + }, + }], + sinks: [{ + label: `${phaseLabel}/`, + effect: { + id: "", + kind: "service.call", + confidentiality: "public", + integrity: "verified", + __unknownBoundary: true, + 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[] }; } /** @@ -375,20 +457,18 @@ export function validateEffectFlow( ...(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 sources = new Map(); - for (const effect of Array.isArray(phase.effects) ? phase.effects : []) { - if (!isObject(effect) || typeof effect.id !== "string" || !isEffectKind(effect.kind)) continue; - const valid = effect as ValidEffectRecord; - if (SOURCE_KINDS.has(valid.kind)) sources.set(`${phase.id}/${valid.id}`, valid); - } - ownSources.set(phase.id, sources); + 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++) { @@ -406,22 +486,97 @@ export function validateEffectFlow( } if (!changed) break; } - for (const [phaseId, phase] of byId) { + for (const phaseId of byId.keys()) { + const ownSourceIds = new Set(ownSources.get(phaseId)?.keys() ?? []); const upstreamSources = [...(reachable.get(phaseId) ?? [])] - .filter(([sourceId]) => !sourceId.startsWith(`${phaseId}/`)); + .filter(([sourceId]) => !ownSourceIds.has(sourceId)); if (upstreamSources.length === 0) continue; - for (const raw of Array.isArray(phase.effects) ? phase.effects : []) { - if (!isObject(raw) || typeof raw.id !== "string" || !isEffectKind(raw.kind)) continue; - const sink = raw as ValidEffectRecord; - if (!SINK_KINDS.has(sink.kind)) continue; + for (const { label: sinkLabel, effect: sink } of sinks.get(phaseId) ?? []) { for (const [sourceId, source] of upstreamSources) { - issues.push(...labelFlowIssues(source, sink, sourceId, `${phaseId}/${sink.id}`)); + 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(); + 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; + 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]); + } + } + const childResult = child + ? summarizeComposedEffectFlow(child, options, `${phaseLabel}/`, childSeen) + : { ok: true, issues: [], summary: unknownBoundarySummary(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. + */ +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); diff --git a/packages/taskflow-core/src/effects/why.ts b/packages/taskflow-core/src/effects/why.ts index 23ba3e8f..7b7c16e5 100644 --- a/packages/taskflow-core/src/effects/why.ts +++ b/packages/taskflow-core/src/effects/why.ts @@ -12,7 +12,7 @@ import type { WhyEffect, } from "./types.ts"; import { EFFECT_KINDS } from "./types.ts"; -import { pathRefRelativeKey, validateEffectIR } from "./validate.ts"; +import { pathRefRelativeKey, validateEffectFlow, validateEffectIR } from "./validate.ts"; import { defaultWorkspaceControlDirectory } from "../resources/execution.ts"; import { WriteIntentJournal, type WriteIntentRecord } from "../resources/journal.ts"; @@ -114,8 +114,7 @@ export function whyEffect(input: WhyInput): WhyEffect { /** Minimal flow shape for effect lookup (Taskflow / FlowIR phases). */ export interface WhyEffectFlowLike { - effects?: unknown; - phases?: ReadonlyArray<{ id?: string; effects?: unknown } | null | undefined>; + phases?: ReadonlyArray<{ id?: string; effects?: unknown; dependsOn?: unknown; from?: unknown } | null | undefined>; } export interface WhyEffectFromFlowInput { @@ -160,23 +159,9 @@ function asEffectDecl(raw: unknown): EffectDecl | undefined { return raw as unknown as EffectDecl; } -/** Collect declared effects from flow-level + each phase (original ids preserved). */ +/** Collect declared effects from each phase (original ids preserved). */ export function collectDeclaredEffects(flow: WhyEffectFlowLike): LocatedEffect[] { const out: LocatedEffect[] = []; - const flowLevel = flow.effects; - if (Array.isArray(flowLevel)) { - for (const raw of flowLevel) { - const e = asEffectDecl(raw); - if (e) out.push({ effect: e, bagId: e.id }); - else if (isObject(raw) && typeof raw.id === "string") { - // Keep a stub so validation can still surface shape errors via bag. - out.push({ - effect: raw as unknown as EffectDecl, - bagId: raw.id, - }); - } - } - } const phases = Array.isArray(flow.phases) ? flow.phases : []; for (const p of phases) { if (!p || typeof p !== "object") continue; @@ -262,12 +247,12 @@ export function whyEffectFromFlow(input: WhyEffectFromFlowInput): WhyEffectFromF } const hit = matches[0]!; - const bagEffects = located.map((l) => ({ - ...l.effect, - id: l.bagId, - })); - const validation = validateEffectIR({ effects: bagEffects }); - const effectIssues = validation.issues.filter( + 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 || diff --git a/packages/taskflow-core/src/flowir/compile.ts b/packages/taskflow-core/src/flowir/compile.ts index 577170ba..1d9964e7 100644 --- a/packages/taskflow-core/src/flowir/compile.ts +++ b/packages/taskflow-core/src/flowir/compile.ts @@ -13,7 +13,7 @@ * @see ./translate.ts (stub; still used for sidecar field list parity) */ -import { collectRefs, dependenciesOf, PHASE_TYPES, type Phase, type PhaseType, type Taskflow } from "../schema.ts"; +import { collectRefs, PHASE_TYPES, type Phase, type PhaseType, type Taskflow } from "../schema.ts"; import { cwdArgName } from "../cwd-bridge.ts"; import { normalizeCond } from "./cond.ts"; import type { @@ -31,7 +31,7 @@ import type { TaskflowIRMeta, } from "./meta.ts"; import type { EffectDecl } from "../effects/types.ts"; -import { validateEffectFlow, validateEffectIR } from "../effects/validate.ts"; +import { validateComposedEffectFlow, validateEffectIR } from "../effects/validate.ts"; // Keep in sync with translate.ts SIDECAR_PHASE_FIELDS (round-trip lossless). const SIDECAR_PHASE_FIELDS = [ @@ -224,7 +224,7 @@ export function compileTaskflowToFlowIR(def: Taskflow): CompileTaskflowToFlowIRR 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 (Array.isArray(effectsRaw) && effectsRaw.length > 0) { + if (effectsRaw !== undefined) { const effectValidation = validateEffectIR({ effects: effectsRaw }); for (const issue of effectValidation.issues) { if (issue.severity === "error") { @@ -237,7 +237,7 @@ export function compileTaskflowToFlowIR(def: Taskflow): CompileTaskflowToFlowIRR warnings.push({ phaseId: phase.id, message: issue.message }); } } - if (effectValidation.ok) { + if (effectValidation.ok && Array.isArray(effectsRaw) && effectsRaw.length > 0) { // Only closed, validated EffectIR enters the content-addressed representation. node.effects = effectsRaw as EffectDecl[]; } @@ -253,7 +253,7 @@ export function compileTaskflowToFlowIR(def: Taskflow): CompileTaskflowToFlowIRR nodes.push(node); } - const effectFlow = validateEffectFlow(def.phases ?? [], (phase) => dependenciesOf(phase as Phase)); + const effectFlow = validateComposedEffectFlow({ name: def.name, phases: def.phases ?? [] }); for (const issue of effectFlow.issues) { const phaseId = issue.effectId?.includes("/") ? issue.effectId.split("/")[0] : undefined; if (issue.severity === "error") { diff --git a/packages/taskflow-core/src/flowir/translate.ts b/packages/taskflow-core/src/flowir/translate.ts index 23fba363..3f2a5956 100644 --- a/packages/taskflow-core/src/flowir/translate.ts +++ b/packages/taskflow-core/src/flowir/translate.ts @@ -14,9 +14,9 @@ * @see docs/internal/overstory-convergence-roadmap.md §3 (M1) */ -import { collectRefs, dependenciesOf, type Phase, type Taskflow } from "../schema.ts"; +import { collectRefs, type Phase, type Taskflow } from "../schema.ts"; import type { EffectDecl } from "../effects/types.ts"; -import { validateEffectFlow, validateEffectIR } from "../effects/validate.ts"; +import { validateComposedEffectFlow, validateEffectIR } from "../effects/validate.ts"; import type { CompileError, CompileWarning, @@ -163,7 +163,7 @@ export function translateTaskflow(def: Taskflow): { const effectsRaw = phase.effects; let effects: EffectDecl[] | undefined; - if (Array.isArray(effectsRaw) && effectsRaw.length > 0) { + if (effectsRaw !== undefined) { const effectValidation = validateEffectIR({ effects: effectsRaw }); for (const issue of effectValidation.issues) { if (issue.severity === "error") { @@ -172,7 +172,7 @@ export function translateTaskflow(def: Taskflow): { warnings.push({ phaseId: phase.id, message: issue.message }); } } - if (effectValidation.ok) effects = effectsRaw as EffectDecl[]; + if (effectValidation.ok && Array.isArray(effectsRaw) && effectsRaw.length > 0) effects = effectsRaw as EffectDecl[]; } return { @@ -185,7 +185,7 @@ export function translateTaskflow(def: Taskflow): { } satisfies FlowIRNode; }); - const effectFlow = validateEffectFlow(def.phases, (phase) => dependenciesOf(phase as Phase)); + const effectFlow = validateComposedEffectFlow({ name: def.name, phases: def.phases }); for (const issue of effectFlow.issues) { const phaseId = issue.effectId?.includes("/") ? issue.effectId.split("/")[0] : undefined; if (issue.severity === "error") { diff --git a/packages/taskflow-core/src/resources/execution.ts b/packages/taskflow-core/src/resources/execution.ts index 596f5c86..dbd5f2f3 100644 --- a/packages/taskflow-core/src/resources/execution.ts +++ b/packages/taskflow-core/src/resources/execution.ts @@ -28,6 +28,7 @@ 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, @@ -371,6 +372,7 @@ class ResolveOnlyWorkspaceSessionImpl implements ResolveOnlyWorkspaceSession { signal: this.#signal, }), }); + garbageCollectResourceFileTransactions(this.#controlDirectory, await this.#journal.listIntents()); } async bindPhase(input: BindResolveOnlyPhaseInput): Promise { diff --git a/packages/taskflow-core/src/resources/file-transaction.ts b/packages/taskflow-core/src/resources/file-transaction.ts index bdeac541..c4d7eaa7 100644 --- a/packages/taskflow-core/src/resources/file-transaction.ts +++ b/packages/taskflow-core/src/resources/file-transaction.ts @@ -79,6 +79,8 @@ export interface ResourceFileTransactionOptions { 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 { @@ -238,6 +240,63 @@ async function releaseBestEffort(lease: LeaseHandle): Promise { 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); @@ -387,7 +446,9 @@ export class PreparedResourceFileTransaction { readonly #journal: WriteIntentJournal; readonly #stagingDirectory: string; readonly #onDeferredLeaseRelease?: (lease: LeaseHandle) => void; + readonly #cleanupStaging: (stagingDirectory: string) => void; #settled = false; + #knownCleanTerminal = false; constructor(input: { snapshots: readonly Snapshot[]; @@ -396,6 +457,7 @@ export class PreparedResourceFileTransaction { journal: WriteIntentJournal; stagingDirectory: string; onDeferredLeaseRelease?: (lease: LeaseHandle) => void; + cleanupStaging?: (stagingDirectory: string) => void; }) { this.intentId = input.mutation.intent.intentId; this.#snapshots = input.snapshots; @@ -404,6 +466,9 @@ export class PreparedResourceFileTransaction { this.#journal = input.journal; this.#stagingDirectory = input.stagingDirectory; this.#onDeferredLeaseRelease = input.onDeferredLeaseRelease; + this.#cleanupStaging = input.cleanupStaging ?? ((stagingDirectory) => { + fs.rmSync(path.join(stagingDirectory, "staged"), { recursive: true, force: true }); + }); } async commit(payloads: readonly FileWritePayload[]): Promise { @@ -451,6 +516,7 @@ export class PreparedResourceFileTransaction { { preCommitGuard: () => assertExactPostState(this.#snapshots, expected) }, ); this.#settled = true; + this.#knownCleanTerminal = true; return { ok: true, intentId: this.intentId, @@ -490,6 +556,7 @@ export class PreparedResourceFileTransaction { 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); @@ -521,7 +588,17 @@ export class PreparedResourceFileTransaction { async #finish(): Promise { try { - fs.rmSync(path.join(this.#stagingDirectory, "staged"), { recursive: true, force: true }); + 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))) { this.#onDeferredLeaseRelease?.(this.#lease); @@ -634,16 +711,24 @@ export async function prepareResourceFileTransaction( journal: options.journal, stagingDirectory: transactionDirectory, onDeferredLeaseRelease: options.onDeferredLeaseRelease, + cleanupStaging: options.cleanupStaging, }); } catch (error) { - 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"); - } - if (lease && !(await releaseBestEffort(lease))) { - options.onDeferredLeaseRelease?.(lease); - console.warn(`[taskflow] resource transaction lease cleanup deferred for lease ${lease.leaseId}`); + 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 error; + throw journalError ?? error; } } diff --git a/packages/taskflow-core/src/runtime.ts b/packages/taskflow-core/src/runtime.ts index 8cac87bf..1683eb49 100644 --- a/packages/taskflow-core/src/runtime.ts +++ b/packages/taskflow-core/src/runtime.ts @@ -824,19 +824,41 @@ function flowTreeUsesDeclaredEffects( loadFlow: RuntimeDeps["loadFlow"], seenUses = new Set(), ): boolean { - if (def.phases.some((phase) => Array.isArray((phase as { effects?: unknown }).effects) && - ((phase as { effects?: unknown[] }).effects?.length ?? 0) > 0)) return true; - if (!loadFlow) return false; + 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) { - if ((phase.type ?? "agent") !== "flow" || !phase.use) continue; - if (seenUses.has(phase.use)) continue; - seenUses.add(phase.use); - 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; + 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; @@ -1161,11 +1183,20 @@ async function executePhaseImpl( } return ps; }; - const executeInnerWithDeclaredEffects = async (innerDeps: RuntimeDeps): Promise => { - const effects = (phase as { effects?: unknown }).effects; - if (!Array.isArray(effects) || effects.length === 0) { - return executePhaseInner(phase, state, innerDeps, prior, emitProgress, _retryDepth, innerOpts); - } + 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) { @@ -3039,7 +3070,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, @@ -3059,7 +3092,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. @@ -4360,8 +4393,10 @@ export async function executeTaskflow(state: RunState, deps: RuntimeDeps): Promi const effectsTree = flowTreeUsesDeclaredEffects(def, deps.loadFlow); const resourceTree = bridgeTree || effectsTree; if (effectsTree) { - const { validateEffectFlow } = await import("./effects/validate.ts"); - const labelFlow = validateEffectFlow(def.phases, (phase) => dependenciesOf(phase as Phase)); + 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 diff --git a/packages/taskflow-core/src/schema.ts b/packages/taskflow-core/src/schema.ts index 10c8de92..dd9dfd7c 100644 --- a/packages/taskflow-core/src/schema.ts +++ b/packages/taskflow-core/src/schema.ts @@ -14,7 +14,7 @@ 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 { validateEffectFlow } from "./effects/validate.ts"; +import { validateComposedEffectFlow } from "./effects/validate.ts"; // --------------------------------------------------------------------------- // Phase types @@ -1536,10 +1536,7 @@ export function validateTaskflow(def: unknown, opts: ValidationOptions = {}): Va // Cycle detection (Kahn) try { - const labelFlow = validateEffectFlow( - flow.phases as Phase[], - (phase) => dependenciesOf(phase as Phase), - ); + const labelFlow = validateComposedEffectFlow({ name: flow.name, phases: flow.phases as Phase[] }); for (const issue of labelFlow.issues) { const message = `[effects] ${issue.message}`; if (issue.severity === "error") errors.push(message); diff --git a/packages/taskflow-core/src/verifiers/effects-lint.ts b/packages/taskflow-core/src/verifiers/effects-lint.ts index 08266984..86306af0 100644 --- a/packages/taskflow-core/src/verifiers/effects-lint.ts +++ b/packages/taskflow-core/src/verifiers/effects-lint.ts @@ -1,7 +1,7 @@ /** * Built-in effects verifier — static EffectIR checks (0.3 Trusted Effects MVP). * - * Collects phase-level `effects[]` (and optional flow-level effects if present), + * 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 @@ -10,9 +10,7 @@ */ import type { Phase } from "../schema.ts"; -import { validateEffectFlow, validateEffectIR } from "../effects/validate.ts"; -import { dependenciesOf } from "../schema.ts"; -import type { EffectDecl } from "../effects/types.ts"; +import { validateComposedEffectFlow, validateEffectIR } from "../effects/validate.ts"; import type { TaskflowVerifier, VerifiableFlow, @@ -20,20 +18,8 @@ import type { VerifierIssue, } from "../verify.ts"; -function phaseEffects(p: Phase): EffectDecl[] { - const raw = (p as Phase & { effects?: unknown }).effects; - if (!Array.isArray(raw)) return []; - return raw as EffectDecl[]; -} - -function flowLevelEffects(flow: VerifiableFlow): EffectDecl[] { - const raw = (flow as VerifiableFlow & { effects?: unknown }).effects; - if (!Array.isArray(raw)) return []; - return raw as EffectDecl[]; -} - /** - * Collect all declared effects from a flow (flow-level + each phase), prefix + * 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"`. * @@ -42,19 +28,17 @@ function flowLevelEffects(flow: VerifiableFlow): EffectDecl[] { export function detectEffectsIssues(flow: VerifiableFlow): VerificationIssue[] { const phases = Array.isArray(flow.phases) ? flow.phases : []; const scopedResults: Array<{ phaseId?: string; result: ReturnType }> = []; - const topLevel = flowLevelEffects(flow); - if (topLevel.length > 0) scopedResults.push({ result: validateEffectIR({ effects: topLevel }) }); for (const rawPhase of phases) { if (!rawPhase || typeof rawPhase !== "object") continue; const phase = rawPhase as Phase; - const effects = phaseEffects(phase); - if (effects.length > 0) { + const effects = (phase as Phase & { effects?: unknown }).effects; + if (effects !== undefined) { scopedResults.push({ phaseId: phase.id, result: validateEffectIR({ effects }) }); } } - if (scopedResults.length === 0) return []; - const flowResult = validateEffectFlow(phases as Phase[], (phase) => dependenciesOf(phase as Phase)); + const flowResult = validateComposedEffectFlow({ phases: phases as Phase[] }); + if (scopedResults.length === 0 && flowResult.issues.length === 0) return []; const issues: VerificationIssue[] = []; for (const scoped of scopedResults) { for (const issue of scoped.result.issues) { diff --git a/packages/taskflow-core/src/verify.ts b/packages/taskflow-core/src/verify.ts index 18ec90db..439e75b4 100644 --- a/packages/taskflow-core/src/verify.ts +++ b/packages/taskflow-core/src/verify.ts @@ -640,7 +640,7 @@ 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 phase/flow carries effects[] + // Trusted Effects (0.3): static EffectIR checks when a phase carries effects[] issues.push(...detectEffectsIssues(safeFlow)); // Caller-supplied verifiers run last, against an isolated deep-frozen snapshot diff --git a/packages/taskflow-core/test/effects-agent-te.test.ts b/packages/taskflow-core/test/effects-agent-te.test.ts index 9314e18d..bb2c22f4 100644 --- a/packages/taskflow-core/test/effects-agent-te.test.ts +++ b/packages/taskflow-core/test/effects-agent-te.test.ts @@ -272,6 +272,95 @@ test("runtime: cross-phase label violation fails before any phase body", async ( } }); +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; 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-trusted.test.ts b/packages/taskflow-core/test/effects-trusted.test.ts index 9817a42a..6b6bfc39 100644 --- a/packages/taskflow-core/test/effects-trusted.test.ts +++ b/packages/taskflow-core/test/effects-trusted.test.ts @@ -56,6 +56,21 @@ test("PhaseSchema/validateTaskflow: accepts phase with effects[] (and without 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 = { @@ -671,6 +686,68 @@ test("compileTaskflowToIR: invalid EffectIR is diagnosed and never content-addre 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 = { @@ -869,3 +946,61 @@ test("whyEffectFromFlow: ambiguous id requires phaseId", () => { 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/resource-file-transaction.test.ts b/packages/taskflow-core/test/resource-file-transaction.test.ts index 6e2e9bdb..5d7374df 100644 --- a/packages/taskflow-core/test/resource-file-transaction.test.ts +++ b/packages/taskflow-core/test/resource-file-transaction.test.ts @@ -37,6 +37,11 @@ function fixture(): { root: string; control: string } { }; } +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, @@ -121,7 +126,7 @@ test("resource file transaction: commits content with durable authority evidence }); assert.equal(declarationOnly.ok, true); if (declarationOnly.ok) assert.equal(declarationOnly.why.authorized.allowed, false); - assert.equal(fs.readdirSync(path.join(control, "file-transactions")).length, 1); + 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 }); @@ -149,6 +154,7 @@ test("resource file transaction: direct final-path bypass is restored with a kno 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 }); @@ -248,6 +254,16 @@ class ReleaseFailsAfterDurableUnlockCoordinator extends PersistentLeaseCoordinat } } +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", @@ -349,6 +365,84 @@ test("resource file transaction: post-terminal lease cleanup failure never makes } }); +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 { @@ -387,6 +481,37 @@ test("resource file transaction: startup recovery restores a process-crashed par 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/verify-effects.test.ts b/packages/taskflow-core/test/verify-effects.test.ts index 42eb9c52..22e0fb4f 100644 --- a/packages/taskflow-core/test/verify-effects.test.ts +++ b/packages/taskflow-core/test/verify-effects.test.ts @@ -87,21 +87,6 @@ test("verify: no effects[] — effects detector is a no-op", () => { assert.equal(r.issues.filter((i) => i.category === "effects").length, 0); }); -test("verify: flow-level effects[] are validated", () => { - const flow = vf([{ id: "a", type: "script", run: "true", final: true }], { - // flow-level effects (optional extension surface) - ...({ - effects: [ - writeEffect("a", "out/x.md"), - writeEffect("b", "out/x.md"), - ], - } as Partial), - }); - const r = verifyTaskflow(flow); - assert.equal(r.ok, false); - assert.ok(r.issues.some((i) => i.category === "effects" && /overlap/i.test(i.message))); -}); - test("detectEffectsIssues: pure helper returns category effects", () => { const flow = vf([ scriptPhase("p", [ @@ -158,3 +143,91 @@ test("verify: dependency-connected source and sink enforce information-flow labe 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)); +}); From 6071fb2a3aedbf3810be792e2f16ed334d4c04d8 Mon Sep 17 00:00:00 2001 From: heggria Date: Sat, 8 Aug 2026 01:31:29 +0800 Subject: [PATCH 05/38] docs: refresh trusted effects candidate evidence --- CHANGELOG.md | 8 ++++- docs/internal/0.3.0-ga-scoreboard.md | 44 ++++++++++++++++------------ 2 files changed, 32 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f89f5e7..c45add06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ All notable changes to taskflow are documented here. This project follows [Keep - 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/flow `effects[]`; FlowIR translate/compile/hash include effects +- 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` @@ -23,6 +23,12 @@ All notable changes to taskflow are documented here. This project follows [Keep - 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. diff --git a/docs/internal/0.3.0-ga-scoreboard.md b/docs/internal/0.3.0-ga-scoreboard.md index 9281578e..e6241ab0 100644 --- a/docs/internal/0.3.0-ga-scoreboard.md +++ b/docs/internal/0.3.0-ga-scoreboard.md @@ -2,10 +2,10 @@ > Living ledger. **Do not claim GA** unless L6 is PASS with human tag evidence. -**Last updated:** 2026-08-07 (clean candidate + live Codex Trusted Effects closure) +**Last updated:** 2026-08-08 (post-review correctness hardening; local contract evidence only) **Branch:** `codex/0.3.0-trusted-effects-candidate` -**Code candidate:** `f55abde1` (evidence-only follow-up excluded) -**Current evidence:** focused effects/resources/FlowIR suite 82/82; full monorepo typecheck PASS; full unit suite 2181/2181 PASS; full build PASS; built Codex MCP host E2E 16/16 PASS; live Codex CLI three-phase A→B→C run PASS with read-only final agent, resource-controlled `fs.write`, and ledger readback. All local evidence was rerun in the clean candidate worktree. Draft PR #117 exact-SHA CI run 31167592775 passed all 10 matrix jobs plus GitHub CodeQL on `1478510f`. +**Code candidate:** `60126954` (scoreboard follow-up excluded) +**Current evidence:** focused effects/resources/FlowIR suite 99/99; full monorepo typecheck PASS; full unit suite 2198/2198 PASS; full build PASS. These checks cover the post-review cache/composition, information-flow, malformed-admission, cleanup/lease, and before-image GC fixes. Exact-SHA remote CI and live-host E2E have **not** yet been rerun for `60126954`. Historical evidence remains: Draft PR #117 CI run 31167592775 passed all 10 matrix jobs plus GitHub CodeQL on `1478510f`; the prior candidate also passed built Codex MCP 16/16 and a live Codex CLI A→B→C run. --- @@ -13,16 +13,16 @@ | Level | Status | Evidence | Notes | |-------|--------|----------|-------| -| L1 local | **PASS** | focused 82/82 + monorepo typecheck PASS | clean candidate worktree | -| L2 contract | **PASS** | full unit suite 2181/2181 + full build PASS; PR #117 exact-SHA CI green | Node 22/24, Linux/macOS/Windows supervisor, packed consumer, website, MCP E2E, CodeQL | +| L1 local | **PASS** | `60126954`: focused 99/99 + monorepo typecheck PASS | post-review fixes committed | +| L2 contract | **PASS** | `60126954`: full unit suite 2198/2198 + full build PASS | exact-SHA remote CI not yet evidenced; prior CI was on `1478510f` | | L3 browser/electron | **N/A** | — | | -| L4 real-environment | **PASS (scoped)** | live Codex CLI A→B→C + declared `fs.write` + ledger readback; built MCP E2E 16/16 | guarantee covers admitted declared targets; no FileBroker/sandbox claim | +| L4 real-environment | **NOT_RUN** | no live-host rerun on `60126954` | prior candidate had scoped live Codex + built MCP evidence; it is not exact-current-SHA proof | | L5 released | **FAIL** | no tag/publish | human gate | | L6 ga | **FAIL** | L5 missing | **NOT GA** | -**Highest proven:** **L4 for the live Codex host plus built Codex MCP fixture**. -**User-facing claim allowed:** *The clean candidate has local/contract-green resource-controlled Trusted Effects with live Codex, built MCP, and remote exact-SHA CI proof for admitted declared FS-write targets; release and GA gates remain open.* -**G5 status:** **contract pass for admitted declared targets** — PathRef escape, direct-write restore, crash recovery, multi-file rollback, gate fast path, cross-run overlap, 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. +**Highest proven for the current code candidate:** **L2 contract**. +**User-facing claim allowed:** *The post-review Trusted Effects candidate is local/contract green for admitted declared FS-write targets; current-SHA remote CI, live-host, release, and GA gates 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. --- @@ -32,12 +32,12 @@ |---|-------------|--------|----------| | 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** | phase-local and transitive DAG confidentiality/integrity checks in schema, verifier, compiler, and runtime | -| 4 | resource transaction | **pass** | durable snapshot/intent/permit + Commit-or-Restore + process-crash recovery | +| 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** | live and built-host readback derives principal/capability/intent/generation from resource ledger | +| 8 | why-* | **pass** | DAG-consistent explanation avoids invented independent-phase dependencies; ledger readback derives principal/capability/intent/generation | --- @@ -49,6 +49,11 @@ | 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` | + +## 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. --- @@ -58,21 +63,22 @@ PI_TASKFLOW_BUILTIN_AGENTS_DIR= node --conditions=development \ --experimental-strip-types --test \ 'packages/taskflow-core/test/effects*.test.ts' \ + packages/taskflow-core/test/resource-file-transaction.test.ts \ + packages/taskflow-core/test/resource-journal.test.ts \ packages/taskflow-core/test/verify-effects.test.ts -# → 82/82 pass (effects/resources/FlowIR focused set) +# → 99/99 pass (effects/resources/FlowIR focused set) pnpm run typecheck # → PASS pnpm test -# → 2181/2181 PASS +# → 2198/2198 PASS pnpm run build # → PASS across core, CharterArc, MCP, hosts, DSL, and five delivery packages -pnpm run test:e2e-codex-mcp-full -# → build PASS + 16/16 built Codex MCP checks, including fs.write + ledger why-effect - -pnpm run test:e2e-codex -# → live Codex A→B→C PASS; read-only final agent + fs.write + ledger why-effect +# NOT RERUN on 60126954: +# pnpm run test:e2e-codex-mcp-full +# pnpm run test:e2e-codex +# Prior-candidate historical evidence: built MCP 16/16 and live Codex A→B→C PASS. ``` From 96a32e8a62e65c6c83096be1ca980490cedfa9e2 Mon Sep 17 00:00:00 2001 From: Clawdy Date: Mon, 10 Aug 2026 18:55:04 +0800 Subject: [PATCH 06/38] fix(store): harden project .pi discovery against tmp and home roots Do not inherit shared OS temp or home .pi dirs while walking ancestors for project flow discovery (hostile CI/tmp). Canonicalize paths via realpath; compare case-insensitively on win32. Wire store boundary tests into CI; keep marketplace/README/website skill counts aligned with the 20-tool MCP roster. --- .claude-plugin/marketplace.json | 4 +- .github/workflows/ci.yml | 2 + .grok-plugin/marketplace.json | 2 +- README.md | 2 +- README.zh-CN.md | 2 +- .../pi-taskflow/test/skills-build.test.ts | 3 +- packages/taskflow-core/src/store.ts | 38 ++++++---- packages/taskflow-core/test/store.test.ts | 69 +++++++++++++++++++ website/app/[lang]/page.tsx | 4 +- 9 files changed, 106 insertions(+), 20 deletions(-) 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 d625d664..b312aa66 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/.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/README.md b/README.md index 42b50d09..f24962af 100644 --- a/README.md +++ b/README.md @@ -182,7 +182,7 @@ The 0.2 line made graphs **compiled and inspectable**. **0.2.7** makes the day-t } ``` -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). +MCP hosts now expose **20 tools**, including `taskflow_plan`, `taskflow_analytics`, and the Trusted Effects audit tool `taskflow_why_effect`. Starter templates: [`examples/templates/`](./examples/templates/). Full notes: [CHANGELOG](./CHANGELOG.md). ## 0.2 is the compiler turn diff --git a/README.zh-CN.md b/README.zh-CN.md index 6be8cb69..f960bf14 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -182,7 +182,7 @@ Pi 审批现在把**选择**和**提交**分开:用 `R` / `E` / `A`、方向 } ``` -MCP 宿主现为 **19 个工具**(新增 `taskflow_plan`、`taskflow_analytics`)。入门模板见 [`examples/templates/`](./examples/templates/)。完整说明:[CHANGELOG 0.2.7](./CHANGELOG.md#027--2026-08-06)。 +MCP 宿主现为 **20 个工具**,包括 `taskflow_plan`、`taskflow_analytics` 和 Trusted Effects 审计工具 `taskflow_why_effect`。入门模板见 [`examples/templates/`](./examples/templates/)。完整说明:[CHANGELOG](./CHANGELOG.md)。 ## 0.2 是编译器转身 diff --git a/packages/pi-taskflow/test/skills-build.test.ts b/packages/pi-taskflow/test/skills-build.test.ts index 3d7168cb..a53c0c52 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-core/src/store.ts b/packages/taskflow-core/src/store.ts index a43342d5..baf4bdd8 100644 --- a/packages/taskflow-core/src/store.ts +++ b/packages/taskflow-core/src/store.ts @@ -1016,25 +1016,39 @@ 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 treat `~/.pi/` as a project flow dir** — the home directory is - // the user-scope boundary, and the user's `~/.pi/` is the agent dir, not a - // project. We skip the home entry entirely during the walk-up, so even a - // deeply nested cwd under home will return null (create=false) when no - // project `.pi` exists on the path. - const home = os.homedir(); - let dir = cwd; + // **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()); + const canonicalCwd = canonicalDiscoveryPath(cwd); + let dir = canonicalCwd; while (true) { - if (dir !== home) { - const candidate = path.join(dir, ".pi"); - if (fs.existsSync(candidate)) return path.join(candidate, "taskflows"); - } + 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(cwd, ".pi", "taskflows") : null; + return create ? path.join(canonicalCwd, ".pi", "taskflows") : null; } /** diff --git a/packages/taskflow-core/test/store.test.ts b/packages/taskflow-core/test/store.test.ts index 808ed7f1..29e86016 100644 --- a/packages/taskflow-core/test/store.test.ts +++ b/packages/taskflow-core/test/store.test.ts @@ -848,6 +848,75 @@ test("findProjectFlowsDir: stops at home dir (v0.0.8.1 boundary)", async () => { // Edge cases and regression guards // --------------------------------------------------------------------------- +test("findProjectFlowsDir: stops at the OS temp root as a project boundary", async () => { + const { findProjectFlowsDir } = await import("../src/store.ts"); + const originalEnv = { + TMPDIR: process.env.TMPDIR, + TMP: process.env.TMP, + TEMP: process.env.TEMP, + }; + const fakeTmp = fs.mkdtempSync(path.join(os.tmpdir(), "pi-taskflow-temp-boundary-")); + const nestedCwd = path.join(fakeTmp, "project", "child"); + fs.mkdirSync(path.join(fakeTmp, ".pi", "taskflows"), { recursive: true }); + fs.mkdirSync(nestedCwd, { recursive: true }); + process.env.TMPDIR = fakeTmp; + process.env.TMP = fakeTmp; + process.env.TEMP = fakeTmp; + + try { + assert.equal(path.resolve(os.tmpdir()), path.resolve(fakeTmp), "test must control the OS temp root"); + assert.equal( + findProjectFlowsDir(nestedCwd, false), + null, + "the shared OS temp root must not become a project merely because it contains .pi", + ); + } finally { + for (const [key, value] of Object.entries(originalEnv)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + fs.rmSync(fakeTmp, { recursive: true, force: true }); + } +}); + +test("findProjectFlowsDir: canonicalizes relative and symlink aliases before boundary checks", async () => { + const { findProjectFlowsDir } = await import("../src/store.ts"); + const originalCwd = process.cwd(); + const originalEnv = { + TMPDIR: process.env.TMPDIR, + TMP: process.env.TMP, + TEMP: process.env.TEMP, + }; + const fakeTmp = fs.mkdtempSync(path.join(os.tmpdir(), "pi-taskflow-temp-alias-")); + const alias = `${fakeTmp}-link`; + const nested = path.join(fakeTmp, "project", "child"); + fs.mkdirSync(path.join(fakeTmp, ".pi", "taskflows"), { recursive: true }); + fs.mkdirSync(nested, { recursive: true }); + fs.symlinkSync(fakeTmp, alias, process.platform === "win32" ? "junction" : "dir"); + process.env.TMPDIR = fakeTmp; + process.env.TMP = fakeTmp; + process.env.TEMP = fakeTmp; + + try { + process.chdir(fakeTmp); + assert.equal(findProjectFlowsDir(path.join("project", "child"), false), null, "relative cwd must stop at temp root"); + process.chdir(originalCwd); + assert.equal( + findProjectFlowsDir(path.join(alias, "project", "child"), false), + null, + "a symlink alias of the temp root must not bypass the boundary", + ); + } finally { + process.chdir(originalCwd); + for (const [key, value] of Object.entries(originalEnv)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + fs.rmSync(alias, { recursive: true, force: true }); + fs.rmSync(fakeTmp, { recursive: true, force: true }); + } +}); + test("saveRun: handles args with complex values", () => { const cwd = makeTmpCwd(); try { diff --git a/website/app/[lang]/page.tsx b/website/app/[lang]/page.tsx index 4469b59e..28ce7f39 100644 --- a/website/app/[lang]/page.tsx +++ b/website/app/[lang]/page.tsx @@ -132,7 +132,7 @@ const copy = { }, { tag: "Hosts", - title: "Five host adapters · 19 MCP tools", + title: "Five host adapters · 20 MCP tools", body: "Pi, Codex, Claude Code, OpenCode, and Grok share one engine.", }, ], @@ -289,7 +289,7 @@ const copy = { }, { tag: "Hosts", - title: "五个宿主 · 19 个 MCP 工具", + title: "五个宿主 · 20 个 MCP 工具", body: "Pi、Codex、Claude Code、OpenCode、Grok 共用同一套引擎。", }, ], From 0834c59ce2569532f35da8911f6090295957f11b Mon Sep 17 00:00:00 2001 From: Clawdy Date: Mon, 10 Aug 2026 19:02:09 +0800 Subject: [PATCH 07/38] docs: refresh 0.3 scoreboard for rc/96a32e8 --- CHANGELOG.md | 2 +- docs/internal/0.3.0-ga-scoreboard.md | 43 ++++++++++++++-------------- docs/internal/0.3.0-release-plan.md | 24 +++++++--------- 3 files changed, 34 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c45add06..6e784efe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to taskflow are documented here. This project follows [Keep ## [0.3.0] — Unreleased (Trusted Effects candidate) > **Version plan:** keep published packages at **0.2.8** until a human cuts `v0.3.0`. -> Branch: `codex/0.3.0-trusted-effects-candidate`. **Not GA until tag + publish.** +> Branch: `rc/0.3.0-trusted-effects`. **Not GA until tag + publish.** ### Added diff --git a/docs/internal/0.3.0-ga-scoreboard.md b/docs/internal/0.3.0-ga-scoreboard.md index e6241ab0..8b9052dd 100644 --- a/docs/internal/0.3.0-ga-scoreboard.md +++ b/docs/internal/0.3.0-ga-scoreboard.md @@ -2,10 +2,10 @@ > Living ledger. **Do not claim GA** unless L6 is PASS with human tag evidence. -**Last updated:** 2026-08-08 (post-review correctness hardening; local contract evidence only) -**Branch:** `codex/0.3.0-trusted-effects-candidate` -**Code candidate:** `60126954` (scoreboard follow-up excluded) -**Current evidence:** focused effects/resources/FlowIR suite 99/99; full monorepo typecheck PASS; full unit suite 2198/2198 PASS; full build PASS. These checks cover the post-review cache/composition, information-flow, malformed-admission, cleanup/lease, and before-image GC fixes. Exact-SHA remote CI and live-host E2E have **not** yet been rerun for `60126954`. Historical evidence remains: Draft PR #117 CI run 31167592775 passed all 10 matrix jobs plus GitHub CodeQL on `1478510f`; the prior candidate also passed built Codex MCP 16/16 and a live Codex CLI A→B→C run. +**Last updated:** 2026-08-10 (RC pipeline re-run at `96a32e8`) +**Branch:** `rc/0.3.0-trusted-effects` +**Code candidate:** `96a32e8` (fix(store): harden project .pi discovery against tmp and home roots) +**Current evidence:** local RC pipeline green at `96a32e8` — full monorepo typecheck PASS; full unit suite 2202 tests → 2198 pass / 0 fail / 4 skipped; full build PASS; `test:pack` 9 packages PASS; built Codex MCP e2e 16/16; `pnpm audit --prod` clean (logs `/tmp/taskflow-03-rc-final/`). These checks cover the post-review cache/composition, information-flow, malformed-admission, cleanup/lease, before-image GC, and store-discovery hardening fixes. Exact-SHA remote CI is **still open** — the branch is not pushed (no gh/SSH GitHub auth on this host). Live-host E2E has **not** been rerun for `96a32e8`; historical evidence only: Draft PR #117 CI run 31167592775 passed all 10 matrix jobs plus GitHub CodeQL on `1478510f`, and the prior candidate passed built Codex MCP 16/16 and a live Codex CLI A→B→C run. --- @@ -13,15 +13,15 @@ | Level | Status | Evidence | Notes | |-------|--------|----------|-------| -| L1 local | **PASS** | `60126954`: focused 99/99 + monorepo typecheck PASS | post-review fixes committed | -| L2 contract | **PASS** | `60126954`: full unit suite 2198/2198 + full build PASS | exact-SHA remote CI not yet evidenced; prior CI was on `1478510f` | +| L1 local | **PASS** | `96a32e8`: RC pipeline — monorepo typecheck PASS, audit --prod clean | RC pipeline green at 96a32e8 | +| L2 contract | **PASS** | `96a32e8`: full unit suite 2202 tests → 2198 pass / 0 fail / 4 skipped + build PASS + test:pack 9 pkgs + built Codex MCP e2e 16/16 | exact-SHA remote CI not yet evidenced — branch not pushed (no GitHub auth) | | L3 browser/electron | **N/A** | — | | -| L4 real-environment | **NOT_RUN** | no live-host rerun on `60126954` | prior candidate had scoped live Codex + built MCP evidence; it is not exact-current-SHA proof | +| L4 real-environment | **NOT_RUN** | no live-host rerun on `96a32e8` | prior candidate had scoped live Codex + built MCP evidence; it is not exact-current-SHA proof | | L5 released | **FAIL** | no tag/publish | human gate | | L6 ga | **FAIL** | L5 missing | **NOT GA** | **Highest proven for the current code candidate:** **L2 contract**. -**User-facing claim allowed:** *The post-review Trusted Effects candidate is local/contract green for admitted declared FS-write targets; current-SHA remote CI, live-host, release, and GA gates remain open.* +**User-facing claim allowed:** *The post-review Trusted Effects candidate is local/contract green for admitted declared FS-write targets (RC pipeline re-run at `96a32e8`: typecheck + 2202-test suite 2198/0/4 + build + pack + built Codex MCP 16/16 + audit clean); current-SHA remote CI, live-host, release, and GA gates 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. --- @@ -50,6 +50,7 @@ | 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 re-run green on `96a32e8` (`rc/0.3.0-trusted-effects`): typecheck, 2202-test suite 2198/0/4, build, test:pack 9 pkgs, built Codex MCP 16/16, audit --prod clean; store .pi discovery hardened; exact-SHA remote CI open | ## Explicitly deferred non-blocker @@ -60,25 +61,25 @@ The append-only resource journal still lacks a sharded/compacted projection desi ## Commands last green ```text -PI_TASKFLOW_BUILTIN_AGENTS_DIR= node --conditions=development \ - --experimental-strip-types --test \ - 'packages/taskflow-core/test/effects*.test.ts' \ - packages/taskflow-core/test/resource-file-transaction.test.ts \ - packages/taskflow-core/test/resource-journal.test.ts \ - packages/taskflow-core/test/verify-effects.test.ts -# → 99/99 pass (effects/resources/FlowIR focused set) - pnpm run typecheck # → PASS pnpm test -# → 2198/2198 PASS +# → 2202 tests: 2198 pass / 0 fail / 4 skipped pnpm run build # → PASS across core, CharterArc, MCP, hosts, DSL, and five delivery packages -# NOT RERUN on 60126954: -# pnpm run test:e2e-codex-mcp-full -# pnpm run test:e2e-codex -# Prior-candidate historical evidence: built MCP 16/16 and live Codex A→B→C PASS. +pnpm run test:pack +# → PASS 9 packages, 24 explicit imports, 80 wildcard exports, 5 bins + +pnpm run test:e2e-codex-mcp-full +# → PASS — comprehensive e2e 16/16 against the built dist bin + +pnpm audit --prod +# → no known vulnerabilities + +# NOT RERUN on 96a32e8: +# pnpm run test:e2e-codex (live Codex CLI; prior-candidate historical A→B→C PASS only) +# Exact-SHA remote CI: open — branch rc/0.3.0-trusted-effects not pushed (no GitHub auth). ``` diff --git a/docs/internal/0.3.0-release-plan.md b/docs/internal/0.3.0-release-plan.md index 6710bec9..e5426fdb 100644 --- a/docs/internal/0.3.0-release-plan.md +++ b/docs/internal/0.3.0-release-plan.md @@ -4,29 +4,27 @@ | Item | Value | |------|--------| -| Branch | `codex/0.3.0-trusted-effects-candidate` | -| Code candidate | `f55abde1` (evidence-only follow-up excluded) | -| Highest proven | **L4** (local + contract + built MCP + live Codex Trusted Effects fixture) | -| Remote candidate | Draft PR #117; exact-SHA CI run 31167592775 green on `1478510f` | -| Package versions on npm | still **0.2.7** | +| Branch | `rc/0.3.0-trusted-effects` | +| Code candidate | `96a32e8` (fix(store): harden project .pi discovery against tmp and home roots) | +| Highest proven | **L2** (local RC pipeline green at `96a32e8`: typecheck PASS; full unit suite 2202 tests → 2198 pass / 0 fail / 4 skipped; build PASS; test:pack 9 pkgs PASS; built Codex MCP e2e 16/16; audit --prod clean) | +| Remote candidate | **not pushed** — no gh/SSH GitHub auth on this host; exact-SHA remote CI still open (historical: Draft PR #117 run 31167592775 green on `1478510f`) | +| L4 live-host | **NOT_RUN** on `96a32e8` (prior-candidate historical evidence only) | +| Package versions on npm | still **0.2.8** (0.3.0 unpublished) | | Tag | **none** | ## Human checklist for L5 → L6 -1. Review scoreboard: `docs/internal/0.3.0-ga-scoreboard.md` (all 8 deliverables **pass**, L4 **pass**). -2. Run locally: +1. Review scoreboard: `docs/internal/0.3.0-ga-scoreboard.md` (all 8 deliverables **pass**, L1/L2 **pass**, L4 **NOT_RUN** on current SHA). +2. Run locally (or confirm the RC logs at `/tmp/taskflow-03-rc-final/`): ```bash - PI_TASKFLOW_BUILTIN_AGENTS_DIR= node --conditions=development \ - --experimental-strip-types --test \ - 'packages/taskflow-core/test/effects*.test.ts' \ - packages/taskflow-core/test/verify-effects.test.ts pnpm run typecheck pnpm test pnpm run build + pnpm run test:pack pnpm run test:e2e-codex-mcp-full - pnpm run test:e2e-codex + pnpm audit --prod ``` -3. ~~Isolate a clean candidate, push the exact candidate SHA, and confirm CI green.~~ Done on Draft PR #117; the evidence-only follow-up must also finish exact-SHA CI before release authorization. +3. **Push `rc/0.3.0-trusted-effects` at exact candidate SHA `96a32e8`** (requires GitHub auth — gh/SSH both fail on this host) and confirm exact-SHA remote CI green. 4. Bump workspace package versions to `0.3.0` (root + publishable packages) via existing release scripts (`RELEASE.md`). 5. Move CHANGELOG `## [0.3.0] — Unreleased` → dated release section. 6. Tag `v0.3.0` and publish **only with explicit human authority**. From 7add1606120adbae7f3142622a9f6ed26ff5c75d Mon Sep 17 00:00:00 2001 From: Clawdy Date: Mon, 10 Aug 2026 19:03:07 +0800 Subject: [PATCH 08/38] docs: add draft PR body for 0.3 trusted-effects RC Source for GitHub PR description; not a publish request. Head rc/0.3.0-trusted-effects, base main. Explicit no npm until human v0.3.0. --- docs/internal/pr-0.3.0-trusted-effects.md | 70 +++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 docs/internal/pr-0.3.0-trusted-effects.md 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..e47a2a10 --- /dev/null +++ b/docs/internal/pr-0.3.0-trusted-effects.md @@ -0,0 +1,70 @@ +# rc: 0.3.0 Trusted Effects candidate (do not publish) + +> Draft PR body. **Not for npm publish** — published packages stay at **0.2.8** until a human cuts `v0.3.0`. +> This file is the source for the GitHub PR description; copy it into the PR once `gh` auth is available. + +## Base / Head + +| | | +|---|---| +| Base | `main` @ `4b04e0d` — fix(pi): require explicit approval confirmation (#119) | +| Head | `rc/0.3.0-trusted-effects` @ `96a32e8` (6 commits ahead of main) | +| Status | **DRAFT — do not merge, do not publish** | + +This PR supersedes the historical Draft **PR #117** (`head: codex/0.3.0-trusted-effects-candidate`). Prefer opening this PR from the `rc/0.3.0-trusted-effects` branch, or update the existing draft with the clear SHA above. + +## 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`) + +## 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** | post-review candidate `6012695`: focused effects/resources/FlowIR suite 99/99; monorepo typecheck PASS | +| L2 contract | **PASS** | full unit suite 2198/2198; full build PASS | +| L3 browser/electron | N/A | — | +| L4 real-environment | **NOT_RUN** | no live-host E2E on the current SHA; historical live Codex A→B→C + built Codex MCP 16/16 were on an earlier candidate | +| L5 released | **FAIL** | no tag/publish — human gate | +| L6 ga | **FAIL** | L5 missing — **NOT GA** | + +Remote CI: historical Draft PR #117 run `31167592775` passed all 10 matrix jobs + GitHub CodeQL on `1478510f` (earlier candidate SHA). **Exact-SHA remote CI and live-host E2E have not been rerun for `96a32e8`** (the rc head adds the store-discovery hardening with its own local store tests). + +## Commits (rc/0.3.0-trusted-effects vs main, newest first) + +``` +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] — Unreleased (Trusted Effects candidate)` — still Unreleased. +- **Not released; not GA.** No tag, no npm publish — blocked on explicit human authorization to cut `v0.3.0`. From 59fd9c3d115ffe45599f1cb7ba90bab41dcb5979 Mon Sep 17 00:00:00 2001 From: Clawdy Date: Mon, 10 Aug 2026 19:08:21 +0800 Subject: [PATCH 09/38] docs(skills): teach 0.3 Trusted Effects authoring surface Add the effects[] DSL surface (fs.write PathRef shape, labels, payload mapping, example pointer), the model-proposes/resources-commit authority one-liner, the MCP-only taskflow_why_effect audit, and explicit honesty non-claims (no FileBroker sandbox, undeclared paths, SecretRef/ServiceRef type-only fail-closed). Regenerated the five host SKILL.md files via scripts/build-skills.mjs; skills-build test stays green. --- .../plugin/skills/taskflow/SKILL.md | 78 ++++++++++++++++++ .../plugin/skills/taskflow/SKILL.md | 78 ++++++++++++++++++ .../plugin/skills/taskflow/SKILL.md | 78 ++++++++++++++++++ .../plugin/skills/taskflow/SKILL.md | 78 ++++++++++++++++++ packages/pi-taskflow/skills/taskflow/SKILL.md | 68 ++++++++++++++++ skills-src/taskflow/core.md | 79 +++++++++++++++++++ skills-src/taskflow/entry.claude.md | 1 + skills-src/taskflow/entry.codex.md | 1 + skills-src/taskflow/entry.grok.md | 1 + skills-src/taskflow/entry.opencode.md | 1 + 10 files changed, 463 insertions(+) diff --git a/packages/claude-taskflow/plugin/skills/taskflow/SKILL.md b/packages/claude-taskflow/plugin/skills/taskflow/SKILL.md index 32d62974..c1e17653 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. | @@ -494,6 +496,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/plugin/skills/taskflow/SKILL.md b/packages/codex-taskflow/plugin/skills/taskflow/SKILL.md index 72780fed..65736dd6 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. | @@ -489,6 +491,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/plugin/skills/taskflow/SKILL.md b/packages/grok-taskflow/plugin/skills/taskflow/SKILL.md index 34c9bc58..c006296b 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. | @@ -499,6 +501,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/plugin/skills/taskflow/SKILL.md b/packages/opencode-taskflow/plugin/skills/taskflow/SKILL.md index 313841dd..11de4a89 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. | @@ -490,6 +492,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/pi-taskflow/skills/taskflow/SKILL.md b/packages/pi-taskflow/skills/taskflow/SKILL.md index 30d6b3be..b9ec6f91 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. | @@ -466,6 +467,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/skills-src/taskflow/core.md b/skills-src/taskflow/core.md index 61f4d99f..7bcaf8cb 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. | @@ -492,6 +493,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.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. | From d532f692c5ade0bf772600664e0fcdacddd898e9 Mon Sep 17 00:00:00 2001 From: Clawdy Date: Mon, 10 Aug 2026 19:12:50 +0800 Subject: [PATCH 10/38] docs: raise scoreboard to L4 built-MCP fixture for rc/96a32e8 Align the 0.3 GA scoreboard with the built Codex MCP 16/16 TE fixture (codex-mcp-full.log: fs.write committed through resources + ledger-backed why-effect). Set L4 honestly as PASS (built-MCP fixture) at evidence SHA 6071fb2 per the build-info stamp, keep the live-Codex-CLI leg and exact-SHA remote CI open, and keep L5/L6 FAIL (no tag/publish). Note that the branch is now pushed to origin via worker-mac SSH relay while CI has no run for 96a32e8 (workflow triggers only on PR->main / push main). Update the release-plan L4 row + human checklist and the agent-goal branch name to rc/0.3.0-trusted-effects. --- docs/internal/0.3.0-agent-goal.md | 2 +- docs/internal/0.3.0-ga-scoreboard.md | 41 +++++++++++++++++----------- docs/internal/0.3.0-release-plan.md | 10 +++---- 3 files changed, 31 insertions(+), 22 deletions(-) diff --git a/docs/internal/0.3.0-agent-goal.md b/docs/internal/0.3.0-agent-goal.md index 7a1ea520..3f5acccf 100644 --- a/docs/internal/0.3.0-agent-goal.md +++ b/docs/internal/0.3.0-agent-goal.md @@ -1,7 +1,7 @@ # Agent Goal — Taskflow 0.3 Trusted Effects → Closed-Loop GA > **Owner:** coding agent (this session and successors) -> **Branch:** `feat/0.3.0-trusted-effects` +> **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) diff --git a/docs/internal/0.3.0-ga-scoreboard.md b/docs/internal/0.3.0-ga-scoreboard.md index 8b9052dd..feea4cd5 100644 --- a/docs/internal/0.3.0-ga-scoreboard.md +++ b/docs/internal/0.3.0-ga-scoreboard.md @@ -2,10 +2,10 @@ > Living ledger. **Do not claim GA** unless L6 is PASS with human tag evidence. -**Last updated:** 2026-08-10 (RC pipeline re-run at `96a32e8`) -**Branch:** `rc/0.3.0-trusted-effects` +**Last updated:** 2026-08-10 (candidate `96a32e8`; RC pipeline evidence + exact-SHA store/typecheck re-verify) +**Branch:** `rc/0.3.0-trusted-effects` (tip now carries docs-only commits — scoreboard refresh `0834c59`, PR draft `7add160`, skills `59fd9c3` — on top of the code candidate; product code unchanged) **Code candidate:** `96a32e8` (fix(store): harden project .pi discovery against tmp and home roots) -**Current evidence:** local RC pipeline green at `96a32e8` — full monorepo typecheck PASS; full unit suite 2202 tests → 2198 pass / 0 fail / 4 skipped; full build PASS; `test:pack` 9 packages PASS; built Codex MCP e2e 16/16; `pnpm audit --prod` clean (logs `/tmp/taskflow-03-rc-final/`). These checks cover the post-review cache/composition, information-flow, malformed-admission, cleanup/lease, before-image GC, and store-discovery hardening fixes. Exact-SHA remote CI is **still open** — the branch is not pushed (no gh/SSH GitHub auth on this host). Live-host E2E has **not** been rerun for `96a32e8`; historical evidence only: Draft PR #117 CI run 31167592775 passed all 10 matrix jobs plus GitHub CodeQL on `1478510f`, and the prior candidate passed built Codex MCP 16/16 and a live Codex CLI A→B→C run. +**Current evidence:** local RC pipeline green (logs `/tmp/taskflow-03-rc-final/`): full monorepo typecheck PASS; full unit suite 2202 tests → 2198 pass / 0 fail / 4 skipped; full build PASS; `test:pack` 9 packages PASS; built Codex MCP e2e 16/16; `pnpm audit --prod` clean. The pipeline logs were produced with HEAD at `6071fb2` (build-info stamp `6071fb2a3aed`); the `96a32e8` store-discovery hardening delta was re-verified at its exact SHA by focused store suite 69/69 + full typecheck (t_bf5ab0c1). These checks cover the post-review cache/composition, information-flow, malformed-admission, cleanup/lease, before-image GC, and store-discovery hardening fixes. Exact-SHA remote CI is **still open** — the branch **is pushed** to origin (via worker-mac SSH relay), but no CI run exists for it: `.github/workflows/ci.yml` triggers only on PR→main / push to main, and no open PR has head `96a32e8` (Draft PR #117 head is still the stale `8778b2f`); opening/updating that PR is the human gate. Live Codex CLI E2E has **not** been rerun for `96a32e8`; historical evidence only: Draft PR #117 CI run 31167592775 passed all 10 matrix jobs plus GitHub CodeQL on `1478510f`, and the prior candidate passed built Codex MCP 16/16 and a live Codex CLI A→B→C run. --- @@ -13,15 +13,15 @@ | Level | Status | Evidence | Notes | |-------|--------|----------|-------| -| L1 local | **PASS** | `96a32e8`: RC pipeline — monorepo typecheck PASS, audit --prod clean | RC pipeline green at 96a32e8 | -| L2 contract | **PASS** | `96a32e8`: full unit suite 2202 tests → 2198 pass / 0 fail / 4 skipped + build PASS + test:pack 9 pkgs + built Codex MCP e2e 16/16 | exact-SHA remote CI not yet evidenced — branch not pushed (no GitHub auth) | +| L1 local | **PASS** | `96a32e8`: monorepo typecheck PASS + audit --prod clean (RC pipeline logs; store delta re-verified at exact SHA) | RC pipeline green; typecheck re-run at exact SHA `96a32e8` | +| L2 contract | **PASS** | `96a32e8`: full unit suite 2202 tests → 2198 pass / 0 fail / 4 skipped + build PASS + test:pack 9 pkgs + built Codex MCP e2e 16/16 | pipeline logs built at evidence SHA `6071fb2`; exact-SHA remote CI not yet evidenced — branch pushed to origin but no run exists (CI triggers only on PR→main / push main; no PR head = `96a32e8`) | | L3 browser/electron | **N/A** | — | | -| L4 real-environment | **NOT_RUN** | no live-host rerun on `96a32e8` | prior candidate had scoped live Codex + built MCP evidence; it is not exact-current-SHA proof | +| L4 real-environment | **PASS (built-MCP fixture)** | built Codex MCP comprehensive e2e 16/16 incl. TE fixture — `fs.write` committed through resources + ledger-backed why-effect — against the built dist bin (`codex-mcp-full.log`; evidence SHA `6071fb2` per build-info stamp) | fixture/network-free runner on the real built artifact, **NOT** a live Codex CLI run: `test:e2e-codex` NOT rerun on `96a32e8` (prior-candidate A→B→C historical only) | | L5 released | **FAIL** | no tag/publish | human gate | | L6 ga | **FAIL** | L5 missing | **NOT GA** | -**Highest proven for the current code candidate:** **L2 contract**. -**User-facing claim allowed:** *The post-review Trusted Effects candidate is local/contract green for admitted declared FS-write targets (RC pipeline re-run at `96a32e8`: typecheck + 2202-test suite 2198/0/4 + build + pack + built Codex MCP 16/16 + audit clean); current-SHA remote CI, live-host, release, and GA gates remain open.* +**Highest proven for the current code candidate:** **L4 real-environment (built-MCP fixture)** — the live-Codex-CLI leg of L4 and exact-SHA remote CI remain open. +**User-facing claim allowed:** *The post-review Trusted Effects candidate is green through the built-MCP real-environment fixture for admitted declared FS-write targets (RC pipeline at `96a32e8`: typecheck + 2202-test suite 2198/0/4 + build + pack + built Codex MCP 16/16 incl. TE fs.write + why-effect + audit clean; store delta re-verified at exact SHA); live Codex CLI run, exact-SHA remote CI, release, and GA gates 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. --- @@ -50,7 +50,7 @@ | 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 re-run green on `96a32e8` (`rc/0.3.0-trusted-effects`): typecheck, 2202-test suite 2198/0/4, build, test:pack 9 pkgs, built Codex MCP 16/16, audit --prod clean; store .pi discovery hardened; exact-SHA remote CI open | +| 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 | ## Explicitly deferred non-blocker @@ -62,24 +62,33 @@ The append-only resource journal still lacks a sharded/compacted projection desi ```text pnpm run typecheck -# → PASS +# → PASS (re-run at exact SHA 96a32e8; t_bf5ab0c1) pnpm test -# → 2202 tests: 2198 pass / 0 fail / 4 skipped +# → 2202 tests: 2198 pass / 0 fail / 4 skipped (RC pipeline log; built at evidence SHA 6071fb2) pnpm run build -# → PASS across core, CharterArc, MCP, hosts, DSL, and five delivery packages +# → PASS across core, CharterArc, MCP, hosts, DSL, and five delivery packages (RC pipeline log) pnpm run test:pack -# → PASS 9 packages, 24 explicit imports, 80 wildcard exports, 5 bins +# → PASS 9 packages, 24 explicit imports, 80 wildcard exports, 5 bins (RC pipeline log) pnpm run test:e2e-codex-mcp-full -# → PASS — comprehensive e2e 16/16 against the built dist bin +# → 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; +# evidence SHA 6071fb2 per build-info stamp) pnpm audit --prod -# → no known vulnerabilities +# → no known vulnerabilities (RC pipeline log) + +# Focused store suite at exact SHA 96a32e8 (t_bf5ab0c1): +# node --conditions=development --experimental-strip-types --test packages/taskflow-core/test/store.test.ts +# → 69/69 pass +# Full store*.test.ts glob (store, store-extended, store-dogfood) → 100/100 pass (re-verified) # NOT RERUN on 96a32e8: +# pnpm test (full suite) / build / test:pack / test:e2e-codex-mcp-full — RC pipeline logs are at evidence SHA 6071fb2 (store delta re-verified at exact SHA) # pnpm run test:e2e-codex (live Codex CLI; prior-candidate historical A→B→C PASS only) -# Exact-SHA remote CI: open — branch rc/0.3.0-trusted-effects not pushed (no GitHub auth). +# Exact-SHA remote CI: open — branch pushed to origin but CI has no run (workflow triggers +# only on PR→main / push main; no PR head = 96a32e8; human gate: open/update PR). ``` diff --git a/docs/internal/0.3.0-release-plan.md b/docs/internal/0.3.0-release-plan.md index e5426fdb..70ff5029 100644 --- a/docs/internal/0.3.0-release-plan.md +++ b/docs/internal/0.3.0-release-plan.md @@ -6,15 +6,15 @@ |------|--------| | Branch | `rc/0.3.0-trusted-effects` | | Code candidate | `96a32e8` (fix(store): harden project .pi discovery against tmp and home roots) | -| Highest proven | **L2** (local RC pipeline green at `96a32e8`: typecheck PASS; full unit suite 2202 tests → 2198 pass / 0 fail / 4 skipped; build PASS; test:pack 9 pkgs PASS; built Codex MCP e2e 16/16; audit --prod clean) | -| Remote candidate | **not pushed** — no gh/SSH GitHub auth on this host; exact-SHA remote CI still open (historical: Draft PR #117 run 31167592775 green on `1478510f`) | -| L4 live-host | **NOT_RUN** on `96a32e8` (prior-candidate historical evidence only) | +| Highest proven | **L4 real-environment (built-MCP fixture)** (local RC pipeline at `96a32e8`: typecheck PASS; full unit suite 2202 tests → 2198 pass / 0 fail / 4 skipped; build PASS; test:pack 9 pkgs PASS; built Codex MCP e2e 16/16 incl. TE fs.write + why-effect; audit --prod clean; store delta re-verified at exact SHA) | +| Remote candidate | **pushed** to origin via worker-mac SSH relay (remote `rc/0.3.0-trusted-effects` == local HEAD; incl. `96a32e8`), but **no exact-SHA CI run exists** — `.github/workflows/ci.yml` triggers only on PR→main / push main, and no open PR has head `96a32e8` (Draft PR #117 head still stale `8778b2f`; historical green: run 31167592775 on `1478510f`) | +| L4 real-environment | **PASS (built-MCP fixture)** — built dist Codex MCP e2e 16/16 incl. TE fixture (`codex-mcp-full.log`, evidence SHA `6071fb2`); live `test:e2e-codex` NOT rerun on `96a32e8` (prior-candidate historical A→B→C only) | | Package versions on npm | still **0.2.8** (0.3.0 unpublished) | | Tag | **none** | ## Human checklist for L5 → L6 -1. Review scoreboard: `docs/internal/0.3.0-ga-scoreboard.md` (all 8 deliverables **pass**, L1/L2 **pass**, L4 **NOT_RUN** on current SHA). +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 @@ -24,7 +24,7 @@ pnpm run test:e2e-codex-mcp-full pnpm audit --prod ``` -3. **Push `rc/0.3.0-trusted-effects` at exact candidate SHA `96a32e8`** (requires GitHub auth — gh/SSH both fail on this host) and confirm exact-SHA remote CI green. +3. **Open/update the PR so CI fires on exact candidate SHA `96a32e8`** (branch is already pushed to origin via worker-mac SSH relay; `gh` not logged in on this host, and CI will not run on a branch push alone) and confirm exact-SHA remote CI green. 4. Bump workspace package versions to `0.3.0` (root + publishable packages) via existing release scripts (`RELEASE.md`). 5. Move CHANGELOG `## [0.3.0] — Unreleased` → dated release section. 6. Tag `v0.3.0` and publish **only with explicit human authority**. From 0f9cf164dc6f1a8561a1e8a73d6471f097cb57c8 Mon Sep 17 00:00:00 2001 From: Clawdy Date: Tue, 11 Aug 2026 11:52:45 +0800 Subject: [PATCH 11/38] docs: sync 0.3 draft PR body to rc tip d532f69 Update Base/Head to the real tip d532f69 (10 commits ahead of main), refresh the evidence table to match the scoreboard (L4 PASS built-MCP fixture), and note this PR itself is the exact-SHA remote CI gate. --- docs/internal/pr-0.3.0-trusted-effects.md | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/docs/internal/pr-0.3.0-trusted-effects.md b/docs/internal/pr-0.3.0-trusted-effects.md index e47a2a10..3854999a 100644 --- a/docs/internal/pr-0.3.0-trusted-effects.md +++ b/docs/internal/pr-0.3.0-trusted-effects.md @@ -1,17 +1,17 @@ # rc: 0.3.0 Trusted Effects candidate (do not publish) > Draft PR body. **Not for npm publish** — published packages stay at **0.2.8** until a human cuts `v0.3.0`. -> This file is the source for the GitHub PR description; copy it into the PR once `gh` auth is available. +> This file is the source for the GitHub PR description. ## Base / Head | | | |---|---| | Base | `main` @ `4b04e0d` — fix(pi): require explicit approval confirmation (#119) | -| Head | `rc/0.3.0-trusted-effects` @ `96a32e8` (6 commits ahead of main) | +| Head | `rc/0.3.0-trusted-effects` @ `d532f69` (10 commits ahead of main) | | Status | **DRAFT — do not merge, do not publish** | -This PR supersedes the historical Draft **PR #117** (`head: codex/0.3.0-trusted-effects-candidate`). Prefer opening this PR from the `rc/0.3.0-trusted-effects` branch, or update the existing draft with the clear SHA above. +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 `d532f69` is the exact head this PR is built from. ## Summary @@ -44,18 +44,22 @@ This candidate guarantees **admitted declared filesystem write targets**. It doe | Gate | Status | Notes | |---|---|---| -| L1 local | **PASS** | post-review candidate `6012695`: focused effects/resources/FlowIR suite 99/99; monorepo typecheck PASS | -| L2 contract | **PASS** | full unit suite 2198/2198; full build PASS | +| L1 local | **PASS** | `96a32e8`: monorepo typecheck PASS + `pnpm audit --prod` clean; store-discovery delta re-verified at exact SHA (focused store suite 69/69 + typecheck) | +| L2 contract | **PASS** | full unit suite 2202 tests → 2198 pass / 0 fail / 4 skipped; full build PASS; `test:pack` 9 packages PASS (pipeline logs built at evidence SHA `6071fb2`) | | L3 browser/electron | N/A | — | -| L4 real-environment | **NOT_RUN** | no live-host E2E on the current SHA; historical live Codex A→B→C + built Codex MCP 16/16 were on an earlier candidate | +| L4 real-environment | **PASS (built-MCP fixture)** | built Codex MCP comprehensive e2e 16/16 incl. TE fixture — `fs.write` committed through resources + ledger-backed why-effect — against the built dist bin (`codex-mcp-full.log`; evidence SHA `6071fb2` per build-info stamp). **NOT** a live Codex CLI run: `test:e2e-codex` NOT rerun on the current tip (prior-candidate A→B→C historical only) | | L5 released | **FAIL** | no tag/publish — human gate | | L6 ga | **FAIL** | L5 missing — **NOT GA** | -Remote CI: historical Draft PR #117 run `31167592775` passed all 10 matrix jobs + GitHub CodeQL on `1478510f` (earlier candidate SHA). **Exact-SHA remote CI and live-host E2E have not been rerun for `96a32e8`** (the rc head adds the store-discovery hardening with its own local store tests). +Exact-SHA remote CI is **open** and is exactly what this PR provides: `.github/workflows/ci.yml` triggers only on PR→main / push to main, and no PR has yet had head = current rc tip (`d532f69`). This Draft PR head is the exact-SHA gate; historical Draft PR #117 run `31167592775` passed all 10 matrix jobs + GitHub CodeQL on `1478510f` (earlier candidate SHA) — prior green is NOT exact-SHA for `d532f69`. ## Commits (rc/0.3.0-trusted-effects vs main, newest first) ``` +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 From e7c5e313b1cf80c221990e4e5d49d96ac1e11700 Mon Sep 17 00:00:00 2001 From: Clawdy Date: Tue, 11 Aug 2026 12:57:48 +0800 Subject: [PATCH 12/38] fix(test): make child-process store imports portable on Windows The process-boundary tests (M1 concurrent saveRun, L1 stale-steal, L1 live-owner, detached-runner e2e) embed an ESM import of store.ts built with path.resolve(), which yields a backslash path (D:\a\...) on Windows. ESM treats that as a bare package specifier and the child dies at startup, so saveRun/lock code was never exercised on windows-latest. Use pathToFileURL(...).href (already the repo convention in resource-leases.test.ts) so the specifier is a portable file:// URL. fix-5 also read ../src/store.ts via new URL(import.meta.url).pathname, which win32 path.join re-prefixes as D:\D:\a\... (ENOENT). Switch to fileURLToPath(import.meta.url). Local: store 69/69, detached 8/8, store-extended 23/23, full pnpm test 2198 pass / 0 fail, typecheck clean. --- packages/taskflow-core/test/detached.test.ts | 3 ++- packages/taskflow-core/test/store.test.ts | 19 +++++++++++++++---- 2 files changed, 17 insertions(+), 5 deletions(-) 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/store.test.ts b/packages/taskflow-core/test/store.test.ts index 29e86016..ec999aae 100644 --- a/packages/taskflow-core/test/store.test.ts +++ b/packages/taskflow-core/test/store.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 { fileURLToPath, pathToFileURL } from "node:url"; import type { Taskflow } from "../src/schema.ts"; import { getFlow, @@ -22,6 +23,16 @@ import { // 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-")); @@ -1344,7 +1355,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: [] }, @@ -1401,7 +1412,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: [] }, @@ -1440,7 +1451,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"); @@ -1580,7 +1591,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. From d3b2878da784447a7edd5ecd5bb209a41e25720d Mon Sep 17 00:00:00 2001 From: Clawdy Date: Tue, 11 Aug 2026 13:04:42 +0800 Subject: [PATCH 13/38] docs: refresh 0.3 scoreboard after exact-SHA CI green (NOT GA) --- docs/internal/0.3.0-ga-scoreboard.md | 53 +++++++++++++---------- docs/internal/0.3.0-release-plan.md | 10 ++--- docs/internal/pr-0.3.0-trusted-effects.md | 15 ++++--- 3 files changed, 43 insertions(+), 35 deletions(-) diff --git a/docs/internal/0.3.0-ga-scoreboard.md b/docs/internal/0.3.0-ga-scoreboard.md index feea4cd5..a0acb7d8 100644 --- a/docs/internal/0.3.0-ga-scoreboard.md +++ b/docs/internal/0.3.0-ga-scoreboard.md @@ -2,10 +2,10 @@ > Living ledger. **Do not claim GA** unless L6 is PASS with human tag evidence. -**Last updated:** 2026-08-10 (candidate `96a32e8`; RC pipeline evidence + exact-SHA store/typecheck re-verify) -**Branch:** `rc/0.3.0-trusted-effects` (tip now carries docs-only commits — scoreboard refresh `0834c59`, PR draft `7add160`, skills `59fd9c3` — on top of the code candidate; product code unchanged) -**Code candidate:** `96a32e8` (fix(store): harden project .pi discovery against tmp and home roots) -**Current evidence:** local RC pipeline green (logs `/tmp/taskflow-03-rc-final/`): full monorepo typecheck PASS; full unit suite 2202 tests → 2198 pass / 0 fail / 4 skipped; full build PASS; `test:pack` 9 packages PASS; built Codex MCP e2e 16/16; `pnpm audit --prod` clean. The pipeline logs were produced with HEAD at `6071fb2` (build-info stamp `6071fb2a3aed`); the `96a32e8` store-discovery hardening delta was re-verified at its exact SHA by focused store suite 69/69 + full typecheck (t_bf5ab0c1). These checks cover the post-review cache/composition, information-flow, malformed-admission, cleanup/lease, before-image GC, and store-discovery hardening fixes. Exact-SHA remote CI is **still open** — the branch **is pushed** to origin (via worker-mac SSH relay), but no CI run exists for it: `.github/workflows/ci.yml` triggers only on PR→main / push to main, and no open PR has head `96a32e8` (Draft PR #117 head is still the stale `8778b2f`); opening/updating that PR is the human gate. Live Codex CLI E2E has **not** been rerun for `96a32e8`; historical evidence only: Draft PR #117 CI run 31167592775 passed all 10 matrix jobs plus GitHub CodeQL on `1478510f`, and the prior candidate passed built Codex MCP 16/16 and a live Codex CLI A→B→C run. +**Last updated:** 2026-08-11 (candidate `e7c5e31`; exact-SHA CI green on PR #122) +**Branch:** `rc/0.3.0-trusted-effects` (tip `e7c5e31`; docs-only commits carry the evidence record — scoreboard refresh, PR body sync, skills — product code candidate `96a32e8` unchanged) +**Code candidate:** `e7c5e31` (fix(test): make child-process store imports portable on Windows — test-harness only, no product code change; the `96a32e8` store-discovery hardening stays the product candidate) +**Current evidence:** exact-SHA remote CI **GREEN** — GitHub Actions run 31460133496 on Draft PR #122 head `e7c5e31` (full matrix PASS: test node 22/24 typecheck + full unit suite; e2e codex MCP network-free incl. built-dist comprehensive `test:e2e-codex-mcp-full`; build dist; packed consumer 9 pkgs + CharterArc; website export; process supervisor ubuntu/macos/windows incl. `store.test.ts`; CodeQL JS/TS). Local at `e7c5e31` (t_6e11e70b): typecheck clean, full `pnpm test` 2198 pass / 0 fail / 4 skipped, store 69/69, detached 8/8, store-extended 23/23. The RC pipeline logs (`/tmp/taskflow-03-rc-final/`) remain at evidence SHA `6071fb2` for `pnpm audit --prod` clean and the built Codex MCP 16/16 fixture log (`codex-mcp-full.log`); CI now covers typecheck/test/build/pack/e2e at the exact SHA. These checks cover the post-review cache/composition, information-flow, malformed-admission, cleanup/lease, before-image GC, and store-discovery hardening fixes plus the Windows test-harness portability fix. Live Codex CLI E2E has **not** been rerun on the current tip: `test:e2e-codex` needs a live model session and stays manual (prior-candidate A→B→C historical only). --- @@ -13,15 +13,15 @@ | Level | Status | Evidence | Notes | |-------|--------|----------|-------| -| L1 local | **PASS** | `96a32e8`: monorepo typecheck PASS + audit --prod clean (RC pipeline logs; store delta re-verified at exact SHA) | RC pipeline green; typecheck re-run at exact SHA `96a32e8` | -| L2 contract | **PASS** | `96a32e8`: full unit suite 2202 tests → 2198 pass / 0 fail / 4 skipped + build PASS + test:pack 9 pkgs + built Codex MCP e2e 16/16 | pipeline logs built at evidence SHA `6071fb2`; exact-SHA remote CI not yet evidenced — branch pushed to origin but no run exists (CI triggers only on PR→main / push main; no PR head = `96a32e8`) | +| L1 local | **PASS** | `e7c5e31`: monorepo typecheck PASS (local + CI node 22/24) + audit --prod clean (RC pipeline logs at `6071fb2`; CI does not run audit) | typecheck re-run at exact SHA `e7c5e31`; store/runner-process suites green in CI on 3 OSes | +| L2 contract | **PASS** | `e7c5e31`: full unit suite 2198 pass / 0 fail / 4 skipped (local + CI node 22/24) + build PASS + test:pack 9 pkgs + built Codex MCP e2e 16/16 | exact-SHA remote CI GREEN — run 31460133496 on PR #122 head `e7c5e31`; RC pipeline logs at `6071fb2` supplement (audit clean) | | L3 browser/electron | **N/A** | — | | -| L4 real-environment | **PASS (built-MCP fixture)** | built Codex MCP comprehensive e2e 16/16 incl. TE fixture — `fs.write` committed through resources + ledger-backed why-effect — against the built dist bin (`codex-mcp-full.log`; evidence SHA `6071fb2` per build-info stamp) | fixture/network-free runner on the real built artifact, **NOT** a live Codex CLI run: `test:e2e-codex` NOT rerun on `96a32e8` (prior-candidate A→B→C historical only) | +| L4 real-environment | **PASS (built-MCP fixture)** | built Codex MCP comprehensive e2e 16/16 incl. TE fixture — `fs.write` committed through resources + ledger-backed why-effect — against the built dist bin (`codex-mcp-full.log`; evidence SHA `6071fb2` per build-info stamp; CI e2e job re-runs `test:e2e-codex-mcp-full` at exact SHA `e7c5e31`) | fixture/network-free runner on the real built artifact, **NOT** a live Codex CLI run: `test:e2e-codex` NOT rerun on current tip (prior-candidate A→B→C historical only) | | L5 released | **FAIL** | no tag/publish | human gate | | L6 ga | **FAIL** | L5 missing | **NOT GA** | -**Highest proven for the current code candidate:** **L4 real-environment (built-MCP fixture)** — the live-Codex-CLI leg of L4 and exact-SHA remote CI remain open. -**User-facing claim allowed:** *The post-review Trusted Effects candidate is green through the built-MCP real-environment fixture for admitted declared FS-write targets (RC pipeline at `96a32e8`: typecheck + 2202-test suite 2198/0/4 + build + pack + built Codex MCP 16/16 incl. TE fs.write + why-effect + audit clean; store delta re-verified at exact SHA); live Codex CLI run, exact-SHA remote CI, release, and GA gates remain open.* +**Highest proven for the current code candidate:** **L4 real-environment (built-MCP fixture)** with exact-SHA remote CI green — the live-Codex-CLI leg of L4 remains open. +**User-facing claim allowed:** *The post-review Trusted Effects candidate is green through the built-MCP real-environment fixture for admitted declared FS-write targets, with exact-SHA CI green (run 31460133496 on PR #122 head `e7c5e31`: test node 22/24, e2e codex MCP network-free incl. built-dist comprehensive, build dist, packed consumer 9 pkgs, website export, process supervisor 3 OSes, CodeQL; local `e7c5e31`: typecheck + 2198/0/4 full suite; RC pipeline at `6071fb2` adds audit clean + 16/16 built Codex MCP fixture); live Codex CLI run, release, and GA gates 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. --- @@ -51,6 +51,7 @@ | 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 @@ -61,34 +62,38 @@ The append-only resource journal still lacks a sharded/compacted projection desi ## 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 (re-run at exact SHA 96a32e8; t_bf5ab0c1) +# → PASS (local at exact SHA e7c5e31; t_6e11e70b) + CI test job node 22/24 pnpm test -# → 2202 tests: 2198 pass / 0 fail / 4 skipped (RC pipeline log; built at evidence SHA 6071fb2) +# → 2198 pass / 0 fail / 4 skipped (local at exact SHA e7c5e31; t_6e11e70b) + CI test job node 22/24 pnpm run build -# → PASS across core, CharterArc, MCP, hosts, DSL, and five delivery packages (RC pipeline log) +# → PASS — CI build job at exact SHA e7c5e31 (RC pipeline log at 6071fb2 also green) pnpm run test:pack -# → PASS 9 packages, 24 explicit imports, 80 wildcard exports, 5 bins (RC pipeline log) +# → 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; -# evidence SHA 6071fb2 per build-info stamp) +# 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) +# → no known vulnerabilities (RC pipeline log at 6071fb2; CI does not run audit) -# Focused store suite at exact SHA 96a32e8 (t_bf5ab0c1): -# node --conditions=development --experimental-strip-types --test packages/taskflow-core/test/store.test.ts -# → 69/69 pass -# Full store*.test.ts glob (store, store-extended, store-dogfood) → 100/100 pass (re-verified) +# 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) -# NOT RERUN on 96a32e8: -# pnpm test (full suite) / build / test:pack / test:e2e-codex-mcp-full — RC pipeline logs are at evidence SHA 6071fb2 (store delta re-verified at exact SHA) +# NOT RERUN on e7c5e31: # pnpm run test:e2e-codex (live Codex CLI; prior-candidate historical A→B→C PASS only) -# Exact-SHA remote CI: open — branch pushed to origin but CI has no run (workflow triggers -# only on PR→main / push main; no PR head = 96a32e8; human gate: open/update PR). +# 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 index 70ff5029..8b278b16 100644 --- a/docs/internal/0.3.0-release-plan.md +++ b/docs/internal/0.3.0-release-plan.md @@ -5,10 +5,10 @@ | Item | Value | |------|--------| | Branch | `rc/0.3.0-trusted-effects` | -| Code candidate | `96a32e8` (fix(store): harden project .pi discovery against tmp and home roots) | -| Highest proven | **L4 real-environment (built-MCP fixture)** (local RC pipeline at `96a32e8`: typecheck PASS; full unit suite 2202 tests → 2198 pass / 0 fail / 4 skipped; build PASS; test:pack 9 pkgs PASS; built Codex MCP e2e 16/16 incl. TE fs.write + why-effect; audit --prod clean; store delta re-verified at exact SHA) | -| Remote candidate | **pushed** to origin via worker-mac SSH relay (remote `rc/0.3.0-trusted-effects` == local HEAD; incl. `96a32e8`), but **no exact-SHA CI run exists** — `.github/workflows/ci.yml` triggers only on PR→main / push main, and no open PR has head `96a32e8` (Draft PR #117 head still stale `8778b2f`; historical green: run 31167592775 on `1478510f`) | -| L4 real-environment | **PASS (built-MCP fixture)** — built dist Codex MCP e2e 16/16 incl. TE fixture (`codex-mcp-full.log`, evidence SHA `6071fb2`); live `test:e2e-codex` NOT rerun on `96a32e8` (prior-candidate historical A→B→C only) | +| Code candidate | `e7c5e31` (fix(test): Windows child-process store import portability — test-harness only, no product code change; product candidate `96a32e8` store-discovery hardening unchanged) | +| Highest proven | **L4 real-environment (built-MCP fixture)** with **exact-SHA remote CI green** (run 31460133496 on PR #122 head `e7c5e31`: test node 22/24, e2e codex MCP network-free incl. built-dist full, build, packed consumer 9 pkgs, website export, process supervisor 3 OSes, CodeQL; local `e7c5e31` typecheck + 2198/0/4; RC pipeline at `6071fb2` adds audit clean + built Codex MCP 16/16) | +| Remote candidate | **GREEN exact-SHA CI** — Draft PR #122 head `e7c5e31`, run https://github.com/heggria/taskflow/actions/runs/31460133496 conclusion success, full matrix incl. windows process-supervisor `store.test.ts` PASS (prior red run 31456747148 on `0f9cf16` fixed by the Windows test-harness portability fix in `e7c5e31`) | +| L4 real-environment | **PASS (built-MCP fixture)** — built dist Codex MCP e2e 16/16 incl. TE fixture (`codex-mcp-full.log`, evidence SHA `6071fb2`); CI e2e job re-runs `test:e2e-codex-mcp-full` at exact SHA `e7c5e31`; live `test:e2e-codex` NOT rerun on current tip (prior-candidate historical A→B→C only) | | Package versions on npm | still **0.2.8** (0.3.0 unpublished) | | Tag | **none** | @@ -24,7 +24,7 @@ pnpm run test:e2e-codex-mcp-full pnpm audit --prod ``` -3. **Open/update the PR so CI fires on exact candidate SHA `96a32e8`** (branch is already pushed to origin via worker-mac SSH relay; `gh` not logged in on this host, and CI will not run on a branch push alone) and confirm exact-SHA remote CI green. +3. **Exact-SHA remote CI: DONE** — Draft PR #122 is open (head `rc/0.3.0-trusted-effects` @ `e7c5e31`) and run 31460133496 is green across the full matrix incl. windows process-supervisor (`gh` is authenticated on this host; the PR was opened/updated from `docs/internal/pr-0.3.0-trusted-effects.md`). If the rc tip moves again, re-verify the PR head before relying on this. 4. Bump workspace package versions to `0.3.0` (root + publishable packages) via existing release scripts (`RELEASE.md`). 5. Move CHANGELOG `## [0.3.0] — Unreleased` → dated release section. 6. Tag `v0.3.0` and publish **only with explicit human authority**. diff --git a/docs/internal/pr-0.3.0-trusted-effects.md b/docs/internal/pr-0.3.0-trusted-effects.md index 3854999a..f71a34b1 100644 --- a/docs/internal/pr-0.3.0-trusted-effects.md +++ b/docs/internal/pr-0.3.0-trusted-effects.md @@ -8,10 +8,10 @@ | | | |---|---| | Base | `main` @ `4b04e0d` — fix(pi): require explicit approval confirmation (#119) | -| Head | `rc/0.3.0-trusted-effects` @ `d532f69` (10 commits ahead of main) | +| Head | `rc/0.3.0-trusted-effects` @ `e7c5e31` (12 commits ahead of main) | | Status | **DRAFT — do not merge, do not publish** | -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 `d532f69` is the exact head this PR is built from. +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 `e7c5e31` is the exact head this PR is built from (Draft PR #122 — this PR). ## Summary @@ -29,6 +29,7 @@ This PR supersedes the historical Draft **PR #117** (`head: codex/0.3.0-trusted- - 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 @@ -44,18 +45,20 @@ This candidate guarantees **admitted declared filesystem write targets**. It doe | Gate | Status | Notes | |---|---|---| -| L1 local | **PASS** | `96a32e8`: monorepo typecheck PASS + `pnpm audit --prod` clean; store-discovery delta re-verified at exact SHA (focused store suite 69/69 + typecheck) | -| L2 contract | **PASS** | full unit suite 2202 tests → 2198 pass / 0 fail / 4 skipped; full build PASS; `test:pack` 9 packages PASS (pipeline logs built at evidence SHA `6071fb2`) | +| L1 local | **PASS** | `e7c5e31`: monorepo typecheck PASS (local + CI node 22/24); `pnpm audit --prod` clean (RC pipeline at `6071fb2` — CI does not run audit) | +| L2 contract | **PASS** | full unit suite 2198 pass / 0 fail / 4 skipped (local + CI node 22/24); full build PASS (CI build job); `test:pack` 9 packages + CharterArc PASS (CI packed-consumer) at exact SHA `e7c5e31` | | L3 browser/electron | N/A | — | -| L4 real-environment | **PASS (built-MCP fixture)** | built Codex MCP comprehensive e2e 16/16 incl. TE fixture — `fs.write` committed through resources + ledger-backed why-effect — against the built dist bin (`codex-mcp-full.log`; evidence SHA `6071fb2` per build-info stamp). **NOT** a live Codex CLI run: `test:e2e-codex` NOT rerun on the current tip (prior-candidate A→B→C historical only) | +| L4 real-environment | **PASS (built-MCP fixture)** | built Codex MCP comprehensive e2e 16/16 incl. TE fixture — `fs.write` committed through resources + ledger-backed why-effect — against the built dist bin (`codex-mcp-full.log`; evidence SHA `6071fb2` per build-info stamp; CI e2e job re-runs `test:e2e-codex-mcp-full` at exact SHA `e7c5e31`). **NOT** a live Codex CLI run: `test:e2e-codex` NOT rerun on the current tip (prior-candidate A→B→C historical only) | | L5 released | **FAIL** | no tag/publish — human gate | | L6 ga | **FAIL** | L5 missing — **NOT GA** | -Exact-SHA remote CI is **open** and is exactly what this PR provides: `.github/workflows/ci.yml` triggers only on PR→main / push to main, and no PR has yet had head = current rc tip (`d532f69`). This Draft PR head is the exact-SHA gate; historical Draft PR #117 run `31167592775` passed all 10 matrix jobs + GitHub CodeQL on `1478510f` (earlier candidate SHA) — prior green is NOT exact-SHA for `d532f69`. +Exact-SHA remote CI is **GREEN**: GitHub Actions run 31460133496 on this PR's head `e7c5e31` passed the full matrix (test node 22/24, e2e codex MCP network-free incl. built-dist comprehensive, build dist, packed consumer 9 pkgs + CharterArc, website export, process supervisor ubuntu/macos/windows, CodeQL JS/TS). A prior red run 31456747148 on `0f9cf16` (windows store tests) was fixed by the test-harness portability change in `e7c5e31`. Historical Draft PR #117 run `31167592775` passed all jobs on `1478510f` (earlier candidate SHA). ## 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 From 13521933e7d72a3dec7f5a856816f810390e4b13 Mon Sep 17 00:00:00 2001 From: Clawdy Date: Tue, 11 Aug 2026 13:40:32 +0800 Subject: [PATCH 14/38] docs(review): add ADV-R4 honesty review artifact Claim-vs-evidence audit of scoreboard/PR body/CHANGELOG/skills/host baseline vs exact-SHA CI + local test run at tip d3b2878. 0 Blocker / 0 High / 5 Low (docs-currency only): stale tip/head citations (31460495091 @ d3b2878 not cited), live PR #122 description lagging head, AGENTS.md MCP roster 19->20, audit-evidence phrasing. Ready=HOLD. --- docs/internal/reviews/0.3-adv-r4-honesty.md | 46 +++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 docs/internal/reviews/0.3-adv-r4-honesty.md 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.** From 10c8a0faef1cc69b4e59bd9b4d6bb446cc55711d Mon Sep 17 00:00:00 2001 From: Clawdy Date: Tue, 11 Aug 2026 13:43:01 +0800 Subject: [PATCH 15/38] docs: ADV-R1 contracts review findings for 0.3 trusted-effects rc --- docs/internal/reviews/0.3-adv-r1-contracts.md | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 docs/internal/reviews/0.3-adv-r1-contracts.md 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. From 7b8bfb828c7932f7f9defb6df18c336b27d8b616 Mon Sep 17 00:00:00 2001 From: Clawdy Date: Tue, 11 Aug 2026 13:44:43 +0800 Subject: [PATCH 16/38] docs: ADV-R2 resources review verdict (no blockers; 1 MED + 4 LOW) --- ...adversarial-review-r2-resources-verdict.md | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 docs/internal/reviews/adversarial-review-r2-resources-verdict.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. From 01ab73f4d2dbe1d61be4b1d575b516c9d2e421f5 Mon Sep 17 00:00:00 2001 From: Clawdy Date: Tue, 11 Aug 2026 13:50:03 +0800 Subject: [PATCH 17/38] docs: ADV-R3 security review findings for 0.3 trusted-effects rc --- docs/internal/reviews/0.3-adv-r3-security.md | 53 ++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 docs/internal/reviews/0.3-adv-r3-security.md 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). From d824aef19b59f2c01343363db5a985f405200e7c Mon Sep 17 00:00:00 2001 From: Clawdy Date: Tue, 11 Aug 2026 13:52:16 +0800 Subject: [PATCH 18/38] docs: ADV-SYNTH multi-agent cross review of 0.3 TE RC Hold Ready: High verifier walk RCE surface + static flow{use} over-taint; resources MVP holds; honesty ledger clean. No merge/tag/npm. --- docs/internal/reviews/0.3-adv-synth.md | 124 +++++++++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 docs/internal/reviews/0.3-adv-synth.md 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..d4adc79a --- /dev/null +++ b/docs/internal/reviews/0.3-adv-synth.md @@ -0,0 +1,124 @@ +# 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. From 93f80d382a473fc1af84d2738917d4dd53359c44 Mon Sep 17 00:00:00 2001 From: Clawdy Date: Tue, 11 Aug 2026 13:53:23 +0800 Subject: [PATCH 19/38] docs: ADV-SYNTH addendum from chat parallel subagents Elevate concurrent commit race, stage effectId path, and commit-path symlink TOCTOU after source spot-check. HOLD Ready strengthened. --- docs/internal/reviews/0.3-adv-synth.md | 46 ++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/docs/internal/reviews/0.3-adv-synth.md b/docs/internal/reviews/0.3-adv-synth.md index d4adc79a..ba123eaf 100644 --- a/docs/internal/reviews/0.3-adv-synth.md +++ b/docs/internal/reviews/0.3-adv-synth.md @@ -122,3 +122,49 @@ Artifacts: 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` + From cbb4131545fe3d7c2055c945d62ca4b7e2707611 Mon Sep 17 00:00:00 2001 From: Clawdy Date: Tue, 11 Aug 2026 14:05:00 +0800 Subject: [PATCH 20/38] fix(security): harden .pi discovery walks and file-transaction commit path Address ADV High findings before Ready: - Shared discovery-boundary for project .pi walks (home/temp stop, canonicalize, reject .pi symlink escape) used by store, verifiers, agents - file-transaction: busy lock against concurrent commit/reject - sanitize effectId for stage blob paths - re-check containment + refuse intermediate symlinks at commit - mkdir parents without following symlinks - swallow onDeferredLeaseRelease throws after terminal so ok:true sticks Tests: adv-high-fix (7) + resource-file-transaction + store + verifier-discover --- packages/taskflow-core/src/agents.ts | 11 +- .../taskflow-core/src/discovery-boundary.ts | 113 +++++++++ .../src/resources/file-transaction.ts | 152 +++++++++++- packages/taskflow-core/src/store.ts | 37 +-- .../taskflow-core/src/verifiers/discover.ts | 17 +- .../taskflow-core/test/adv-high-fix.test.ts | 225 ++++++++++++++++++ 6 files changed, 494 insertions(+), 61 deletions(-) create mode 100644 packages/taskflow-core/src/discovery-boundary.ts create mode 100644 packages/taskflow-core/test/adv-high-fix.test.ts 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/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/resources/file-transaction.ts b/packages/taskflow-core/src/resources/file-transaction.ts index c4d7eaa7..452e2d6b 100644 --- a/packages/taskflow-core/src/resources/file-transaction.ts +++ b/packages/taskflow-core/src/resources/file-transaction.ts @@ -98,6 +98,106 @@ function isWithin(root: string, candidate: string): boolean { 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) { @@ -144,9 +244,10 @@ function writeBufferDurable(filePath: string, content: Buffer, mode = 0o600): vo fsyncDirectory(path.dirname(filePath)); } -function replaceFileAtomic(filePath: string, content: Buffer, mode = 0o600): void { +function replaceFileAtomic(filePath: string, content: Buffer, mode = 0o600, authorizationRoot?: string): void { const parent = path.dirname(filePath); - ensureDirectory(parent); + 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); @@ -448,6 +549,8 @@ export class PreparedResourceFileTransaction { readonly #onDeferredLeaseRelease?: (lease: LeaseHandle) => void; readonly #cleanupStaging: (stagingDirectory: string) => void; #settled = false; + #busy = false; + readonly #authorizationScopeRoot: string; #knownCleanTerminal = false; constructor(input: { @@ -457,6 +560,7 @@ export class PreparedResourceFileTransaction { journal: WriteIntentJournal; stagingDirectory: string; onDeferredLeaseRelease?: (lease: LeaseHandle) => void; + authorizationScopeRoot: string; cleanupStaging?: (stagingDirectory: string) => void; }) { this.intentId = input.mutation.intent.intentId; @@ -466,6 +570,7 @@ export class PreparedResourceFileTransaction { 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 }); }); @@ -473,12 +578,22 @@ export class PreparedResourceFileTransaction { 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", @@ -491,7 +606,7 @@ export class PreparedResourceFileTransaction { 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 = path.join(this.#stagingDirectory, "staged", `${snapshot.effectId}-${crypto.randomUUID()}.blob`); + const stagedPath = stageBlobPath(this.#stagingDirectory, snapshot.effectId); writeBufferDurable(stagedPath, bytes); expected.set(snapshot.effectId, contentId(bytes)); } @@ -499,7 +614,7 @@ export class PreparedResourceFileTransaction { 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); + replaceFileAtomic(snapshot.physicalPath, bytes, snapshot.mode ?? 0o600, this.#authorizationScopeRoot); } const evidence = this.#snapshots.map((snapshot) => ({ canonicalPrefix: snapshot.physicalPath, @@ -532,10 +647,15 @@ export class PreparedResourceFileTransaction { async reject(reason: string): Promise { this.#assertOpen(); + this.#enterBusy(); try { - return await this.#rejectAndRestore("commit-rejected", reason || "phase rejected"); + try { + return await this.#rejectAndRestore("commit-rejected", reason || "phase rejected"); + } finally { + await this.#finish(); + } } finally { - await this.#finish(); + this.#leaveBusy(); } } @@ -586,6 +706,15 @@ export class PreparedResourceFileTransaction { 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 { @@ -601,7 +730,14 @@ export class PreparedResourceFileTransaction { } } finally { if (!(await releaseBestEffort(this.#lease))) { - this.#onDeferredLeaseRelease?.(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}`); } } @@ -616,6 +752,7 @@ export async function prepareResourceFileTransaction( 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`); } @@ -711,6 +848,7 @@ export async function prepareResourceFileTransaction( journal: options.journal, stagingDirectory: transactionDirectory, onDeferredLeaseRelease: options.onDeferredLeaseRelease, + authorizationScopeRoot: options.authorizationScopeRoot, cleanupStaging: options.cleanupStaging, }); } catch (error) { diff --git a/packages/taskflow-core/src/store.ts b/packages/taskflow-core/src/store.ts index baf4bdd8..1a3c1ee1 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 } from "./discovery-boundary.ts"; export interface SavedFlow { name: string; @@ -1016,39 +1016,16 @@ 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"); } /** 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/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"); +}); From 77abef1a441bb35afb270d5b341f1709ee3cb2a7 Mon Sep 17 00:00:00 2001 From: Clawdy Date: Tue, 11 Aug 2026 14:09:45 +0800 Subject: [PATCH 21/38] docs: note ADV High fix wave cbb4131 CI green; S-H2 still open --- docs/internal/reviews/0.3-adv-synth.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/docs/internal/reviews/0.3-adv-synth.md b/docs/internal/reviews/0.3-adv-synth.md index ba123eaf..9388c4db 100644 --- a/docs/internal/reviews/0.3-adv-synth.md +++ b/docs/internal/reviews/0.3-adv-synth.md @@ -168,3 +168,22 @@ Kanban honesty lane still clean on L5/L6/live-E2E wording; **resource write cont 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 | **still open** (product/UX) | + +Ready: still **HOLD** until S-H2 decision + optional second ADV pass; no merge/tag/npm. From 580daa06241759d3edb65a017aabd19f50bf39a9 Mon Sep 17 00:00:00 2001 From: Clawdy Date: Tue, 11 Aug 2026 14:33:39 +0800 Subject: [PATCH 22/38] fix(effects): downgrade unresolved flow{use} taint at static gates (S-H2) Static gates (validateTaskflow / verifyTaskflow / FlowIR translate+compile) run without a flow store, so every flow{use: } child degraded to the unknown-boundary summary and rejected legal saved-subflow + downstream write compositions that the runtime (with loadFlow) executes successfully. - validateComposedEffectFlow gains downgradeUnresolvedUse: unresolved flow{use} boundaries now report advisory warnings (unresolved-flow-use / unresolved-flow-use-taint) instead of hard confidentiality taint; dynamic inline flow{def} boundaries stay hard-tainted. - Static gates accept an optional resolveFlow: pi run + MCP taskflow_run inject the store-backed loader so saved children are checked with real effects (real violations still hard-fail); runtime admission remains the authoritative fail-closed gate. - New test effects-static-use-downgrade (4): benign use + write accepted statically and at runtime; real violation advisory without loader but hard-fails with resolver and at runtime; dynamic def stays tainted; loader-miss is advisory statically and fails closed at runtime. --- packages/pi-taskflow/src/index.ts | 5 +- .../taskflow-core/src/effects/validate.ts | 64 ++++- packages/taskflow-core/src/flowir/compile.ts | 6 +- .../taskflow-core/src/flowir/translate.ts | 6 +- packages/taskflow-core/src/schema.ts | 15 +- .../src/verifiers/effects-lint.ts | 21 +- packages/taskflow-core/src/verify.ts | 8 +- .../test/effects-static-use-downgrade.test.ts | 250 ++++++++++++++++++ packages/taskflow-mcp-core/src/mcp/server.ts | 15 +- 9 files changed, 371 insertions(+), 19 deletions(-) create mode 100644 packages/taskflow-core/test/effects-static-use-downgrade.test.ts diff --git a/packages/pi-taskflow/src/index.ts b/packages/pi-taskflow/src/index.ts index 2ecd7d01..24dc1f01 100644 --- a/packages/pi-taskflow/src/index.ts +++ b/packages/pi-taskflow/src/index.ts @@ -1404,7 +1404,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/taskflow-core/src/effects/validate.ts b/packages/taskflow-core/src/effects/validate.ts index 29f83408..63e62a7a 100644 --- a/packages/taskflow-core/src/effects/validate.ts +++ b/packages/taskflow-core/src/effects/validate.ts @@ -76,23 +76,30 @@ function labelFlowIssues( // 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: "error", - code: "confidentiality-flow-violation", - message: `source effect '${sourceLabel}' (${sourceConf}) cannot flow to sink '${sinkLabel}' (${sinkConf})`, + 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: "error", - code: "integrity-flow-violation", - message: `source effect '${sourceLabel}' (${sourceIntegrity}) cannot satisfy sink '${sinkLabel}' integrity (${sinkIntegrity})`, + severity: advisory ? "warning" : "error", + code: code("integrity-flow-violation"), + message: `source effect '${sourceLabel}' (${sourceIntegrity}) cannot satisfy sink '${sinkLabel}' integrity (${sinkIntegrity})${suffix}`, effectId: sinkLabel, }); } @@ -389,6 +396,21 @@ interface PhaseEffectSummary { 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 { @@ -404,7 +426,8 @@ function directPhaseSummary(phase: EffectFlowPhaseLike, phaseLabel: string): Pha return { sources, sinks }; } -function unknownBoundarySummary(phaseLabel: string): PhaseEffectSummary { +function unknownBoundarySummary(phaseLabel: string, advisory = false): PhaseEffectSummary { + const advisoryFlag = advisory ? { __unresolvedUseAdvisory: true } : {}; return { sources: [{ label: `${phaseLabel}/`, @@ -414,6 +437,7 @@ function unknownBoundarySummary(phaseLabel: string): PhaseEffectSummary { confidentiality: "secret", integrity: "untrusted", __unknownBoundary: true, + ...advisoryFlag, target: { kind: "secret", secret: { secretId: "" } }, }, }], @@ -425,6 +449,7 @@ function unknownBoundarySummary(phaseLabel: string): PhaseEffectSummary { confidentiality: "public", integrity: "verified", __unknownBoundary: true, + ...advisoryFlag, target: { kind: "service", service: { serviceId: "" } }, }, }], @@ -513,6 +538,9 @@ function summarizeComposedEffectFlow( 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}`; @@ -530,6 +558,10 @@ function summarizeComposedEffectFlow( 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) { @@ -541,10 +573,23 @@ function summarizeComposedEffectFlow( } 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) }; + : { 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); @@ -568,6 +613,9 @@ function summarizeComposedEffectFlow( * 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, diff --git a/packages/taskflow-core/src/flowir/compile.ts b/packages/taskflow-core/src/flowir/compile.ts index 1d9964e7..7f6e097a 100644 --- a/packages/taskflow-core/src/flowir/compile.ts +++ b/packages/taskflow-core/src/flowir/compile.ts @@ -253,7 +253,11 @@ export function compileTaskflowToFlowIR(def: Taskflow): CompileTaskflowToFlowIRR nodes.push(node); } - const effectFlow = validateComposedEffectFlow({ name: def.name, phases: def.phases ?? [] }); + 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") { diff --git a/packages/taskflow-core/src/flowir/translate.ts b/packages/taskflow-core/src/flowir/translate.ts index 3f2a5956..f931b7aa 100644 --- a/packages/taskflow-core/src/flowir/translate.ts +++ b/packages/taskflow-core/src/flowir/translate.ts @@ -185,7 +185,11 @@ export function translateTaskflow(def: Taskflow): { } satisfies FlowIRNode; }); - const effectFlow = validateComposedEffectFlow({ name: def.name, phases: def.phases }); + 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") { diff --git a/packages/taskflow-core/src/schema.ts b/packages/taskflow-core/src/schema.ts index dd9dfd7c..12900e9c 100644 --- a/packages/taskflow-core/src/schema.ts +++ b/packages/taskflow-core/src/schema.ts @@ -14,7 +14,7 @@ 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 } from "./effects/validate.ts"; +import { validateComposedEffectFlow, type ComposedEffectFlowLike } from "./effects/validate.ts"; // --------------------------------------------------------------------------- // Phase types @@ -778,6 +778,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 & { @@ -1536,7 +1541,13 @@ export function validateTaskflow(def: unknown, opts: ValidationOptions = {}): Va // Cycle detection (Kahn) try { - const labelFlow = validateComposedEffectFlow({ name: flow.name, phases: flow.phases as Phase[] }); + 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); diff --git a/packages/taskflow-core/src/verifiers/effects-lint.ts b/packages/taskflow-core/src/verifiers/effects-lint.ts index 86306af0..52859407 100644 --- a/packages/taskflow-core/src/verifiers/effects-lint.ts +++ b/packages/taskflow-core/src/verifiers/effects-lint.ts @@ -10,7 +10,7 @@ */ import type { Phase } from "../schema.ts"; -import { validateComposedEffectFlow, validateEffectIR } from "../effects/validate.ts"; +import { validateComposedEffectFlow, validateEffectIR, type ComposedEffectFlowLike } from "../effects/validate.ts"; import type { TaskflowVerifier, VerifiableFlow, @@ -18,6 +18,15 @@ import type { 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 @@ -25,7 +34,7 @@ import type { * * Pure — no I/O. Empty effects → empty array (does not fail verify). */ -export function detectEffectsIssues(flow: VerifiableFlow): VerificationIssue[] { +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) { @@ -37,7 +46,13 @@ export function detectEffectsIssues(flow: VerifiableFlow): VerificationIssue[] { } } - const flowResult = validateComposedEffectFlow({ phases: phases as Phase[] }); + 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) { diff --git a/packages/taskflow-core/src/verify.ts b/packages/taskflow-core/src/verify.ts index 439e75b4..68e138e6 100644 --- a/packages/taskflow-core/src/verify.ts +++ b/packages/taskflow-core/src/verify.ts @@ -13,6 +13,7 @@ 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 @@ -87,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; } // --------------------------------------------------------------------------- @@ -641,7 +647,7 @@ export function verifyTaskflow(flow: VerifiableFlow, options?: VerifyOptions): V issues.push(...detectGuardContradictions(phases)); issues.push(...detectContractRefMismatches(phases)); // Trusted Effects (0.3): static EffectIR checks when a phase carries effects[] - issues.push(...detectEffectsIssues(safeFlow)); + 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/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-mcp-core/src/mcp/server.ts b/packages/taskflow-mcp-core/src/mcp/server.ts index 1f45c473..4d348801 100644 --- a/packages/taskflow-mcp-core/src/mcp/server.ts +++ b/packages/taskflow-mcp-core/src/mcp/server.ts @@ -793,6 +793,16 @@ function resolveFlow(cwd: string, params: { name?: string; define?: unknown; def 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; + }; +} + /** Optional host-identity options for the MCP server (0.2.0 dogfood issue 4). * `host` is the bound host identity (codex/claude/opencode/grok); it is * stamped onto RunState.host and reported by `taskflow_version`. Defaults to @@ -863,13 +873,14 @@ export function makeToolHandlers( ? args.name.trim() : undefined; const def = resolveFlow(cwd, args); - 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") { From e8a027c9e69076cf59da5a330fb0da383687ce60 Mon Sep 17 00:00:00 2001 From: Clawdy Date: Tue, 11 Aug 2026 14:34:11 +0800 Subject: [PATCH 23/38] docs: mark S-H2 static flow{use} over-taint fixed at 580daa0 --- docs/internal/reviews/0.3-adv-synth.md | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/docs/internal/reviews/0.3-adv-synth.md b/docs/internal/reviews/0.3-adv-synth.md index 9388c4db..96b152d4 100644 --- a/docs/internal/reviews/0.3-adv-synth.md +++ b/docs/internal/reviews/0.3-adv-synth.md @@ -184,6 +184,24 @@ Kanban honesty lane still clean on L5/L6/live-E2E wording; **resource write cont | 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 | **still open** (product/UX) | +| 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** | From 0551f62a86395963705df4b45da4c6ce8ebe536c Mon Sep 17 00:00:00 2001 From: Clawdy Date: Tue, 11 Aug 2026 14:46:41 +0800 Subject: [PATCH 24/38] docs: residual recheck + Ready checklist at e8a027c (NOT GA) Clawdy handoff after orch residual crash. Focused 110/110, CI 31465576937 green. Sync scoreboard/PR body tip. No merge/tag/npm. --- docs/internal/0.3.0-ga-scoreboard.md | 18 +++--- docs/internal/pr-0.3.0-trusted-effects.md | 5 +- docs/internal/reviews/0.3-ready-checklist.md | 58 +++++++++++++++++++ docs/internal/reviews/0.3-residual-recheck.md | 35 +++++++++++ 4 files changed, 105 insertions(+), 11 deletions(-) create mode 100644 docs/internal/reviews/0.3-ready-checklist.md create mode 100644 docs/internal/reviews/0.3-residual-recheck.md diff --git a/docs/internal/0.3.0-ga-scoreboard.md b/docs/internal/0.3.0-ga-scoreboard.md index a0acb7d8..65f055e5 100644 --- a/docs/internal/0.3.0-ga-scoreboard.md +++ b/docs/internal/0.3.0-ga-scoreboard.md @@ -2,10 +2,10 @@ > Living ledger. **Do not claim GA** unless L6 is PASS with human tag evidence. -**Last updated:** 2026-08-11 (candidate `e7c5e31`; exact-SHA CI green on PR #122) -**Branch:** `rc/0.3.0-trusted-effects` (tip `e7c5e31`; docs-only commits carry the evidence record — scoreboard refresh, PR body sync, skills — product code candidate `96a32e8` unchanged) -**Code candidate:** `e7c5e31` (fix(test): make child-process store imports portable on Windows — test-harness only, no product code change; the `96a32e8` store-discovery hardening stays the product candidate) -**Current evidence:** exact-SHA remote CI **GREEN** — GitHub Actions run 31460133496 on Draft PR #122 head `e7c5e31` (full matrix PASS: test node 22/24 typecheck + full unit suite; e2e codex MCP network-free incl. built-dist comprehensive `test:e2e-codex-mcp-full`; build dist; packed consumer 9 pkgs + CharterArc; website export; process supervisor ubuntu/macos/windows incl. `store.test.ts`; CodeQL JS/TS). Local at `e7c5e31` (t_6e11e70b): typecheck clean, full `pnpm test` 2198 pass / 0 fail / 4 skipped, store 69/69, detached 8/8, store-extended 23/23. The RC pipeline logs (`/tmp/taskflow-03-rc-final/`) remain at evidence SHA `6071fb2` for `pnpm audit --prod` clean and the built Codex MCP 16/16 fixture log (`codex-mcp-full.log`); CI now covers typecheck/test/build/pack/e2e at the exact SHA. These checks cover the post-review cache/composition, information-flow, malformed-admission, cleanup/lease, before-image GC, and store-discovery hardening fixes plus the Windows test-harness portability fix. Live Codex CLI E2E has **not** been rerun on the current tip: `test:e2e-codex` needs a live model session and stays manual (prior-candidate A→B→C historical only). +**Last updated:** 2026-08-11 (tip `e8a027c`; residual recheck + S-H2 closed) +**Branch:** `rc/0.3.0-trusted-effects` @ `e8a027c` +**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 [31465576937](https://github.com/heggria/taskflow/actions/runs/31465576937) on Draft PR #122 head `e8a027c` (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 **not** rerun on tip. L5/L6 remain FAIL (no tag/publish). --- @@ -13,15 +13,15 @@ | Level | Status | Evidence | Notes | |-------|--------|----------|-------| -| L1 local | **PASS** | `e7c5e31`: monorepo typecheck PASS (local + CI node 22/24) + audit --prod clean (RC pipeline logs at `6071fb2`; CI does not run audit) | typecheck re-run at exact SHA `e7c5e31`; store/runner-process suites green in CI on 3 OSes | -| L2 contract | **PASS** | `e7c5e31`: full unit suite 2198 pass / 0 fail / 4 skipped (local + CI node 22/24) + build PASS + test:pack 9 pkgs + built Codex MCP e2e 16/16 | exact-SHA remote CI GREEN — run 31460133496 on PR #122 head `e7c5e31`; RC pipeline logs at `6071fb2` supplement (audit clean) | +| L1 local | **PASS** | tip `e8a027c`: 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 `e8a027c`: CI full unit + build + pack + MCP e2e; residual focused green | exact-SHA CI GREEN run 31465576937 | | L3 browser/electron | **N/A** | — | | -| L4 real-environment | **PASS (built-MCP fixture)** | built Codex MCP comprehensive e2e 16/16 incl. TE fixture — `fs.write` committed through resources + ledger-backed why-effect — against the built dist bin (`codex-mcp-full.log`; evidence SHA `6071fb2` per build-info stamp; CI e2e job re-runs `test:e2e-codex-mcp-full` at exact SHA `e7c5e31`) | fixture/network-free runner on the real built artifact, **NOT** a live Codex CLI run: `test:e2e-codex` NOT rerun on current tip (prior-candidate A→B→C historical only) | +| L4 real-environment | **PASS (built-MCP fixture)** | built Codex MCP e2e in CI at tip + historical 16/16 fixture log @ `6071fb2` | **NOT** live Codex CLI; `test:e2e-codex` open | | L5 released | **FAIL** | no 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 — the live-Codex-CLI leg of L4 remains open. -**User-facing claim allowed:** *The post-review Trusted Effects candidate is green through the built-MCP real-environment fixture for admitted declared FS-write targets, with exact-SHA CI green (run 31460133496 on PR #122 head `e7c5e31`: test node 22/24, e2e codex MCP network-free incl. built-dist comprehensive, build dist, packed consumer 9 pkgs, website export, process supervisor 3 OSes, CodeQL; local `e7c5e31`: typecheck + 2198/0/4 full suite; RC pipeline at `6071fb2` adds audit clean + 16/16 built Codex MCP fixture); live Codex CLI run, release, and GA gates remain open.* +**Highest proven for the current code candidate:** **L4 real-environment (built-MCP fixture)** with exact-SHA remote CI green on tip `e8a027c` — live-Codex-CLI leg of L4 remains open. ADV High + S-H2 closed; Ready/merge/tag are human gates. +**User-facing claim allowed:** *Trusted Effects RC tip `e8a027c` is green on exact-SHA CI (run 31465576937) with TE + security hardenings (discovery walk, file-transaction commit path, S-H2 static flow{use}); live Codex CLI, 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. --- diff --git a/docs/internal/pr-0.3.0-trusted-effects.md b/docs/internal/pr-0.3.0-trusted-effects.md index f71a34b1..ee7a8200 100644 --- a/docs/internal/pr-0.3.0-trusted-effects.md +++ b/docs/internal/pr-0.3.0-trusted-effects.md @@ -8,10 +8,11 @@ | | | |---|---| | Base | `main` @ `4b04e0d` — fix(pi): require explicit approval confirmation (#119) | -| Head | `rc/0.3.0-trusted-effects` @ `e7c5e31` (12 commits ahead of main) | +| Head | `rc/0.3.0-trusted-effects` @ `e8a027c` (23 commits ahead of main) | | Status | **DRAFT — do not merge, do not publish** | +| Post-ADV harden | `cbb4131` discovery/file-tx + `580daa0` S-H2; CI 31465576937 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 `e7c5e31` is the exact head this PR is built from (Draft PR #122 — this PR). +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 `e8a027c` is the exact head this PR is built from (Draft PR #122 — this PR). ## Summary 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..6e80b0c7 --- /dev/null +++ b/docs/internal/reviews/0.3-ready-checklist.md @@ -0,0 +1,58 @@ +# 0.3 Trusted Effects — Ready checklist (human handoff) + +**Branch tip:** `e8a027c` on `rc/0.3.0-trusted-effects` +**PR:** https://github.com/heggria/taskflow/pull/122 (**DRAFT** — do not auto-merge) +**Exact-SHA CI:** [31465576937](https://github.com/heggria/taskflow/actions/runs/31465576937) **GREEN** (full matrix) +**Base:** `main` @ `4b04e0d` · **~23 commits** ahead + +> **NOT GA.** L5/L6 FAIL until human tag + npm. Agents must not merge/tag/publish. + +--- + +## What is green (agent-verified) + +| Item | Evidence | +|------|----------| +| Product TE + post-review harden | history includes `e6efcd4` … `467bc28`, store `96a32e8` | +| Windows store test harness | `e7c5e31` | +| 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 31465576937 success | +| Honesty | L5/L6 FAIL; live Codex E2E open; 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 +- [ ] Decide whether live `test:e2e-codex` is required for *your* bar (still open) +- [ ] 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 per `RELEASE.md` +4. **Tag** `v0.3.0` + **npm publish** with explicit authority +5. Update scoreboard L5/L6 with tag URL + publish receipt + +## 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..678db3f1 --- /dev/null +++ b/docs/internal/reviews/0.3-residual-recheck.md @@ -0,0 +1,35 @@ +# Residual ADV recheck after S-H2 (Clawdy handoff) + +**Date:** 2026-08-11 +**Tip:** `e8a027c` (`e8a027c9e69076cf59da5a330fb0da383687ce60`) +**PR:** #122 Draft +**CI:** [31465576937](https://github.com/heggria/taskflow/actions/runs/31465576937) **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 `e8a027c` with CI green. Human gates (Ready/merge/tag/npm) unchanged. From 4524d2d3be1cf670f7e0300ccd2903acc37fc9b6 Mon Sep 17 00:00:00 2001 From: Clawdy Date: Tue, 11 Aug 2026 14:49:24 +0800 Subject: [PATCH 25/38] docs: point residual handoff tip/CI to 0551f62 / 31466355338 --- docs/internal/0.3.0-ga-scoreboard.md | 14 +++++++------- docs/internal/pr-0.3.0-trusted-effects.md | 6 +++--- docs/internal/reviews/0.3-ready-checklist.md | 6 +++--- docs/internal/reviews/0.3-residual-recheck.md | 6 +++--- 4 files changed, 16 insertions(+), 16 deletions(-) diff --git a/docs/internal/0.3.0-ga-scoreboard.md b/docs/internal/0.3.0-ga-scoreboard.md index 65f055e5..d6adb2e0 100644 --- a/docs/internal/0.3.0-ga-scoreboard.md +++ b/docs/internal/0.3.0-ga-scoreboard.md @@ -2,10 +2,10 @@ > Living ledger. **Do not claim GA** unless L6 is PASS with human tag evidence. -**Last updated:** 2026-08-11 (tip `e8a027c`; residual recheck + S-H2 closed) -**Branch:** `rc/0.3.0-trusted-effects` @ `e8a027c` +**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 [31465576937](https://github.com/heggria/taskflow/actions/runs/31465576937) on Draft PR #122 head `e8a027c` (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 **not** rerun on tip. L5/L6 remain FAIL (no tag/publish). +**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 **not** rerun on tip. L5/L6 remain FAIL (no tag/publish). --- @@ -13,15 +13,15 @@ | Level | Status | Evidence | Notes | |-------|--------|----------|-------| -| L1 local | **PASS** | tip `e8a027c`: 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 `e8a027c`: CI full unit + build + pack + MCP e2e; residual focused green | exact-SHA CI GREEN run 31465576937 | +| 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)** | built Codex MCP e2e in CI at tip + historical 16/16 fixture log @ `6071fb2` | **NOT** live Codex CLI; `test:e2e-codex` open | | L5 released | **FAIL** | no 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 `e8a027c` — live-Codex-CLI leg of L4 remains open. ADV High + S-H2 closed; Ready/merge/tag are human gates. -**User-facing claim allowed:** *Trusted Effects RC tip `e8a027c` is green on exact-SHA CI (run 31465576937) with TE + security hardenings (discovery walk, file-transaction commit path, S-H2 static flow{use}); live Codex CLI, release, and GA remain open.* +**Highest proven for the current code candidate:** **L4 real-environment (built-MCP fixture)** with exact-SHA remote CI green on tip `0551f62` — live-Codex-CLI leg of L4 remains open. ADV High + S-H2 closed; Ready/merge/tag are human gates. +**User-facing claim allowed:** *Trusted Effects RC tip `0551f62` is green on exact-SHA CI (run 31466355338) with TE + security hardenings (discovery walk, file-transaction commit path, S-H2 static flow{use}); live Codex CLI, 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. --- diff --git a/docs/internal/pr-0.3.0-trusted-effects.md b/docs/internal/pr-0.3.0-trusted-effects.md index ee7a8200..fe98dff1 100644 --- a/docs/internal/pr-0.3.0-trusted-effects.md +++ b/docs/internal/pr-0.3.0-trusted-effects.md @@ -8,11 +8,11 @@ | | | |---|---| | Base | `main` @ `4b04e0d` — fix(pi): require explicit approval confirmation (#119) | -| Head | `rc/0.3.0-trusted-effects` @ `e8a027c` (23 commits ahead of main) | +| Head | `rc/0.3.0-trusted-effects` @ `0551f62` (23 commits ahead of main) | | Status | **DRAFT — do not merge, do not publish** | -| Post-ADV harden | `cbb4131` discovery/file-tx + `580daa0` S-H2; CI 31465576937 green | +| Post-ADV harden | `cbb4131` discovery/file-tx + `580daa0` S-H2; CI 31466355338 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 `e8a027c` is the exact head this PR is built from (Draft PR #122 — this PR). +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 `0551f62` is the exact head this PR is built from (Draft PR #122 — this PR). ## Summary diff --git a/docs/internal/reviews/0.3-ready-checklist.md b/docs/internal/reviews/0.3-ready-checklist.md index 6e80b0c7..385980fd 100644 --- a/docs/internal/reviews/0.3-ready-checklist.md +++ b/docs/internal/reviews/0.3-ready-checklist.md @@ -1,8 +1,8 @@ # 0.3 Trusted Effects — Ready checklist (human handoff) -**Branch tip:** `e8a027c` on `rc/0.3.0-trusted-effects` +**Branch tip:** `0551f62` on `rc/0.3.0-trusted-effects` **PR:** https://github.com/heggria/taskflow/pull/122 (**DRAFT** — do not auto-merge) -**Exact-SHA CI:** [31465576937](https://github.com/heggria/taskflow/actions/runs/31465576937) **GREEN** (full matrix) +**Exact-SHA CI:** [31466355338](https://github.com/heggria/taskflow/actions/runs/31466355338) **GREEN** (full matrix) **Base:** `main` @ `4b04e0d` · **~23 commits** ahead > **NOT GA.** L5/L6 FAIL until human tag + npm. Agents must not merge/tag/publish. @@ -18,7 +18,7 @@ | 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 31465576937 success | +| Remote CI tip | run 31466355338 success | | Honesty | L5/L6 FAIL; live Codex E2E open; no FileBroker claim | ## ADV fix ledger (closed for Ready-eligible) diff --git a/docs/internal/reviews/0.3-residual-recheck.md b/docs/internal/reviews/0.3-residual-recheck.md index 678db3f1..49cd8eec 100644 --- a/docs/internal/reviews/0.3-residual-recheck.md +++ b/docs/internal/reviews/0.3-residual-recheck.md @@ -1,9 +1,9 @@ # Residual ADV recheck after S-H2 (Clawdy handoff) **Date:** 2026-08-11 -**Tip:** `e8a027c` (`e8a027c9e69076cf59da5a330fb0da383687ce60`) +**Tip:** `0551f62` (`0551f629e69076cf59da5a330fb0da383687ce60`) **PR:** #122 Draft -**CI:** [31465576937](https://github.com/heggria/taskflow/actions/runs/31465576937) **success** (full matrix incl. windows) +**CI:** [31466355338](https://github.com/heggria/taskflow/actions/runs/31466355338) **success** (full matrix incl. windows) ## Focused tests (local) ``` @@ -32,4 +32,4 @@ node --conditions=development --experimental-strip-types --test \ - S-H2 product polish beyond landed fix ## Conclusion -Residual technical gate for Ready-eligible handoff: **PASS** at tip `e8a027c` with CI green. Human gates (Ready/merge/tag/npm) unchanged. +Residual technical gate for Ready-eligible handoff: **PASS** at tip `0551f62` with CI green. Human gates (Ready/merge/tag/npm) unchanged. From b8ffb703e786ac1341805cbb698fc456f0b8d4c5 Mon Sep 17 00:00:00 2001 From: Clawdy Date: Tue, 11 Aug 2026 15:01:03 +0800 Subject: [PATCH 26/38] =?UTF-8?q?docs:=20live=20Codex=20E2E=20PASS=20on=20?= =?UTF-8?q?tip=204524d2d=20(A=E2=86=92B=E2=86=92C=20+=20TE=20write)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit worker-mac dogfood closes live-host L4 leg. Evidence under docs/internal/evidence/. No merge/tag/npm. --- docs/internal/0.3.0-ga-scoreboard.md | 12 ++++++------ .../internal/evidence/e2e-codex-live-4524d2d.md | 17 +++++++++++++++++ docs/internal/reviews/0.3-ready-checklist.md | 4 ++-- docs/internal/reviews/0.3-residual-recheck.md | 6 ++++++ 4 files changed, 31 insertions(+), 8 deletions(-) create mode 100644 docs/internal/evidence/e2e-codex-live-4524d2d.md diff --git a/docs/internal/0.3.0-ga-scoreboard.md b/docs/internal/0.3.0-ga-scoreboard.md index d6adb2e0..986cd950 100644 --- a/docs/internal/0.3.0-ga-scoreboard.md +++ b/docs/internal/0.3.0-ga-scoreboard.md @@ -5,7 +5,7 @@ **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 **not** rerun on tip. L5/L6 remain FAIL (no tag/publish). +**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). --- @@ -16,12 +16,12 @@ | 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)** | built Codex MCP e2e in CI at tip + historical 16/16 fixture log @ `6071fb2` | **NOT** live Codex CLI; `test:e2e-codex` open | +| 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 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 `0551f62` — live-Codex-CLI leg of L4 remains open. ADV High + S-H2 closed; Ready/merge/tag are human gates. -**User-facing claim allowed:** *Trusted Effects RC tip `0551f62` is green on exact-SHA CI (run 31466355338) with TE + security hardenings (discovery walk, file-transaction commit path, S-H2 static flow{use}); live Codex CLI, release, and GA remain open.* +**Highest proven for the current code candidate:** **L4 real-environment (built-MCP fixture)** with exact-SHA remote CI green on tip `0551f62` — live-Codex-CLI leg closed on tip `4524d2d`. ADV High + S-H2 closed; Ready/merge/tag are human gates. +**User-facing claim allowed:** *Trusted Effects RC tip `0551f62` is green on exact-SHA CI (run 31466355338) with TE + security hardenings (discovery walk, file-transaction commit path, S-H2 static flow{use}); live Codex CLI leg closed on tip; 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. --- @@ -92,8 +92,8 @@ pnpm audit --prod # 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) -# NOT RERUN on e7c5e31: -# pnpm run test:e2e-codex (live Codex CLI; prior-candidate historical A→B→C PASS only) +# 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/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/reviews/0.3-ready-checklist.md b/docs/internal/reviews/0.3-ready-checklist.md index 385980fd..9907783c 100644 --- a/docs/internal/reviews/0.3-ready-checklist.md +++ b/docs/internal/reviews/0.3-ready-checklist.md @@ -19,7 +19,7 @@ | S-H2 static flow{use} over-taint | `580daa0` | | Local focused recheck | 110/110 (residual pass) | | Remote CI tip | run 31466355338 success | -| Honesty | L5/L6 FAIL; live Codex E2E open; no FileBroker claim | +| Honesty | L5/L6 FAIL; live Codex E2E **PASS @ 4524d2d**; no FileBroker claim | ## ADV fix ledger (closed for Ready-eligible) @@ -36,7 +36,7 @@ ## Optional before you click Ready - [ ] Spot-read `docs/internal/reviews/0.3-adv-synth.md` + residual note -- [ ] Decide whether live `test:e2e-codex` is required for *your* bar (still open) +- [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) diff --git a/docs/internal/reviews/0.3-residual-recheck.md b/docs/internal/reviews/0.3-residual-recheck.md index 49cd8eec..2723ae2e 100644 --- a/docs/internal/reviews/0.3-residual-recheck.md +++ b/docs/internal/reviews/0.3-residual-recheck.md @@ -33,3 +33,9 @@ node --conditions=development --experimental-strip-types --test \ ## 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`. From 41c7a9ae6ff3940e070f951506178714005a24c4 Mon Sep 17 00:00:00 2001 From: Clawdy Date: Tue, 11 Aug 2026 15:01:14 +0800 Subject: [PATCH 27/38] docs: add live Codex e2e raw log for 4524d2d --- docs/internal/evidence/e2e-codex-live-4524d2d.log | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 docs/internal/evidence/e2e-codex-live-4524d2d.log 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. From b5beb8ecef8ed31c79aa6db8d56ac09f60d8a865 Mon Sep 17 00:00:00 2001 From: Clawdy Date: Tue, 11 Aug 2026 21:40:01 +0800 Subject: [PATCH 28/38] fix: harden CI process cleanup races Bound Windows atomic rename contention, keep background waits honest until authenticated workers quiesce, and relax a test-only startup deadline under CI load. --- packages/taskflow-core/src/atomic-rename.ts | 48 +++++++++ packages/taskflow-core/src/store.ts | 3 +- .../taskflow-core/test/atomic-rename.test.ts | 78 ++++++++++++++ .../taskflow-core/test/runner-process.test.ts | 2 +- .../taskflow-mcp-core/src/mcp/background.ts | 38 ++++++- packages/taskflow-mcp-core/src/mcp/server.ts | 10 +- .../test/background-runs.test.ts | 100 ++++++++++++++++++ 7 files changed, 273 insertions(+), 6 deletions(-) create mode 100644 packages/taskflow-core/src/atomic-rename.ts create mode 100644 packages/taskflow-core/test/atomic-rename.test.ts 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/store.ts b/packages/taskflow-core/src/store.ts index 1a3c1ee1..fa540480 100644 --- a/packages/taskflow-core/src/store.ts +++ b/packages/taskflow-core/src/store.ts @@ -17,6 +17,7 @@ import * as crypto from "node:crypto"; import * as fs from "node:fs"; import * as path from "node:path"; +import { renameAtomicWithRetry } from "./atomic-rename.ts"; import { parseJsonc } from "./jsonc.ts"; import { getAgentDir } from "./paths.ts"; import { parseStrict } from "./interpolate.ts"; @@ -1549,7 +1550,7 @@ export function writeFileAtomic(filePath: string, data: string): void { const tmp = `${filePath}.${process.pid}.${crypto.randomBytes(4).toString("hex")}.tmp`; try { fs.writeFileSync(tmp, data, "utf-8"); - fs.renameSync(tmp, filePath); + renameAtomicWithRetry(tmp, filePath); } catch (e) { try { if (fs.existsSync(tmp)) fs.unlinkSync(tmp); 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/runner-process.test.ts b/packages/taskflow-core/test/runner-process.test.ts index 74a2e731..ba62a736 100644 --- a/packages/taskflow-core/test/runner-process.test.ts +++ b/packages/taskflow-core/test/runner-process.test.ts @@ -203,7 +203,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"}),20); setInterval(()=>{},1000); - `, { idleTimeoutMs: 80, terminalGraceMs: 50, signal: controller.signal }); + `, { idleTimeoutMs: 1_000, terminalGraceMs: 50, 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-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 4d348801..b783c0eb 100644 --- a/packages/taskflow-mcp-core/src/mcp/server.ts +++ b/packages/taskflow-mcp-core/src/mcp/server.ts @@ -1039,7 +1039,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") { 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); From 1bc54d6c7e39167614abd86c96b3ab7a27bd3d6d Mon Sep 17 00:00:00 2001 From: Clawdy Date: Tue, 11 Aug 2026 22:57:38 +0800 Subject: [PATCH 29/38] fix: preserve resume execution context --- .../codex-taskflow/test/mcp-server.test.ts | 40 +++++++++++++++---- .../plugin/skills/taskflow/SKILL.md | 10 +++++ packages/taskflow-core/src/runtime.ts | 2 +- packages/taskflow-core/test/cache.test.ts | 24 +++++++++++ packages/taskflow-mcp-core/src/mcp/server.ts | 4 +- skills-src/taskflow/core.md | 4 +- skills-src/taskflow/entry.hermes.md | 1 + 7 files changed, 73 insertions(+), 12 deletions(-) diff --git a/packages/codex-taskflow/test/mcp-server.test.ts b/packages/codex-taskflow/test/mcp-server.test.ts index ca6b6238..d7c1df56 100644 --- a/packages/codex-taskflow/test/mcp-server.test.ts +++ b/packages/codex-taskflow/test/mcp-server.test.ts @@ -559,14 +559,30 @@ test("mcp: taskflow_resume forks failed history, applies override, and preserves } = 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 { - const agentFile = path.join(cwd, ".pi", "agents", "resume-fixture.md"); - fs.mkdirSync(path.dirname(agentFile), { recursive: true }); - fs.writeFileSync(agentFile, "---\nname: resume-fixture\ndescription: resume test fixture\n---\nStable resume fixture.\n"); + 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, "both", settings.modelRoles, settings.taskflow); + const { agents } = discoverAgents(cwd, "user", settings.modelRoles, settings.taskflow); const def: Taskflow = { name: "resume-me", + agentScope: "user", phases: [ { id: "a", type: "agent", agent: "resume-fixture", task: "stable" }, { id: "b", type: "agent", agent: "resume-fixture", task: "fail-me", dependsOn: ["a"], final: true }, @@ -586,6 +602,7 @@ test("mcp: taskflow_resume forks failed history, applies override, and preserves const parentResult = await executeTaskflow(parent, { cwd, agents, + globalThinking: settings.globalThinking, runTask: parentRunner, }); assert.equal(parentResult.ok, false); @@ -594,11 +611,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" }; }, }; @@ -619,10 +639,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/hermes-taskflow/plugin/skills/taskflow/SKILL.md b/packages/hermes-taskflow/plugin/skills/taskflow/SKILL.md index bab6df50..faecf11c 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. | @@ -552,6 +553,15 @@ 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*; diff --git a/packages/taskflow-core/src/runtime.ts b/packages/taskflow-core/src/runtime.ts index f373f428..c63eb5e4 100644 --- a/packages/taskflow-core/src/runtime.ts +++ b/packages/taskflow-core/src/runtime.ts @@ -1641,7 +1641,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, 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-mcp-core/src/mcp/server.ts b/packages/taskflow-mcp-core/src/mcp/server.ts index 32c610f4..9ec23fba 100644 --- a/packages/taskflow-mcp-core/src/mcp/server.ts +++ b/packages/taskflow-mcp-core/src/mcp/server.ts @@ -1099,10 +1099,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, diff --git a/skills-src/taskflow/core.md b/skills-src/taskflow/core.md index 26e2fc58..190e35bd 100644 --- a/skills-src/taskflow/core.md +++ b/skills-src/taskflow/core.md @@ -550,7 +550,7 @@ 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 @@ -559,7 +559,7 @@ 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):** 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. | From 2cf1c1252be2bdb3f94df76de8e784195ac5505f Mon Sep 17 00:00:00 2001 From: Clawdy Date: Wed, 12 Aug 2026 16:50:58 +0800 Subject: [PATCH 30/38] docs: archive Control Plane legacy as future-notes (TE supersedes) --- docs/internal/control-plane-future-notes.md | 105 ++++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 docs/internal/control-plane-future-notes.md 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 覆盖 From 537150aa97d6e3028bc310617d0b9c3e2b1ae082 Mon Sep 17 00:00:00 2001 From: Clawdy Date: Wed, 12 Aug 2026 17:28:59 +0800 Subject: [PATCH 31/38] docs: 0.3-C control-plane plan (approved, kanban) --- docs/internal/0.3-c-control-plane-plan.md | 51 +++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 docs/internal/0.3-c-control-plane-plan.md 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) From 653edd1104f7b2f4ae69154fcc1347506062feab Mon Sep 17 00:00:00 2001 From: Clawdy Date: Wed, 12 Aug 2026 17:35:21 +0800 Subject: [PATCH 32/38] docs: freeze 0.3-C P1-P16 ADRs and wire proposal --- docs/internal/p-adrs/P1-policy-overlay.md | 44 + docs/internal/p-adrs/P10-rollback-tiers.md | 31 + docs/internal/p-adrs/P11-compaction-cursor.md | 25 + .../p-adrs/P12-command-batch-reauth.md | 51 + .../p-adrs/P13-bootstrap-singleton.md | 56 ++ .../p-adrs/P14-controlstore-engine.md | 58 ++ docs/internal/p-adrs/P15-approval-protocol.md | 80 ++ .../p-adrs/P16-coordinator-concurrency.md | 113 +++ .../p-adrs/P2-empty-policy-exposure.md | 28 + .../p-adrs/P3-domain-registry-identity.md | 32 + docs/internal/p-adrs/P4-negotiation-errors.md | 44 + .../p-adrs/P5-phase-feature-matrix.md | 61 ++ .../internal/p-adrs/P6-canonical-hash-refs.md | 37 + .../p-adrs/P7-dynamic-paths-dual-hashes.md | 50 + .../p-adrs/P8-enforcement-capabilities.md | 43 + docs/internal/p-adrs/P9-legacy-conflict.md | 31 + docs/internal/p-adrs/README.md | 44 + docs/internal/rfc-0.3.0-control-plane-v7.6.md | 896 ++++++++++++++++++ docs/internal/wire-freeze.md | 135 +++ 19 files changed, 1859 insertions(+) create mode 100644 docs/internal/p-adrs/P1-policy-overlay.md create mode 100644 docs/internal/p-adrs/P10-rollback-tiers.md create mode 100644 docs/internal/p-adrs/P11-compaction-cursor.md create mode 100644 docs/internal/p-adrs/P12-command-batch-reauth.md create mode 100644 docs/internal/p-adrs/P13-bootstrap-singleton.md create mode 100644 docs/internal/p-adrs/P14-controlstore-engine.md create mode 100644 docs/internal/p-adrs/P15-approval-protocol.md create mode 100644 docs/internal/p-adrs/P16-coordinator-concurrency.md create mode 100644 docs/internal/p-adrs/P2-empty-policy-exposure.md create mode 100644 docs/internal/p-adrs/P3-domain-registry-identity.md create mode 100644 docs/internal/p-adrs/P4-negotiation-errors.md create mode 100644 docs/internal/p-adrs/P5-phase-feature-matrix.md create mode 100644 docs/internal/p-adrs/P6-canonical-hash-refs.md create mode 100644 docs/internal/p-adrs/P7-dynamic-paths-dual-hashes.md create mode 100644 docs/internal/p-adrs/P8-enforcement-capabilities.md create mode 100644 docs/internal/p-adrs/P9-legacy-conflict.md create mode 100644 docs/internal/p-adrs/README.md create mode 100644 docs/internal/rfc-0.3.0-control-plane-v7.6.md create mode 100644 docs/internal/wire-freeze.md 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/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..0ba3c1b5 --- /dev/null +++ b/docs/internal/wire-freeze.md @@ -0,0 +1,135 @@ +# 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 出处核对无遗漏、无额外类型 +- [ ] S2 落地 `taskflow-control` 包时回填每个类型的实际文件路径,并保持本文件同步 From 57d927efc95d74e75eb03c3f198dec8645b854e8 Mon Sep 17 00:00:00 2001 From: Clawdy Date: Wed, 12 Aug 2026 18:06:56 +0800 Subject: [PATCH 33/38] feat(control): add taskflow-control package with frozen wire contracts + ControlHost 0.3-C S2: create the taskflow-control package from the frozen wire plan (docs/internal/wire-freeze.md + P1-P16 ADRs; no archive implementation). - TypeBox wire schemas with closed contracts + schemaVersion covering wire-freeze 3.2-3.9 (~33 new types), TE REUSE mirrors (resources/* shapes not exported by taskflow-core) and read-only TE imports (effects/schema, flowir/canonical-hash). - ControlHost auto/coordinated/standalone mode contracts (P13 D5): fresh-install defaults to auto; coordinated fails closed; no silent auto->standalone fallback. - User singleton lock/endpoint/fencing (D32): hard-link create, stale endpoint recovery, identity-bound reclaim, compare-and-delete release, fencing epoch enforcement (TF_AUTHORITY_REVOKED). - hello-before-RPC negotiation gate (P4): TF_PROTOCOL_INCOMPATIBLE / TF_SCHEMA_UNSUPPORTED / TF_FEATURE_REQUIRED. - TE resources as the only execution authority (P8): te-provider adapter with accepted|rejected|ambiguous DTOs; ControlHost refuses non-TE providers. - Unit tests: modes, singleton fencing/stale recovery, te-provider delegation, hello gate, schema contracts, ControlHost integration. - Workspace wiring: pnpm-workspace, root build/devDeps; wire-freeze.md backfilled with actual file paths. 10-package 0.2.10 surface unchanged (typecheck, full test suite 2353 pass, packed smoke 10 packages). --- docs/internal/wire-freeze.md | 55 +- package.json | 3 +- packages/taskflow-control/package.json | 89 +++ packages/taskflow-control/src/control-host.ts | 289 ++++++++ packages/taskflow-control/src/errors.ts | 96 +++ packages/taskflow-control/src/hello.ts | 116 +++ packages/taskflow-control/src/index.ts | 22 + packages/taskflow-control/src/modes.ts | 114 +++ .../taskflow-control/src/schema/approval.ts | 106 +++ .../taskflow-control/src/schema/commands.ts | 276 ++++++++ .../taskflow-control/src/schema/common.ts | 43 ++ .../src/schema/coordinator.ts | 150 ++++ .../taskflow-control/src/schema/evidence.ts | 131 ++++ .../taskflow-control/src/schema/header.ts | 120 ++++ packages/taskflow-control/src/schema/index.ts | 41 ++ packages/taskflow-control/src/schema/plan.ts | 155 ++++ .../taskflow-control/src/schema/policy.ts | 115 +++ packages/taskflow-control/src/schema/run.ts | 78 +++ .../taskflow-control/src/schema/te-mirrors.ts | 236 +++++++ .../taskflow-control/src/schema/transport.ts | 337 +++++++++ packages/taskflow-control/src/singleton.ts | 662 ++++++++++++++++++ packages/taskflow-control/src/te-provider.ts | 221 ++++++ .../test/control-host.test.ts | 192 +++++ packages/taskflow-control/test/hello.test.ts | 109 +++ packages/taskflow-control/test/modes.test.ts | 111 +++ packages/taskflow-control/test/schema.test.ts | 281 ++++++++ .../taskflow-control/test/singleton.test.ts | 242 +++++++ .../taskflow-control/test/te-provider.test.ts | 186 +++++ packages/taskflow-control/tsconfig.build.json | 13 + pnpm-lock.yaml | 12 + pnpm-workspace.yaml | 1 + 31 files changed, 4600 insertions(+), 2 deletions(-) create mode 100644 packages/taskflow-control/package.json create mode 100644 packages/taskflow-control/src/control-host.ts create mode 100644 packages/taskflow-control/src/errors.ts create mode 100644 packages/taskflow-control/src/hello.ts create mode 100644 packages/taskflow-control/src/index.ts create mode 100644 packages/taskflow-control/src/modes.ts create mode 100644 packages/taskflow-control/src/schema/approval.ts create mode 100644 packages/taskflow-control/src/schema/commands.ts create mode 100644 packages/taskflow-control/src/schema/common.ts create mode 100644 packages/taskflow-control/src/schema/coordinator.ts create mode 100644 packages/taskflow-control/src/schema/evidence.ts create mode 100644 packages/taskflow-control/src/schema/header.ts create mode 100644 packages/taskflow-control/src/schema/index.ts create mode 100644 packages/taskflow-control/src/schema/plan.ts create mode 100644 packages/taskflow-control/src/schema/policy.ts create mode 100644 packages/taskflow-control/src/schema/run.ts create mode 100644 packages/taskflow-control/src/schema/te-mirrors.ts create mode 100644 packages/taskflow-control/src/schema/transport.ts create mode 100644 packages/taskflow-control/src/singleton.ts create mode 100644 packages/taskflow-control/src/te-provider.ts create mode 100644 packages/taskflow-control/test/control-host.test.ts create mode 100644 packages/taskflow-control/test/hello.test.ts create mode 100644 packages/taskflow-control/test/modes.test.ts create mode 100644 packages/taskflow-control/test/schema.test.ts create mode 100644 packages/taskflow-control/test/singleton.test.ts create mode 100644 packages/taskflow-control/test/te-provider.test.ts create mode 100644 packages/taskflow-control/tsconfig.build.json diff --git a/docs/internal/wire-freeze.md b/docs/internal/wire-freeze.md index 0ba3c1b5..343e54f3 100644 --- a/docs/internal/wire-freeze.md +++ b/docs/internal/wire-freeze.md @@ -132,4 +132,57 @@ - [ ] P1–P16 ADR 评审通过(13 Accepted + 3 Proposed: P8/P14/P15 重写版) - [ ] 本清单与 P-ADR 出处核对无遗漏、无额外类型 -- [ ] S2 落地 `taskflow-control` 包时回填每个类型的实际文件路径,并保持本文件同步 +- [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/package.json b/package.json index a2427a5e..e276154c 100644 --- a/package.json +++ b/package.json @@ -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/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/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" From e3d128f286952701f4ef1a2cc16f057e80eac03f Mon Sep 17 00:00:00 2001 From: Clawdy Date: Wed, 12 Aug 2026 22:09:24 +0800 Subject: [PATCH 34/38] fix(test): make storage boundary probes portable --- packages/taskflow-core/test/store.test.ts | 57 ++++++++++++++++++----- 1 file changed, 45 insertions(+), 12 deletions(-) diff --git a/packages/taskflow-core/test/store.test.ts b/packages/taskflow-core/test/store.test.ts index d211f3bb..095de305 100644 --- a/packages/taskflow-core/test/store.test.ts +++ b/packages/taskflow-core/test/store.test.ts @@ -47,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, @@ -313,7 +346,7 @@ 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.equal(loaded.filePath, canonicalTestPath(filePath)); assert.deepEqual(loaded.sourceDirIdentity, directoryIdentity(nestedDir)); } finally { cleanup(cwd); @@ -330,7 +363,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); } @@ -341,7 +374,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); @@ -363,7 +396,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 { @@ -388,7 +421,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"); @@ -423,7 +456,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; @@ -526,7 +559,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 }); @@ -566,7 +599,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 { @@ -621,7 +654,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 { @@ -670,7 +703,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 { @@ -709,7 +742,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"; @@ -985,7 +1018,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); From e2f8990ed490ff60476478a4eb044d25e309452f Mon Sep 17 00:00:00 2001 From: Clawdy Date: Wed, 12 Aug 2026 22:20:16 +0800 Subject: [PATCH 35/38] fix(test): normalize cross-platform storage assertions --- packages/taskflow-core/src/store.ts | 9 +++++++-- packages/taskflow-core/test/store.test.ts | 12 ++++++++++-- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/packages/taskflow-core/src/store.ts b/packages/taskflow-core/src/store.ts index 165a7646..a2d37c10 100644 --- a/packages/taskflow-core/src/store.ts +++ b/packages/taskflow-core/src/store.ts @@ -1246,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. */ @@ -1460,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/test/store.test.ts b/packages/taskflow-core/test/store.test.ts index 095de305..d6423c6b 100644 --- a/packages/taskflow-core/test/store.test.ts +++ b/packages/taskflow-core/test/store.test.ts @@ -347,7 +347,11 @@ test("listFlows: discovers project flows recursively below the flows convention assert.ok(loaded, "nested flow should be discoverable by its declared name"); assert.equal(loaded.scope, "project"); assert.equal(loaded.filePath, canonicalTestPath(filePath)); - assert.deepEqual(loaded.sourceDirIdentity, directoryIdentity(nestedDir)); + 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); } @@ -583,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"); @@ -621,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"); From ab622c8b04f9cf79aa7b0ee1ccc8d834a5a32ca5 Mon Sep 17 00:00:00 2001 From: Clawdy Date: Wed, 12 Aug 2026 23:38:02 +0800 Subject: [PATCH 36/38] docs: rewrite 0.3 trusted effects story --- README.md | 510 +++++------------- README.zh-CN.md | 507 +++++------------ website/app/[lang]/layout.tsx | 8 +- website/app/[lang]/page.tsx | 308 +++++------ website/app/page.tsx | 4 +- website/components/home/compiler-bench.tsx | 16 +- website/content/docs/en/getting-started.mdx | 4 + website/content/docs/en/index.mdx | 173 +++--- website/content/docs/en/meta.json | 1 + website/content/docs/en/trusted-effects.mdx | 42 ++ .../content/docs/zh-cn/getting-started.mdx | 4 + website/content/docs/zh-cn/index.mdx | 173 +++--- website/content/docs/zh-cn/meta.json | 1 + .../content/docs/zh-cn/trusted-effects.mdx | 42 ++ 14 files changed, 685 insertions(+), 1108 deletions(-) create mode 100644 website/content/docs/en/trusted-effects.mdx create mode 100644 website/content/docs/zh-cn/trusted-effects.mdx diff --git a/README.md b/README.md index 499b95b1..cf162253 100644 --- a/README.md +++ b/README.md @@ -1,460 +1,214 @@
-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 Trusted Effects candidate — unreleased and not GA.** Published packages remain on the 0.2.x line until a human cuts the `v0.3.0` tag and publishes it. The claims below describe the current candidate, not a promise of the final release. -**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 modes, frozen wire contracts, singleton/fencing, negotiation, approvals, receipts, and coordination | Active 0.3-C implementation track; 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 candidate is developed from source. Use a clean checkout and 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: +Run the no-LLM Trusted Effects vertical-slice fixture: -> Use taskflow to audit `src/api` in parallel and return one prioritized report. +```bash +pnpm exec node --conditions=development --experimental-strip-types --test \ + packages/taskflow-core/test/effects-e2e-fixture.test.ts +``` -The routing skill uses the same familiar `task` / `tasks` / `chain` shape: +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/). -```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}" - } - ] -} -``` +## Declare an effect -That already gives you an isolated, tracked run. When the job needs real topology, declare the graph: +Effects are part of the flow contract, not a free-form prompt promise: ```json { - "name": "audit-api", - "args": { "dir": { "default": "src/api" } }, - "concurrency": 4, + "name": "trusted-effects-write", "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"], + "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 } ] } ``` -Save it as `.pi/taskflows/audit-api.json`, then run: - -```text -/tf:audit-api dir=src/api -``` - -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. +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. -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. +## The runtime contract -A file-backed flow can opt script phases into definition-relative execution: - -```json -{ - "name": "release", - "scriptCwd": "flow", - "phases": [ - { "id": "prepare", "type": "script", "run": ["./scripts/prepare.sh"], "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) - -## See the graph run - -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 **20 tools**, including `taskflow_plan`, `taskflow_analytics`, and the Trusted Effects audit tool `taskflow_why_effect`. Starter templates: [`examples/templates/`](./examples/templates/). Full notes: [CHANGELOG](./CHANGELOG.md). - -## 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. - -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. - -```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 -``` - -[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) +| **Dynamic graph** | `expand` | Validate and execute a runtime-produced nested or grafted fragment | -### OpenCode +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. -```bash -opencode mcp add taskflow -- \ - npx -y -p opencode-taskflow@0.2.10 opencode-taskflow-mcp -``` +Useful zero-token operations include: -[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 receipts and WebUI are part of the active 0.3-C track, 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 +216,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 7ffaf00b..2783536c 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -1,464 +1,223 @@
-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 Trusted Effects candidate——未发布、未 GA。** 在人工创建 `v0.3.0` tag 并发布前,已发布 package 仍保持 0.2.x 版本线。下文描述的是当前 candidate,不是最终发布承诺。 -**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、协商、审批、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 candidate 当前从源码开发。准备干净 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 ``` -然后自然地提出需求: +运行不需要 LLM 的 Trusted Effects vertical-slice fixture: -> 用 taskflow 并行审计 `src/api`,最后只返回一份按优先级排列的报告。 +```bash +pnpm exec node --conditions=development --experimental-strip-types --test \ + packages/taskflow-core/test/effects-e2e-fixture.test.ts +``` -路由 skill 使用你已经熟悉的 `task` / `tasks` / `chain` 形式: +它会在没有 live LLM 的情况下执行仓库内的 `examples/trusted-effects-write.json` 路径。要交互式运行,请按当前使用的宿主查看对应指南。稳定的 0.2 安装路径仍在[宿主指南](https://heggria.github.io/taskflow/zh-cn/docs/guides/)中单独说明。 -```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}" - } - ] -} -``` +## 声明一个 effect -这样就已经得到一次隔离、可追踪的运行。当任务需要真正的拓扑结构时,声明整张图: +Effect 是 flow 合同的一部分,不是 prompt 里的自由文本承诺: ```json { - "name": "audit-api", - "args": { "dir": { "default": "src/api" } }, - "concurrency": 4, + "name": "trusted-effects-write", "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"], + "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 } ] } ``` -保存为 `.pi/taskflows/audit-api.json`,然后运行: - -```text -/tf:audit-api dir=src/api -``` - -在 Codex、Claude Code、OpenCode、Grok Build 和 Hermes Agent 上,通过 `taskflow_run` 按名称运行同一份保存定义。长任务可使用 `mode: "background"`,再用 `taskflow_runs` 执行 `list` / `status` / `wait` / `cancel`,无需担心单次 MCP 调用超时;列表会显示当前并发数,并可筛选 `running` 或 `terminal` 运行。 - -大型项目可以把保存的定义递归组织在 `.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 时会执行相同的存储边界校验,并在写锁内重新验证目标目录的物理身份。 - -文件支持的 flow 可以显式选择从定义文件目录运行脚本阶段: - -```json -{ - "name": "release", - "scriptCwd": "flow", - "phases": [ - { "id": "prepare", "type": "script", "run": ["./scripts/prepare.sh"], "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 宿主现为 **20 个工具**,包括 `taskflow_plan`、`taskflow_analytics` 和 Trusted Effects 审计工具 `taskflow_why_effect`。入门模板见 [`examples/templates/`](./examples/templates/)。完整说明:[CHANGELOG](./CHANGELOG.md)。 - -## 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 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/website/app/[lang]/layout.tsx b/website/app/[lang]/layout.tsx index 74013124..f9aad41d 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 — Trusted Effects candidate", 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 candidate; unreleased and not GA.", }, "zh-cn": { - title: "taskflow — 花 token 前先验证", + title: "taskflow 0.3 — Trusted Effects candidate", brand: "taskflow", description: - "花 token 前先验证,跨会话续跑,只重算变化部分。taskflow 是面向 coding-agent 编排的 compiled runtime。", + "声明 coding-agent effect,验证类型化路径,让已准入的文件修改经过唯一 resource authority。0.3 candidate,未发布、未 GA。", }, } as const; diff --git a/website/app/[lang]/page.tsx b/website/app/[lang]/page.tsx index ba9f70f8..c868570f 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 · Trusted Effects candidate", 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 candidate · unreleased · 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. Control Plane and WebUI remain active follow-on tracks, 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: "Stable 0.2.x host installs — candidate runs from source.", 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: "ControlHost, receipts, approvals, and WebUI are active implementation tracks, not shipped 0.3 GA surface yet.", }, { tag: "Hosts", - title: "Six host adapters · 20 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 · Trusted Effects candidate", + title: ["声明 effect。", "验证路径。", "让一个 authority 负责提交。"], + sub: "taskflow 把 coding-agent 工作变成可验证的运行时:显式任务图、类型化 effect 声明、隔离执行、受 resources 控制的文件提交,以及覆盖六个宿主的 ledger-backed 解释。", + noteKicker: "0.3 candidate · 未发布 · 未 GA", noteBody: - "0.2.7 补上日常闭环:taskflow_plan 在任何模型调用前;hooks 与 analytics 在跑完之后;recompute 带上省钱数字——且从不把 transcript 灌进宿主。", - micro: "中间过程留在运行时里。回到宿主的,只有结果。", + "智能体可以提出内容。对于已准入的已声明目标,resources transaction 是唯一最终提交者。Control Plane 与 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.2.x 宿主安装;candidate 请从源码运行。", 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、receipts、审批与 WebUI 是活跃实现轨道,还不是已发布的 0.3 GA 表面。", }, { tag: "Hosts", - title: "六个宿主 · 20 个 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-candidate", 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..cf4e6185 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 candidate; unreleased 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/content/docs/en/getting-started.mdx b/website/content/docs/en/getting-started.mdx index 4907a046..2b5dac53 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 the current **0.3 Trusted Effects candidate**, start with the [Trusted Effects overview](/en/docs/trusted-effects) and use the repository checkout; 0.3 is unreleased and not GA. + + The fastest way to see it is to run something. ## A minimal taskflow diff --git a/website/content/docs/en/index.mdx b/website/content/docs/en/index.mdx index 76ce4e6a..ddf294c3 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 candidate — unreleased and not GA.** The published package line remains 0.2.x until a human cuts and publishes `v0.3.0`. -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 candidate is currently developed from this repository. For the stable published host packages, follow the existing host guides and keep the package pins on the published 0.2.x line. -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:** an active implementation track for ControlHost, project stores, coordination, approvals, receipts, and later evidence UI. It is not 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..91e7c6f3 --- /dev/null +++ b/website/content/docs/en/trusted-effects.mdx @@ -0,0 +1,42 @@ +--- +title: Trusted Effects +--- + +> **0.3 candidate — unreleased and not GA.** This page describes the current Trusted Effects MVP implementation, not an npm release guarantee. + +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, receipts, and WebUI belong to the active 0.3-C follow-on track; they are not 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/getting-started.mdx b/website/content/docs/zh-cn/getting-started.mdx index 9443a502..b33a5d29 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 Trusted Effects candidate** 请先阅读 [Trusted Effects 总览](/zh-cn/docs/trusted-effects),并从仓库 checkout 运行;0.3 尚未发布,也不是 GA。 + + 要最快地感受它,先跑一个看看。 ## 一个最小的 taskflow diff --git a/website/content/docs/zh-cn/index.mdx b/website/content/docs/zh-cn/index.mdx index 6c37a45b..23631fc0 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 candidate——未发布、未 GA。** 在人工创建并发布 `v0.3.0` 前,已发布 package 仍保持 0.2.x 版本线。 -在 **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 candidate 当前从仓库源码开发。稳定的已发布宿主 package 仍请按现有宿主指南操作,并保持已发布的 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..113a3216 --- /dev/null +++ b/website/content/docs/zh-cn/trusted-effects.mdx @@ -0,0 +1,42 @@ +--- +title: Trusted Effects +--- + +> **0.3 candidate——未发布、未 GA。** 本页描述当前 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、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) From f5284da6153e09dc88650f3d61918334f12ea831 Mon Sep 17 00:00:00 2001 From: Clawdy Date: Wed, 12 Aug 2026 23:43:55 +0800 Subject: [PATCH 37/38] docs: clarify control plane candidate boundaries --- README.md | 4 ++-- README.zh-CN.md | 4 ++-- website/app/[lang]/page.tsx | 8 ++++---- website/content/docs/en/index.mdx | 2 +- website/content/docs/en/trusted-effects.mdx | 2 +- website/content/docs/zh-cn/index.mdx | 2 +- website/content/docs/zh-cn/trusted-effects.mdx | 2 +- 7 files changed, 12 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index cf162253..42b1fc27 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ This is **not** an OS sandbox. Resolve-only hosts cannot prevent every write to | **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 modes, frozen wire contracts, singleton/fencing, negotiation, approvals, receipts, and coordination | Active 0.3-C implementation track; not the 0.3 MVP GA claim | +| **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 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). @@ -183,7 +183,7 @@ Host support is not a blanket security guarantee. Read the [host support baselin - 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 receipts and WebUI are part of the active 0.3-C track, not proof that 0.3 is released or GA. +- Control Plane stores, approvals, receipts, and WebUI are future 0.3-C stages, not proof that 0.3 is released or GA. ## Development diff --git a/README.zh-CN.md b/README.zh-CN.md index 2783536c..2ceb8202 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -55,7 +55,7 @@ flow / .tf.ts | **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、协商、审批、receipt 与协调 | 活跃的 0.3-C 实现轨;不是 0.3 MVP 的 GA 声明 | +| **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)。 @@ -183,7 +183,7 @@ validate → Taskflow JSON → FlowIR + content hash - 在 resolve-only 执行下,未声明路径的写入仍取决于宿主策略。 - `SecretRef` 与 `ServiceRef` 只是类型化句柄;这一版没有 vault 或 live service adapter。 - 0.3 MVP 不声称提供 FileBroker 或完整 OS sandbox。 -- Control Plane receipt 与 WebUI 属于活跃的 0.3-C 轨道,不证明 0.3 已发布或 GA。 +- Control Plane store、审批、receipt 与 WebUI 属于未来的 0.3-C 阶段,不证明 0.3 已发布或 GA。 ## 开发 diff --git a/website/app/[lang]/page.tsx b/website/app/[lang]/page.tsx index c868570f..566fa9a5 100644 --- a/website/app/[lang]/page.tsx +++ b/website/app/[lang]/page.tsx @@ -34,7 +34,7 @@ const copy = { 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 candidate · unreleased · not GA", noteBody: - "An agent can propose content. For admitted declared targets, the resources transaction is the only finalizer. Control Plane and WebUI remain active follow-on tracks, not shipped GA claims.", + "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: "Resolve-only is not an OS sandbox. Undeclared writes remain host-policy dependent.", hosts: "Pi · Codex · Claude Code · OpenCode · Grok · Hermes", @@ -128,7 +128,7 @@ const copy = { { tag: "0.3-C", title: "Control Plane follows", - body: "ControlHost, receipts, approvals, and WebUI are active implementation tracks, not shipped 0.3 GA surface yet.", + 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", @@ -192,7 +192,7 @@ const copy = { sub: "taskflow 把 coding-agent 工作变成可验证的运行时:显式任务图、类型化 effect 声明、隔离执行、受 resources 控制的文件提交,以及覆盖六个宿主的 ledger-backed 解释。", noteKicker: "0.3 candidate · 未发布 · 未 GA", noteBody: - "智能体可以提出内容。对于已准入的已声明目标,resources transaction 是唯一最终提交者。Control Plane 与 WebUI 仍是后续实现轨道,不是已交付的 GA 表面。", + "智能体可以提出内容。对于已准入的已声明目标,resources transaction 是唯一最终提交者。ControlHost 目前是脚手架;store、审批、receipt 与 WebUI 仍是后续阶段,不是已交付的 GA 表面。", micro: "Resolve-only 不是 OS sandbox。未声明写入仍取决于宿主策略。", hosts: "Pi · Codex · Claude Code · OpenCode · Grok · Hermes", primary: "阅读文档", @@ -285,7 +285,7 @@ const copy = { { tag: "0.3-C", title: "Control Plane 在后面", - body: "ControlHost、receipts、审批与 WebUI 是活跃实现轨道,还不是已发布的 0.3 GA 表面。", + body: "ControlHost 目前是脚手架;store、receipt、审批与 WebUI 属于后续 0.3-C 阶段,还不是已发布的 0.3 GA 表面。", }, { tag: "Hosts", diff --git a/website/content/docs/en/index.mdx b/website/content/docs/en/index.mdx index ddf294c3..58f09dac 100644 --- a/website/content/docs/en/index.mdx +++ b/website/content/docs/en/index.mdx @@ -96,7 +96,7 @@ The 0.3 candidate is currently developed from this repository. For the stable pu 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:** an active implementation track for ControlHost, project stores, coordination, approvals, receipts, and later evidence UI. It is not the 0.3 MVP GA definition, and the WebUI is not shipped in this candidate. +- **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. diff --git a/website/content/docs/en/trusted-effects.mdx b/website/content/docs/en/trusted-effects.mdx index 91e7c6f3..90a2847d 100644 --- a/website/content/docs/en/trusted-effects.mdx +++ b/website/content/docs/en/trusted-effects.mdx @@ -32,7 +32,7 @@ pnpm exec node --conditions=development --experimental-strip-types --test \ - 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, receipts, and WebUI belong to the active 0.3-C follow-on track; they are not proof that 0.3 is released or GA. +- 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 diff --git a/website/content/docs/zh-cn/index.mdx b/website/content/docs/zh-cn/index.mdx index 23631fc0..eec4f852 100644 --- a/website/content/docs/zh-cn/index.mdx +++ b/website/content/docs/zh-cn/index.mdx @@ -96,7 +96,7 @@ taskflow 是面向 coding-agent 工作流的声明式运行时。它把任务图 当前 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 交付。 +- **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)。 diff --git a/website/content/docs/zh-cn/trusted-effects.mdx b/website/content/docs/zh-cn/trusted-effects.mdx index 113a3216..58901317 100644 --- a/website/content/docs/zh-cn/trusted-effects.mdx +++ b/website/content/docs/zh-cn/trusted-effects.mdx @@ -32,7 +32,7 @@ pnpm exec node --conditions=development --experimental-strip-types --test \ - Resolve-only 宿主无法阻止所有对未声明路径的写入。 - 这一版没有 SecretRef vault 后端。 - 这一版没有 ServiceRef live network adapter。 -- ControlHost、receipts 与 WebUI 属于活跃的 0.3-C 后续轨道;它们不证明 0.3 已发布或 GA。 +- ControlHost 当前是脚手架;store、审批、receipts 与 WebUI 属于后续的 0.3-C 阶段。它们不证明 0.3 已发布或 GA。 ## 接下来阅读 From 5bbb56683752af389b6e58553e55d3cc1fe3793f Mon Sep 17 00:00:00 2001 From: Clawdy Date: Thu, 13 Aug 2026 01:11:54 +0800 Subject: [PATCH 38/38] chore(release): prepare 0.3.0-beta.1 --- .github/workflows/publish.yml | 44 ++++++++++++++----- CHANGELOG.md | 7 ++- README.md | 12 ++++- README.zh-CN.md | 13 +++++- RELEASE.md | 36 +++++++-------- SECURITY.md | 3 +- docs/claude-mcp.md | 2 +- docs/codex-mcp.md | 4 +- docs/grok-mcp.md | 4 +- docs/hermes-mcp.md | 2 +- docs/i18n/README.ar.md | 2 +- docs/i18n/README.bn.md | 2 +- docs/i18n/README.es.md | 2 +- docs/i18n/README.hi.md | 2 +- docs/i18n/README.pt.md | 2 +- docs/i18n/README.ru.md | 2 +- docs/internal/0.3.0-ga-scoreboard.md | 6 +-- docs/internal/0.3.0-release-plan.md | 29 ++++++------ docs/internal/pr-0.3.0-trusted-effects.md | 28 ++++++------ docs/internal/reviews/0.3-ready-checklist.md | 18 ++++---- package.json | 2 +- packages/claude-taskflow/package.json | 2 +- .../plugin/.claude-plugin/plugin.json | 2 +- packages/claude-taskflow/plugin/.mcp.json | 2 +- .../claude-taskflow/test/mcp-server.test.ts | 2 +- packages/codex-taskflow/package.json | 2 +- .../plugin/.codex-plugin/plugin.json | 2 +- packages/codex-taskflow/plugin/.mcp.json | 2 +- .../codex-taskflow/test/mcp-server.test.ts | 2 +- packages/grok-taskflow/package.json | 2 +- .../plugin/.grok-plugin/plugin.json | 2 +- packages/grok-taskflow/plugin/.mcp.json | 2 +- .../grok-taskflow/test/mcp-server.test.ts | 2 +- packages/hermes-taskflow/package.json | 2 +- .../plugin/hermes.config.snippet.yaml | 2 +- .../hermes-taskflow/test/mcp-server.test.ts | 2 +- packages/opencode-taskflow/package.json | 2 +- .../opencode-taskflow/plugin/opencode.json | 2 +- .../opencode-taskflow/test/mcp-server.test.ts | 2 +- packages/pi-taskflow/package.json | 2 +- packages/taskflow-core/package.json | 2 +- packages/taskflow-dsl/package.json | 2 +- packages/taskflow-hosts/package.json | 2 +- packages/taskflow-mcp-core/package.json | 2 +- website/app/[lang]/layout.tsx | 8 ++-- website/app/[lang]/page.tsx | 14 +++--- website/app/page.tsx | 2 +- website/components/home/install-rail.tsx | 8 ++-- .../en/compiler-runtime/typescript-dsl.mdx | 2 +- website/content/docs/en/getting-started.mdx | 10 ++--- website/content/docs/en/guides/grok-build.mdx | 4 +- website/content/docs/en/guides/hermes.mdx | 4 +- website/content/docs/en/guides/opencode.mdx | 4 +- website/content/docs/en/guides/pi.mdx | 2 +- website/content/docs/en/index.mdx | 4 +- website/content/docs/en/trusted-effects.mdx | 2 +- .../zh-cn/compiler-runtime/typescript-dsl.mdx | 2 +- .../content/docs/zh-cn/getting-started.mdx | 10 ++--- .../content/docs/zh-cn/guides/grok-build.mdx | 2 +- website/content/docs/zh-cn/guides/hermes.mdx | 4 +- .../content/docs/zh-cn/guides/opencode.mdx | 4 +- website/content/docs/zh-cn/guides/pi.mdx | 2 +- website/content/docs/zh-cn/index.mdx | 4 +- .../content/docs/zh-cn/trusted-effects.mdx | 2 +- 64 files changed, 201 insertions(+), 161 deletions(-) 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/CHANGELOG.md b/CHANGELOG.md index 3472597a..7e78704f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,9 @@ All notable changes to taskflow are documented here. This project follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) format. -## [0.3.0] — Unreleased (Trusted Effects candidate) +## [0.3.0-beta.1] — 2026-08-13 -> **Version plan:** keep published packages at **0.2.9** until a human cuts `v0.3.0`. -> Branch: `rc/0.3.0-trusted-effects`. **Not GA until tag + publish.** +> **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 @@ -34,7 +33,7 @@ All notable changes to taskflow are documented here. This project follows [Keep - 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. -- **Not released; not GA.** +- **Beta release; not GA.** ## [0.2.10] — 2026-08-12 diff --git a/README.md b/README.md index 42b1fc27..c3f8b195 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ **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. -> **Status: 0.3.0 Trusted Effects candidate — unreleased and not GA.** Published packages remain on the 0.2.x line until a human cuts the `v0.3.0` tag and publishes it. The claims below describe the current candidate, not a promise of the final release. +> **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. ## The 0.3 idea @@ -62,7 +62,7 @@ The normative MVP definition is [`docs/internal/0.3.0-trusted-effects-mvp.md`](. ## Quickstart -The 0.3 candidate is developed from source. Use a clean checkout and Node.js **≥ 22.19.0**: +The 0.3 beta can be installed from npm, or exercised from a clean source checkout. Use Node.js **≥ 22.19.0**: ```bash git clone https://github.com/heggria/taskflow.git @@ -73,6 +73,14 @@ pnpm run typecheck pnpm test ``` +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 +``` + +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. + Run the no-LLM Trusted Effects vertical-slice fixture: ```bash diff --git a/README.zh-CN.md b/README.zh-CN.md index 2ceb8202..a5fb5d2f 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -21,7 +21,7 @@ **taskflow 是面向 coding-agent 工作流的声明式运行时。** 它把任务图变成可验证的执行合同,让阶段隔离运行,并把中间过程留在宿主对话之外。在 0.3 candidate 中,这份合同还可以描述每个阶段被允许提出的副作用。 -> **状态:0.3.0 Trusted Effects candidate——未发布、未 GA。** 在人工创建 `v0.3.0` tag 并发布前,已发布 package 仍保持 0.2.x 版本线。下文描述的是当前 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 已交付的产品表面。 ## 0.3 的核心想法 @@ -62,7 +62,7 @@ flow / .tf.ts ## 快速开始 -0.3 candidate 当前从源码开发。准备干净 checkout,并使用 Node.js **≥ 22.19.0**: +0.3 beta 可以从 npm 安装,也可以从源码 checkout 运行。准备 Node.js **≥ 22.19.0**: ```bash git clone https://github.com/heggria/taskflow.git @@ -73,6 +73,15 @@ pnpm run typecheck pnpm test ``` +以下 beta 命令在 tag workflow 完成后可用;在此之前它们只是发版目标示例,不代表 registry 已可获取。 + +```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。 + 运行不需要 LLM 的 Trusted Effects vertical-slice fixture: ```bash 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/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.0-ga-scoreboard.md b/docs/internal/0.3.0-ga-scoreboard.md index 986cd950..5d397102 100644 --- a/docs/internal/0.3.0-ga-scoreboard.md +++ b/docs/internal/0.3.0-ga-scoreboard.md @@ -17,11 +17,11 @@ | 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 tag/publish | human gate | +| 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 `0551f62` — live-Codex-CLI leg closed on tip `4524d2d`. ADV High + S-H2 closed; Ready/merge/tag are human gates. -**User-facing claim allowed:** *Trusted Effects RC tip `0551f62` is green on exact-SHA CI (run 31466355338) with TE + security hardenings (discovery walk, file-transaction commit path, S-H2 static flow{use}); live Codex CLI leg closed on tip; release and GA remain open.* +**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. --- diff --git a/docs/internal/0.3.0-release-plan.md b/docs/internal/0.3.0-release-plan.md index 8b278b16..1931db2c 100644 --- a/docs/internal/0.3.0-release-plan.md +++ b/docs/internal/0.3.0-release-plan.md @@ -1,18 +1,18 @@ -# 0.3.0 Trusted Effects — release plan (human gate) +# 0.3.0 Trusted Effects — beta.1 release plan ## Current state | Item | Value | |------|--------| | Branch | `rc/0.3.0-trusted-effects` | -| Code candidate | `e7c5e31` (fix(test): Windows child-process store import portability — test-harness only, no product code change; product candidate `96a32e8` store-discovery hardening unchanged) | -| Highest proven | **L4 real-environment (built-MCP fixture)** with **exact-SHA remote CI green** (run 31460133496 on PR #122 head `e7c5e31`: test node 22/24, e2e codex MCP network-free incl. built-dist full, build, packed consumer 9 pkgs, website export, process supervisor 3 OSes, CodeQL; local `e7c5e31` typecheck + 2198/0/4; RC pipeline at `6071fb2` adds audit clean + built Codex MCP 16/16) | -| Remote candidate | **GREEN exact-SHA CI** — Draft PR #122 head `e7c5e31`, run https://github.com/heggria/taskflow/actions/runs/31460133496 conclusion success, full matrix incl. windows process-supervisor `store.test.ts` PASS (prior red run 31456747148 on `0f9cf16` fixed by the Windows test-harness portability fix in `e7c5e31`) | -| L4 real-environment | **PASS (built-MCP fixture)** — built dist Codex MCP e2e 16/16 incl. TE fixture (`codex-mcp-full.log`, evidence SHA `6071fb2`); CI e2e job re-runs `test:e2e-codex-mcp-full` at exact SHA `e7c5e31`; live `test:e2e-codex` NOT rerun on current tip (prior-candidate historical A→B→C only) | -| Package versions on npm | still **0.2.8** (0.3.0 unpublished) | -| Tag | **none** | +| 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** | -## Human checklist for L5 → L6 +## 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/`): @@ -24,12 +24,13 @@ 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` @ `e7c5e31`) and run 31460133496 is green across the full matrix incl. windows process-supervisor (`gh` is authenticated on this host; the PR was opened/updated from `docs/internal/pr-0.3.0-trusted-effects.md`). If the rc tip moves again, re-verify the PR head before relying on this. -4. Bump workspace package versions to `0.3.0` (root + publishable packages) via existing release scripts (`RELEASE.md`). -5. Move CHANGELOG `## [0.3.0] — Unreleased` → dated release section. -6. Tag `v0.3.0` and publish **only with explicit human authority**. -7. Update scoreboard L5/L6 to PASS with tag URL + publish receipt. +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. Stop at candidate + truthful scoreboard (L5/L6 FAIL until step 6–7). +Agents must not tag or npm publish without explicit human authority. Beta publication does not make L6 GA PASS. diff --git a/docs/internal/pr-0.3.0-trusted-effects.md b/docs/internal/pr-0.3.0-trusted-effects.md index fe98dff1..8afc4187 100644 --- a/docs/internal/pr-0.3.0-trusted-effects.md +++ b/docs/internal/pr-0.3.0-trusted-effects.md @@ -1,18 +1,18 @@ -# rc: 0.3.0 Trusted Effects candidate (do not publish) +# rc: 0.3.0 Trusted Effects beta.1 -> Draft PR body. **Not for npm publish** — published packages stay at **0.2.8** until a human cuts `v0.3.0`. +> 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` @ `4b04e0d` — fix(pi): require explicit approval confirmation (#119) | -| Head | `rc/0.3.0-trusted-effects` @ `0551f62` (23 commits ahead of main) | -| Status | **DRAFT — do not merge, do not publish** | -| Post-ADV harden | `cbb4131` discovery/file-tx + `580daa0` S-H2; CI 31466355338 green | +| 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 `0551f62` is the exact head this PR is built from (Draft PR #122 — this PR). +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 @@ -46,14 +46,14 @@ This candidate guarantees **admitted declared filesystem write targets**. It doe | Gate | Status | Notes | |---|---|---| -| L1 local | **PASS** | `e7c5e31`: monorepo typecheck PASS (local + CI node 22/24); `pnpm audit --prod` clean (RC pipeline at `6071fb2` — CI does not run audit) | -| L2 contract | **PASS** | full unit suite 2198 pass / 0 fail / 4 skipped (local + CI node 22/24); full build PASS (CI build job); `test:pack` 9 packages + CharterArc PASS (CI packed-consumer) at exact SHA `e7c5e31` | +| 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)** | built Codex MCP comprehensive e2e 16/16 incl. TE fixture — `fs.write` committed through resources + ledger-backed why-effect — against the built dist bin (`codex-mcp-full.log`; evidence SHA `6071fb2` per build-info stamp; CI e2e job re-runs `test:e2e-codex-mcp-full` at exact SHA `e7c5e31`). **NOT** a live Codex CLI run: `test:e2e-codex` NOT rerun on the current tip (prior-candidate A→B→C historical only) | -| L5 released | **FAIL** | no tag/publish — human gate | +| 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 31460133496 on this PR's head `e7c5e31` passed the full matrix (test node 22/24, e2e codex MCP network-free incl. built-dist comprehensive, build dist, packed consumer 9 pkgs + CharterArc, website export, process supervisor ubuntu/macos/windows, CodeQL JS/TS). A prior red run 31456747148 on `0f9cf16` (windows store tests) was fixed by the test-harness portability change in `e7c5e31`. Historical Draft PR #117 run `31167592775` passed all jobs on `1478510f` (earlier candidate SHA). +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) @@ -74,5 +74,5 @@ e6efcd4 feat(effects): add resource-controlled trusted writes ## Release state -- CHANGELOG: `## [0.3.0] — Unreleased (Trusted Effects candidate)` — still Unreleased. -- **Not released; not GA.** No tag, no npm publish — blocked on explicit human authorization to cut `v0.3.0`. +- 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-ready-checklist.md b/docs/internal/reviews/0.3-ready-checklist.md index 9907783c..3d95c795 100644 --- a/docs/internal/reviews/0.3-ready-checklist.md +++ b/docs/internal/reviews/0.3-ready-checklist.md @@ -1,11 +1,11 @@ -# 0.3 Trusted Effects — Ready checklist (human handoff) +# 0.3.0-beta.1 Trusted Effects beta — not GA -**Branch tip:** `0551f62` on `rc/0.3.0-trusted-effects` +**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:** [31466355338](https://github.com/heggria/taskflow/actions/runs/31466355338) **GREEN** (full matrix) +**Exact-SHA CI:** [31613909075](https://github.com/heggria/taskflow/actions/runs/31613909075) **GREEN** (full matrix) **Base:** `main` @ `4b04e0d` · **~23 commits** ahead -> **NOT GA.** L5/L6 FAIL until human tag + npm. Agents must not merge/tag/publish. +> **BETA RELEASE, NOT GA.** L6 remains FAIL. Agents must not merge/tag/publish without explicit human authority. --- @@ -14,11 +14,11 @@ | Item | Evidence | |------|----------| | Product TE + post-review harden | history includes `e6efcd4` … `467bc28`, store `96a32e8` | -| Windows store test harness | `e7c5e31` | +| 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 31466355338 success | +| 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) @@ -43,9 +43,9 @@ 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 per `RELEASE.md` -4. **Tag** `v0.3.0` + **npm publish** with explicit authority -5. Update scoreboard L5/L6 with tag URL + publish receipt +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 diff --git a/package.json b/package.json index e276154c..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", 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/test/mcp-server.test.ts b/packages/claude-taskflow/test/mcp-server.test.ts index 9cae1d50..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 () => { 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/test/mcp-server.test.ts b/packages/codex-taskflow/test/mcp-server.test.ts index 205a0de0..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 () => { 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/test/mcp-server.test.ts b/packages/grok-taskflow/test/mcp-server.test.ts index 30ace01e..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 () => { 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/test/mcp-server.test.ts b/packages/hermes-taskflow/test/mcp-server.test.ts index 04b9fbf2..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 () => { 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/test/mcp-server.test.ts b/packages/opencode-taskflow/test/mcp-server.test.ts index e04917e8..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 () => { 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/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-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/website/app/[lang]/layout.tsx b/website/app/[lang]/layout.tsx index f9aad41d..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 0.3 — Trusted Effects candidate", + title: "taskflow 0.3.0-beta.1 — Trusted Effects beta", brand: "taskflow", description: - "Declare coding-agent effects, verify typed paths, and commit admitted filesystem changes through one resource authority. 0.3 candidate; unreleased and not GA.", + "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 0.3 — Trusted Effects candidate", + title: "taskflow 0.3.0-beta.1 — Trusted Effects beta", brand: "taskflow", description: - "声明 coding-agent effect,验证类型化路径,让已准入的文件修改经过唯一 resource authority。0.3 candidate,未发布、未 GA。", + "声明 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 566fa9a5..f05ad37b 100644 --- a/website/app/[lang]/page.tsx +++ b/website/app/[lang]/page.tsx @@ -25,14 +25,14 @@ const copy = { localeZh: "中文", }, hero: { - eyebrow: "taskflow 0.3 · Trusted Effects candidate", + eyebrow: "taskflow 0.3.0-beta.1 · Trusted Effects beta", title: [ "Declare the effect.", "Verify the path.", "Commit through one authority.", ], 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 candidate · unreleased · not GA", + noteKicker: "0.3.0-beta.1 · beta channel · not GA", noteBody: "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: @@ -78,7 +78,7 @@ const copy = { ], }, install: { - label: "Stable 0.2.x host installs — candidate runs from source.", + label: "0.3.0-beta.1 host installs — select the beta channel.", copy: "Copy", copied: "Copied", guide: "Guide", @@ -187,10 +187,10 @@ const copy = { localeZh: "中文", }, hero: { - eyebrow: "taskflow 0.3 · Trusted Effects candidate", + eyebrow: "taskflow 0.3.0-beta.1 · Trusted Effects beta", title: ["声明 effect。", "验证路径。", "让一个 authority 负责提交。"], sub: "taskflow 把 coding-agent 工作变成可验证的运行时:显式任务图、类型化 effect 声明、隔离执行、受 resources 控制的文件提交,以及覆盖六个宿主的 ledger-backed 解释。", - noteKicker: "0.3 candidate · 未发布 · 未 GA", + noteKicker: "0.3.0-beta.1 · beta channel · 尚未 GA", noteBody: "智能体可以提出内容。对于已准入的已声明目标,resources transaction 是唯一最终提交者。ControlHost 目前是脚手架;store、审批、receipt 与 WebUI 仍是后续阶段,不是已交付的 GA 表面。", micro: "Resolve-only 不是 OS sandbox。未声明写入仍取决于宿主策略。", @@ -235,7 +235,7 @@ const copy = { ], }, install: { - label: "稳定 0.2.x 宿主安装;candidate 请从源码运行。", + label: "0.3.0-beta.1 宿主安装;请显式选择 beta channel。", copy: "复制", copied: "已复制", guide: "指南", @@ -341,7 +341,7 @@ export default async function HomePage({ "@context": "https://schema.org", "@type": "SoftwareApplication", name: "taskflow", - softwareVersion: "0.3.0-candidate", + softwareVersion: "0.3.0-beta.1", description: t.hero.sub, applicationCategory: "DeveloperApplication", operatingSystem: "Any", diff --git a/website/app/page.tsx b/website/app/page.tsx index cf4e6185..a9031001 100644 --- a/website/app/page.tsx +++ b/website/app/page.tsx @@ -2,7 +2,7 @@ import type { Metadata } from "next"; const title = "taskflow 0.3 — Trusted Effects for Coding Agents"; const description = - "Declare coding-agent effects, verify typed paths, and commit admitted filesystem changes through one resource authority. 0.3 candidate; unreleased and not GA."; + "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/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 2b5dac53..096bcfcc 100644 --- a/website/content/docs/en/getting-started.mdx +++ b/website/content/docs/en/getting-started.mdx @@ -6,7 +6,7 @@ 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 the current **0.3 Trusted Effects candidate**, start with the [Trusted Effects overview](/en/docs/trusted-effects) and use the repository checkout; 0.3 is unreleased and not GA. + 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. @@ -81,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 ``` @@ -98,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 ``` @@ -112,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 ``` @@ -120,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 58f09dac..7457e78c 100644 --- a/website/content/docs/en/index.mdx +++ b/website/content/docs/en/index.mdx @@ -3,7 +3,7 @@ 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." --- -> **0.3 candidate — unreleased and not GA.** The published package line remains 0.2.x until a human cuts and publishes `v0.3.0`. +> **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. 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. @@ -47,7 +47,7 @@ taskflow is a declarative runtime for coding-agent workflows. It turns a graph i ## Install on your host -The 0.3 candidate is currently developed from this repository. For the stable published host packages, follow the existing host guides and keep the package pins on the published 0.2.x line. +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. Every taskflow package requires **Node.js 22.19.0 or newer**. The candidate's source and fixture checks use pnpm from the repository checkout. diff --git a/website/content/docs/en/trusted-effects.mdx b/website/content/docs/en/trusted-effects.mdx index 90a2847d..47568aa2 100644 --- a/website/content/docs/en/trusted-effects.mdx +++ b/website/content/docs/en/trusted-effects.mdx @@ -2,7 +2,7 @@ title: Trusted Effects --- -> **0.3 candidate — unreleased and not GA.** This page describes the current Trusted Effects MVP implementation, not an npm release guarantee. +> **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**. 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 b33a5d29..d5da5a6a 100644 --- a/website/content/docs/zh-cn/getting-started.mdx +++ b/website/content/docs/zh-cn/getting-started.mdx @@ -6,7 +6,7 @@ description: 五分钟内运行你的第一个 taskflow。 taskflow 让你把多步骤的 agent 工作描述成一张声明式图。你不需要写一个一个调用子代理的脚本,只需声明节点和边——运行时会替你处理 fan-out、重试、缓存和续跑。 - 本指南覆盖稳定的 0.2.x 宿主安装路径。当前 **0.3 Trusted Effects candidate** 请先阅读 [Trusted Effects 总览](/zh-cn/docs/trusted-effects),并从仓库 checkout 运行;0.3 尚未发布,也不是 GA。 + 本指南介绍稳定的 0.2.x 宿主安装路径。对于 **0.3.0-beta.1**,请从 [Trusted Effects 总览](/zh-cn/docs/trusted-effects) 开始,并显式选择 npm 的 `beta` channel;beta 尚未 GA。 要最快地感受它,先跑一个看看。 @@ -81,7 +81,7 @@ taskflow 让你把多步骤的 agent 工作描述成一张声明式图。你不 ```bash title="安装 pi-taskflow" - pi install npm:pi-taskflow + pi install npm:pi-taskflow@beta ``` @@ -98,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 ``` @@ -112,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 ``` @@ -120,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 eec4f852..0d1c89ac 100644 --- a/website/content/docs/zh-cn/index.mdx +++ b/website/content/docs/zh-cn/index.mdx @@ -3,7 +3,7 @@ title: taskflow 0.3 文档 description: "面向 coding-agent 工作流的 Trusted Effects:声明 effect,验证类型化路径,让已准入的文件修改经过唯一 resource authority。" --- -> **0.3 candidate——未发布、未 GA。** 在人工创建并发布 `v0.3.0` 前,已发布 package 仍保持 0.2.x 版本线。 +> **0.3.0-beta.1——beta channel candidate,尚未 GA。** 这个页面描述为 beta 发布准备的 Trusted Effects MVP;0.3-C Control Plane 仍是后续 candidate 轨道。 taskflow 是面向 coding-agent 工作流的声明式运行时。它把任务图变成可验证的执行合同,让阶段隔离运行,并把中间 transcript 留在宿主对话之外。0.3 candidate 增加了 **Trusted Effects**:为已准入的文件 effect 提供类型化声明与受 resources 控制的提交路径。 @@ -47,7 +47,7 @@ taskflow 是面向 coding-agent 工作流的声明式运行时。它把任务图 ## 在你的宿主上使用 -0.3 candidate 当前从仓库源码开发。稳定的已发布宿主 package 仍请按现有宿主指南操作,并保持已发布的 0.2.x package pin。 +0.3 beta 可从 npm 的 `beta` channel 安装,也可从仓库源码运行。若未显式选择 beta,稳定宿主指南仍使用已发布的 0.2.x package pin。 所有 taskflow package 都要求 **Node.js 22.19.0 或更高版本**。Candidate 的源码与 fixture 检查使用仓库内的 pnpm。 diff --git a/website/content/docs/zh-cn/trusted-effects.mdx b/website/content/docs/zh-cn/trusted-effects.mdx index 58901317..5d14fa74 100644 --- a/website/content/docs/zh-cn/trusted-effects.mdx +++ b/website/content/docs/zh-cn/trusted-effects.mdx @@ -2,7 +2,7 @@ title: Trusted Effects --- -> **0.3 candidate——未发布、未 GA。** 本页描述当前 Trusted Effects MVP 实现,不是 npm 发布承诺。 +> **0.3.0-beta.1——beta channel candidate,尚未 GA。** 本页描述为 beta 发布准备的 Trusted Effects MVP;npm 发布仍是发版闸门。 Trusted Effects 把阶段的副作用写进合同。模型可以提出内容,但对于已准入的已声明文件目标,**resources transaction 是唯一最终提交者**。