diff --git a/CHANGELOG.md b/CHANGELOG.md index ca1161b3..be4aff84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,21 @@ All notable changes to taskflow are documented here. This project follows [Keep ## [Unreleased] +## [0.3.0-beta.2] — 2026-08-18 + +> npm `beta` dist-tag. **Not GA.** Control Plane slice: S2 Unix UDS + S3-min files-only store. Does **not** implement `#137` (`/tf web`) or `#95` (adaptive-authority isolation). TypeBox contracts and `wire-freeze.md` remain **PROPOSED**. `taskflow-control` is **not** in the ten-package publish set (`publishConfig.tag` stays `next`). + +### Added + +- **ControlHost Unix UDS (S2).** Winner listens on a user-scoped socket; loser attaches and uses the winner fencing epoch. Auto never silently degrades to standalone. Unix-only process tests; not part of the 3-OS process-supervisor matrix. +- **Files-only project ControlStore (S3-min).** Header + `commit-seq.json` + journal + store-self projections; exclusive writer lock; hardened UUID/`wx`/fsync/rename; process-level SIGKILL crash matrix (old-complete | new-complete | fail-closed). `ControlHost.start()` opens `projectStorePath` before dispatch on winner/standalone. Read-only RPCs: `control.store.header`, `control.store.status`. MCP `taskflow_control_status` is **not** added — that would force unpublished `taskflow-control` onto the ten-package face. + +### Notes + +- Rebased onto `0.3.0-beta.1.1` (`8fab2c9`). +- All publishable surfaces aligned to `0.3.0-beta.2`. +- Ready / merge / tag / npm still require a new instruction. + ## [0.3.0-beta.1.1] — 2026-08-18 > Hotfix on `0.3.0-beta.1`. npm `beta` dist-tag. **Not GA.** Does **not** implement `#137` (`/tf web`) or `#95` (adaptive-authority isolation). diff --git a/README.md b/README.md index d5b4cee1..2c9e01be 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-beta.1.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. +> **Status: 0.3.0-beta.2 — beta channel, not GA.** Ships the Trusted Effects MVP plus a Control Plane slice: Unix UDS ControlHost listen/attach (S2) and a files-only project ControlStore (S3-min). Wire contracts remain **PROPOSED**. `taskflow-control` is workspace-only until a later 11-package decision. Does **not** implement `#137` (`/tf web`) or `#95` (adaptive-authority isolation). ## The 0.3 idea diff --git a/README.zh-CN.md b/README.zh-CN.md index 9e60ec72..045e258d 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -21,7 +21,7 @@ **taskflow 是面向 coding-agent 工作流的声明式运行时。** 它把任务图变成可验证的执行合同,让阶段隔离运行,并把中间过程留在宿主对话之外。在 0.3 candidate 中,这份合同还可以描述每个阶段被允许提出的副作用。 -> **状态:0.3.0-beta.1.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.0-beta.2——beta channel,尚未 GA。** 含 Trusted Effects MVP,以及 Control Plane 切片:Unix UDS ControlHost listen/attach(S2)和 files-only project ControlStore(S3 最小集)。Wire 合同仍为 **PROPOSED**。`taskflow-control` 在十一包决定之前只在 workspace。不实现 `#137`(`/tf web`)和 `#95`(权限隔离)。 ## 0.3 的核心想法 diff --git a/RELEASE.md b/RELEASE.md index 7fb69052..6dd1e3f1 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -19,7 +19,7 @@ Dependency order: `taskflow-mcp-core`, `taskflow-hosts`, `taskflow-dsl`, `pi-tas ## One-time repository setup -The beta release path is `v0.3.0-beta.1.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. +The beta release path is `v0.3.0-beta.2`: 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) @@ -77,8 +77,8 @@ the matching annotated tag: ```sh git switch main git pull --ff-only origin main -git tag -a v0.3.0-beta.1.1 -m "Release v0.3.0-beta.1.1" -git push origin v0.3.0-beta.1.1 +git tag -a v0.3.0-beta.2 -m "Release v0.3.0-beta.2" +git push origin v0.3.0-beta.2 ``` `.github/workflows/publish.yml` then performs the complete release transaction: diff --git a/SECURITY.md b/SECURITY.md index e3e82b51..d4c2f144 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -28,7 +28,7 @@ The runtime has intentional hardening: `realpath`-based path containment, runId | Version | Support | |---------|---------| | Latest stable npm release (`v0.2.10`) | ✅ Active | -| `0.3.0-beta.1.1` beta channel | ⚠️ Pre-release; test-only support | +| `0.3.0-beta.2` beta channel | ⚠️ Pre-release; test-only support | | Earlier versions | ❌ Unsupported — upgrade to the latest npm release | ## Disclosure diff --git a/docs/internal/0.3.0-beta.2-release-plan.md b/docs/internal/0.3.0-beta.2-release-plan.md new file mode 100644 index 00000000..aea1b63f --- /dev/null +++ b/docs/internal/0.3.0-beta.2-release-plan.md @@ -0,0 +1,194 @@ +# 0.3.0-beta.2 — Control Plane 最小可跑增量 + +> **Status:** APPROVED — 2026-08-13 用户指令「开始,记得用 kanban」。三路审查 APPROVE-WITH-FIXES 已吸收。开工令:从 `origin/main` 拉 `rc/0.3.0-beta.2`,Kanban 链式施工;停在 Draft PR + exact-SHA CI,未经新指令不得 Ready / merge / tag / npm。 +> **Date:** 2026-08-13 +> **Reviews:** honesty / architecture / release-gate(`deleg_fc8005c7`) +> **Parent:** [0.3-C 作战计划](./0.3-c-control-plane-plan.md) · [P13](./p-adrs/P13-bootstrap-singleton.md) · [P14](./p-adrs/P14-controlstore-engine.md) +> **Baseline:** `origin/main` @ `561cfd3761c683e47fee5aa6d7fb7b26e69c4521` +> **Published beta.1:** annotated tag `v0.3.0-beta.1` **peels to** `561cfd3`(tag object 自身是 `6c75950`,那不是源 commit)。该 merge commit 的 subject 仍写着 candidate / do not publish,是 squash 文案残留,不以它否定 tag。 +> **Not GA.** Does not move npm `latest`. Does not ship WebUI, approval, or FileBroker. + +本地残留 `rc/0.3.0-trusted-effects` @ `5bbb566` **不是** `origin/main` 祖先。禁止在那棵脏树上继续写。开工命令: + +```bash +git fetch origin +git switch -c rc/0.3.0-beta.2 origin/main +``` + +## 1. One sentence + +beta.2 让 Control Plane **第一次能控**:Unix 上一个真 UDS ControlHost + 一份 files-only project store + 至少一次经 TE 提交的声明 `fs.write`;对外仍走 npm `beta`。 + +「能控」= 能起、能附、能把一次真实 run 记进 store、能经 TE 提交声明写。不是「能起进程、能查状态」。 + +## 2. Why this cut + +| 事实 | 含义 | +|---|---| +| beta.1 已发布 TE MVP | 下一档不该再扩 TE 主线 | +| 0.3-C 已拍板 TE + 重写 Control Plane + WebUI | **拍板的是方向**,不是 wire 已冻结 | +| S2 现状 | 锁 / hello / fencing / TE-only provider / standalone lease / stale-endpoint **单测级已有**;**没有** `node:net` listen/accept,没有打开 project store,`projectStorePath` 未接线,`renewLease` 未挂定时器,`attached` 只是锁层标签 | +| S3–S8 未做 | 不能跳到审批或 WebUI | +| `taskflow-control` 未进十包发布面 | version 仍是 `0.3.0`,`publishConfig.tag: next`,registry E404;description 还写着 “frozen TypeBox wire contracts”,与 `wire-freeze.md` = PROPOSED 矛盾 | + +历史分支 `backup/mac-0.3.0-beta.2` **不是**本档方向。 + +十个已发布包:`taskflow-core`、`taskflow-mcp-core`、`taskflow-hosts`、`taskflow-dsl`、`pi-taskflow`、`codex-taskflow`、`claude-taskflow`、`opencode-taskflow`、`grok-taskflow`、`hermes-taskflow`。 + +## 3. Decisions locked by review + +原 §9 四问不再开放: + +| Q | 决定 | +|---|---| +| P14 Status | **保持 Proposed**(wire 层)。本档只批准 **S3 store 引擎实施门**。可在 P14 加一行「beta.2 已批 S3 实施门」,不改 ADR Status。P15 完全不碰。 | +| 发布面 | **条件十一包**。仅当 tag 前 tarball dogfood(A8-pre)过线,才把 `taskflow-control@0.3.0-beta.2` 纳入发布面。否则只发十包,control 继续 workspace-only。禁止 `0.3.0` / `next` 出包。 | +| Dogfood 深度 | **必须**至少一次声明 `fs.write`,经 TE resources 提交(声明根内写 marker)。只读状态查询不够。 | +| S3 事件最小集 | **必须**能追加至少一条非审批 `CommandRecord`。默认载体 = A8 那次真实 run 的 `run.submit` + 同批 ControlEvents。不实现 admission、不签 Receipt、不用 approval kind。空 journal 会使 A4/A5/P14 原子批门空转。 | + +另锁: + +- **Registry / UserCoordinatorStore(P16 store)推迟。** 作战计划 S3 的后两块不进 beta.2。P13 `auto`「确保 registry」本档只部分满足,必须在诚实账本里写明。 +- **`projections/`** 本档只重建 store 自身投影(header / commit-seq / 已提交 command 索引),不做 run 视图投影(那是 S4/S6)。 + +## 4. In scope + +只收 **S2 收口 + S3 最小集**。 + +### 4.1 S2 收口 — 真 UDS 进程 + +已有、本档只做**进程级验证**(不是重写):禁 `auto→standalone` 静默回退;ExecutionProvider 只能 TE-backed。 + +必须新做: + +1. Unix UDS **server**:`listen` / `accept` 绑定 `singletonPaths` 的 endpoint。 +2. 败者是 **client `connect`**,走线上 hello,拿到赢者 fencing epoch。禁止只改锁层标签就叫 attach。 +3. 线上 hello-before-RPC + `protocolMajor` 偏差拒绝(P13 §5.5)。 +4. 两**进程**竞争同一 user singleton lock + 同一 endpoint;输者 attach 赢者,禁止 silent fork。 +5. 杀赢者后 stale socket → unlink → 新进程可重新 `listen`。 +6. `renewLease()` 挂定时器(代码已注明 S3 接线)。 +7. socket 权限显式 `0o600`(不要只靠 umask)。 +8. Unix UDS 是本档必过平台。Windows named pipe **非交付**。 + +### 4.2 S3 最小集 — 一份 project ledger + +1. files-only ControlStore:`header` + `commit-seq.json` + `journal/` + `projections/`。`commands/` / `receipts/` 可建空目录,**不签发 Receipt、不跑审批命令**。 +2. Header:`{ projectId, controlDomainId, schemaVersion, directoryBinding }`。 +3. `commitSeq` 单调且永不重编号(P11)。 +4. daemon / embedded / standalone 打开 **同一** project store。`projectStorePath` 必须在 `ControlHost.start()` 真正打开,且在 dispatch 前完成。 +5. 单写者;第二写者对 store mutation fail closed。 +6. 至少一种非审批 `CommandRecord`(`run.submit`)与其 events **同批原子提交**。 +7. 崩溃恢复:重读 journal → 重建本档允许的投影;禁止未验证混合态。 +8. P14 实施门本档收:原子批 crash 矩阵;temporary-path symlink 硬化(随机 UUID + 独占 `wx` + EEXIST 重试;rename 目标被换成 symlink → fail closed);recovery-racing-mutation fail closed。 +9. 信任模型保持 trusted-local-disk;整根一致回滚 **不承诺**。 +10. Control 可以写 **control 元数据**(`~/.taskflow/control/` lock/lease/claim/socket + project store)。**不得**写 run 声明的最终路径;那只经 `taskflow-core` resources。 + +### 4.3 宿主 / 发布 + +1. 从 `origin/main`(`561cfd3`)拉 `rc/0.3.0-beta.2`。 +2. 十包版本面跟到 `0.3.0-beta.2`。下列 pin **必须**同步,否则 `publish.yml` tag 校验必挂: + - `packages/*/plugin/plugin.json`(codex / claude / grok) + - `packages/*/plugin/.mcp.json`(codex / claude / grok) + - `packages/opencode-taskflow/plugin/opencode.json` + - `packages/hermes-taskflow/plugin/hermes.config.snippet.yaml` +3. `taskflow-control`:同一提交把 `version` 改为 `0.3.0-beta.2`,`publishConfig.tag` 改为 `beta`,`description` 去掉 “frozen”,改成 proposed / planned。**无论是否进 npm 都必须改**,避免 0.3.0 游离。 +4. `publish.yml` 的 `0.3.0-beta.*` 闸门 **已经**放行 beta.2。两条路径写死: + - **十包:** workflow 零功能改动,只改注释 / `RELEASE.md` 示例。 + - **十一包:** 同一 release commit 扩 `RELEASE_PACKAGE_NAMES`、`publish.yml` 版本循环 / `publish_one` / `verify_one`、`smoke-packed-packages.mjs`、release-notes 兜底。走 Draft PR 人工闸门。 +5. 发布决策在 **A8-pre(tarball dogfood)之后、打 tag 之前** 冻结,避免「npm 上还没有 beta.2,却要用 `@beta` 决定是否发 control」的循环。 + +## 5. Out of scope(硬禁区) + +- S4 完整 BoundPlan admission / maxActiveRuns 竞争 / reconcile-unknown 终态协议 +- S5 审批三模式 / park / CAS +- S6 完整 MCP/CLI 控制面(本档最多加 **只读** host status + store header) +- S7 WebUI +- S8 / L6 GA / npm `latest` / 正式 `0.3.0` +- Secret vault / live ServiceRef +- FileBroker / OS sandbox +- Adaptive / Retain / CharterArc 晋级 +- Windows named pipe ControlHost(lock-file 协调可做;pipe **non-GA**,release notes 必须写明) +- Registry 多 mount / UserCoordinatorStore 引擎 +- 搬 `feat/0.3.0` 或 `backup/mac-0.3.0-beta.2` 的旧代码 + +## 6. Acceptance + +全部必须有命令级证据。文档绿不算过。 + +| ID | 门槛 | 证据 | +|---|---|---| +| A1 | TE 8 deliverables 不回退 | Draft PR 的 **exact-SHA 全矩阵**:unit + `e2e-codex-mcp-full` + packed consumer + process-supervisor 3 OS。live Codex CLI @ `4524d2d` 只算 beta.1 历史证据,不为 beta.2 背书,本档不重跑。 | +| A2 | 两进程 UDS | Unix-only CI job(**不得**进 Windows process-supervisor 腿):进程 A listen;进程 B connect + 线上 hello + 拿到 epoch;杀 A 后 stale socket 恢复;`auto` 不能静默变 `standalone`。 | +| A2b | 线上控制面 | 赢者经 UDS 回答 `control.probe`(或等价只读 RPC);败者经 socket 收到同一 `fencingEpoch`。锁层标签不够。 | +| A3 | 同一 store | standalone 写入 header / journal / 至少一条 `run.submit` 后,第二次以 daemon/embedded 打开读到同一 `projectId` / `commit-seq`。 | +| A4 | 崩溃矩阵 | **进程级 SIGKILL**,不是抛异常 seam。注入点:① header 已 fsync、journal 未开始;② journal 追加中;③ projection 重建中。每点重启后只能是:旧完整态 **或** 新完整态 **或** fail-closed(拒绝打开 / 拒绝 mutation,可观察)。禁止混合态。 | +| A5 | 单写者 | 第二进程对同一 store 的 mutation 被拒绝。 | +| A6 | 权威边界 | 声明 `fs.write` 最终路径只经 `taskflow-core` resources。control 源码对 `writeFile`/`appendFile`/`createWriteStream` 的命中必须落在 control 元数据白名单(lock/lease/claim/socket/store),并用测试钉住。`resources/*` 语义本档不改。 | +| A7 | 打包 | typecheck + build + `test:pack`。若十一包:fresh consumer 能 import `taskflow-control@0.3.0-beta.2`。 | +| A8-pre | tag 前 dogfood | 用 `.release-tarballs` 里的 hermes-taskflow tarball 装进 scratch Hermes profile:`taskflow_version` = `0.3.0-beta.2`;list / verify / run;**一次声明 `fs.write` 经 TE 提交**;能读 host status + store header。CI 以 built-MCP fixture 为主。 | +| A8-post | 发布后核验 | `verify-published-package` + `npm view` 十包(或十一包)`beta=0.3.0-beta.2`。live Hermes `@beta` 复验可选,人工。 | +| A9 | 发布诚实 | `latest` 仍为 `0.2.10`;GitHub Release = prerelease;release notes 写 Windows pipe non-GA + resolve-only 不是 OS sandbox + 无审批/Receipt/WebUI + Registry/P16 store 未交付。 | + +已知 Windows process-supervisor 抖动 **不进入** beta.2 新门槛;既有 3-OS job 只回归 TE,不跑 UDS。 + +## 7. Honesty ledger + +**允许:** + +- Trusted Effects MVP 已在 `0.3.0-beta.1`(tag peel = `561cfd3`)。 +- beta.2 是 Control Plane **最小可跑**增量。 +- Unix 上可以起 UDS ControlHost,败者线上 attach,一份 project store,单写者,崩溃 fail-closed。 +- Control 写自己的 lock/store 元数据;run 声明写仍只经 TE。 +- Resolve-only 不是 OS sandbox;未声明路径仍取决于宿主策略。 +- Windows ControlHost / named pipe 不是本档交付。 +- Registry 与 UserCoordinatorStore 未交付。 + +**不许:** + +- “Control Plane 已完成 / 已冻结 / 已 GA” +- “可审批 / 有 Receipt / 有 WebUI” +- “FileBroker / OS sandbox” +- “Windows named pipe 已交付” +- 把 P8 / P14 / P15 的 **Proposed** 写成 Accepted +- 把 `wire-freeze.md` 偷偷改成 frozen,却没有 TypeBox + 测试证据 +- 把锁层 `attached` 说成已经有 UDS attach +- 把常量 `printf` run 说成「能控」 + +## 8. Implementation sequence + +1. **基线** — `git switch -c rc/0.3.0-beta.2 origin/main`。刷新 scoreboard:header 日期/tip;L5 对 **beta.1** 改 PASS(tag + publish run `31621938101`);L6 仍 FAIL;L4 live-Codex 标 historical。 +2. **S2 进程化** — UDS listen/accept/connect + 线上 hello + 进程级 A2/A2b。Unix-only CI job。 +3. **S3 store** — `packages/taskflow-control/src/store/**`。接上 `projectStorePath` 与 lease timer。先过 A3–A6。 +4. **宿主只读表面** — Hermes/MCP 暴露 host status + store header;确认 `test:e2e-hermes-mcp` 覆盖新工具。不把审批/Receipt 标成可用。 +5. **诚实性文案** — README / CHANGELOG / SECURITY / website / `RELEASE.md` / `publish.yml` 头注释;**必须改** `packages/taskflow-control/package.json` description。 +6. **版本面** — 十包 + pin 文件 + control version/tag。 +7. **A8-pre** — tarball scratch dogfood(含声明写)。这时才决定十包还是十一包。 +8. **人工闸门** — Draft PR → 审查 → 用户解锁 Ready → squash merge → **merged SHA 全矩阵绿** → 人工推 `v0.3.0-beta.2` → 只走 `publish.yml` → A8-post。 + +预估:S2 收口 2–3d,S3 最小集 3–4d,宿主+文案+发布链 1–2d。上限切片,不是日历承诺。 + +## 9. Files likely to change + +- `packages/taskflow-control/src/control-host.ts` +- `packages/taskflow-control/src/singleton.ts` +- `packages/taskflow-control/src/store/**`(新) +- `packages/taskflow-control/test/**`(含 Unix 进程级) +- `packages/taskflow-control/package.json`(version / tag / description) +- `packages/taskflow-mcp-core` / `packages/hermes-taskflow`(只读状态工具) +- 宿主 pin:上述 plugin.json / `.mcp.json` / `opencode.json` / `hermes.config.snippet.yaml` +- 若十一包:`scripts/pack-release-packages.mjs`、`.github/workflows/publish.yml`、`scripts/smoke-packed-packages.mjs` +- `docs/internal/0.3.0-ga-scoreboard.md`、`CHANGELOG.md`、`RELEASE.md`、公开 README/website +- `docs/internal/p-adrs/P14-controlstore-engine.md`(只加「beta.2 已批 S3 实施门」一行) + +不改:`packages/taskflow-core/src/resources/**` 的权威语义,除非发现 beta.1 回归。 + +## 10. Agent stop line + +Status 已是 **APPROVED**。允许按 §8 开工。 + +本档施工停在: + +- Draft PR + exact-SHA CI +- 诚实 scoreboard(L6 仍 FAIL) + +未经新的人工指令,禁止 Ready / merge / tag / npm / 把 beta.2 写成 GA。 diff --git a/docs/internal/0.3.0-ga-scoreboard.md b/docs/internal/0.3.0-ga-scoreboard.md index 5d397102..12678337 100644 --- a/docs/internal/0.3.0-ga-scoreboard.md +++ b/docs/internal/0.3.0-ga-scoreboard.md @@ -1,11 +1,13 @@ # 0.3 Trusted Effects — GA Scoreboard > Living ledger. **Do not claim GA** unless L6 is PASS with human tag evidence. +> +> **beta.2 (2026-08-18):** `rc/0.3.0-beta.2` tip `bee8586` — Draft PR **#142** exact-SHA CI GREEN (run `32144803693`, 10/10 jobs) as **candidate** evidence only. **`v0.3.0-beta.2` is NOT tagged or published.** Published 0.3 beta remains **`v0.3.0-beta.1`** (tag `561cfd3`, publish run `31621938101`). `#137` / `#95` out of scope. -**Last updated:** 2026-08-11 (tip `0551f62`; residual recheck + S-H2 closed) -**Branch:** `rc/0.3.0-trusted-effects` @ `0551f62` -**Code/product tip:** includes TE MVP + store harden `96a32e8` + ADV High harden `cbb4131` + S-H2 static composition `580daa0` (+ docs) -**Current evidence:** exact-SHA remote CI **GREEN** — GitHub Actions run [31466355338](https://github.com/heggria/taskflow/actions/runs/31466355338) on Draft PR #122 head `0551f62` (full matrix PASS incl. windows process-supervisor). Prior green milestones: `cbb4131` run 31463823993; Windows harness `e7c5e31` era. Local residual focused suites **110/110** (adv-high-fix, resource-file-transaction, store, verifier-discover, verify-effects, effects-composition-cache). RC pipeline logs at `/tmp/taskflow-03-rc-final/` (evidence SHA `6071fb2`) still back audit --prod clean + built Codex MCP 16/16 fixture. Live Codex CLI E2E **PASS** on tip `4524d2d` (worker-mac, 2026-08-11) — evidence `docs/internal/evidence/e2e-codex-live-4524d2d.md`. L5/L6 remain FAIL (no tag/publish). +**Last updated:** 2026-08-18 (tip `bee8586`; beta.2 Draft PR #142 CI + L5 for `v0.3.0-beta.1`) +**Branch:** `rc/0.3.0-beta.2` @ `bee8586` +**Code/product tip:** TE MVP + beta.2 Control Plane slice (S2 Unix UDS ControlHost, S3-min files-only project ControlStore, version freeze `0.3.0-beta.2`) +**Current evidence:** beta.2 **candidate** exact-SHA remote CI **GREEN** — GitHub Actions run [32144803693](https://github.com/heggria/taskflow/actions/runs/32144803693) on Draft PR #142 head `bee8586` (10/10 jobs: unit node 22/24, e2e codex MCP network-free, packed consumer 10 pkgs, process supervisor ubuntu/macos/windows incl. UDS, build, CodeQL, website). **This is candidate evidence, not a release.** Published `v0.3.0-beta.1` (tag `561cfd3`, publish run `31621938101`) → **L5 PASS (beta.1)**. Prior green milestone: PR #122 head `0551f62` run `31466355338` (beta.1 candidate). Live Codex CLI E2E @ `4524d2d` (worker-mac, 2026-08-11) is **historical** beta.1-era evidence — **not** beta.2 evidence. **L6 still FAIL.** --- @@ -13,15 +15,15 @@ | Level | Status | Evidence | Notes | |-------|--------|----------|-------| -| L1 local | **PASS** | tip `0551f62`: CI node 22/24 + residual focused 110/110; audit --prod clean at RC log `6071fb2` | typecheck/suite via CI on tip | -| L2 contract | **PASS** | tip `0551f62`: CI full unit + build + pack + MCP e2e; residual focused green | exact-SHA CI GREEN run 31466355338 | +| L1 local | **PASS** | beta.2 candidate `bee8586`: CI node 22/24 (run 32144803693) | typecheck/suite via CI on candidate tip | +| L2 contract | **PASS** | beta.2 candidate `bee8586`: CI full unit + build + pack (10 pkgs) + MCP e2e | exact-SHA CI GREEN run 32144803693 (PR #142) | | L3 browser/electron | **N/A** | — | | -| L4 real-environment | **PASS (built-MCP fixture + live Codex CLI @ 4524d2d)** | CI built-MCP e2e on tip; live `test:e2e-codex` PASS on worker-mac @ `4524d2d` (A→B→C + TE fs.write + ledger why) — see `docs/internal/evidence/e2e-codex-live-4524d2d.md` | both fixture and live legs evidenced on tip family | -| L5 released | **FAIL** | no `v0.3.0-beta.1` tag/publish | human gate | -| L6 ga | **FAIL** | L5 missing | **NOT GA** | +| L4 real-environment | **PASS (built-MCP fixture)** | CI built-MCP e2e on beta.2 candidate tip; live Codex CLI E2E @ `4524d2d` is **historical** (beta.1-era, worker-mac 2026-08-11) — see `docs/internal/evidence/e2e-codex-live-4524d2d.md` | both fixture legs evidenced on candidate family; live-Codex leg not beta.2 evidence | +| L5 released | **PASS (for `v0.3.0-beta.1`)** | tag `561cfd3`, publish run `31621938101` — `v0.3.0-beta.1` on npm `beta` dist-tag. **`v0.3.0-beta.2` NOT published** | human gate | +| L6 ga | **FAIL** | beta.1 released but not GA; beta.2 not tagged/published | **NOT GA** | -**Highest proven for the current code candidate:** **L4 real-environment (built-MCP fixture)** with exact-SHA remote CI green on tip `f5284da` — live-Codex-CLI leg closed on tip `4524d2d`. ADV High + S-H2 closed; beta release and GA remain human gates. -**User-facing claim allowed:** *Trusted Effects beta.1 tip `f5284da` is green on exact-SHA CI (run 31613909075) with TE + security hardenings; beta release and GA remain open.* +**Highest proven for the current code candidate (`rc/0.3.0-beta.2`):** **L4 real-environment (built-MCP fixture)** with exact-SHA remote CI green on candidate `bee8586` (run 32144803693, DR #142). Live-Codex-CLI is a **historical** beta.1-era leg (closed on `4524d2d`) — **not** beta.2 evidence. L5 **PASS** for published `v0.3.0-beta.1`; beta.2 and 0.3 GA remain human gates. +**User-facing claim allowed:** *Trusted Effects `v0.3.0-beta.1` is published on npm `beta` (tag `561cfd3`); the beta.2 candidate `bee8586` is green on exact-SHA CI (run 32144803693, Draft PR #142) but **not released**; 0.3 GA remains 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. --- @@ -52,6 +54,7 @@ | 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) | +| beta.2 candidate CI (2026-08-18) | Draft PR #142 head `bee8586` (rc/0.3.0-beta.2): GitHub Actions run 32144803693 (CI #434) full matrix PASS (10/10: test node 22/24, e2e codex MCP network-free, packed consumer 10 pkgs, process supervisor ubuntu/macos/windows incl. UDS attach, build dist, CodeQL, website export). **Candidate evidence only — `v0.3.0-beta.2` NOT tagged/published; L6 still FAIL.** | ## Explicitly deferred non-blocker @@ -62,8 +65,15 @@ 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 +# BETA.2 CANDIDATE (2026-08-18) — Draft PR #142 head bee8586: exact-SHA CI GREEN, +# GitHub Actions run 32144803693 (CI #434), 10/10 jobs: +# https://github.com/heggria/taskflow/actions/runs/32144803693 +# → test node 22/24, e2e codex MCP network-free, packed consumer 10 pkgs, +# process supervisor ubuntu/macos/windows (incl. Unix UDS attach, not skipped +# onto Windows), build dist, CodeQL, website export. +# NOT a release: v0.3.0-beta.2 not tagged/published. Published beta = +# v0.3.0-beta.1 (tag 561cfd3, publish run 31621938101). L6 still FAIL. +# (The verbose matrix below is HISTORICAL beta.1-era evidence at e7c5e31.) # → 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 @@ -92,8 +102,10 @@ 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) -# LIVE RERUN on 4524d2d (worker-mac): +# LIVE RERUN on 4524d2d (HISTORICAL, beta.1-era — not beta.2 evidence): # 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. +# L5 = PASS for published v0.3.0-beta.1 (tag 561cfd3, run 31621938101). +# beta.2 = candidate-only (RUN 32144803693 GREEN) — NOT tagged/published. +# 0.3 GA (L6): FAIL until a human tags/releases v0.3.0. ``` diff --git a/docs/internal/p-adrs/P14-controlstore-engine.md b/docs/internal/p-adrs/P14-controlstore-engine.md index 538c6a24..421d79af 100644 --- a/docs/internal/p-adrs/P14-controlstore-engine.md +++ b/docs/internal/p-adrs/P14-controlstore-engine.md @@ -42,6 +42,8 @@ ### 实施门(S3,非 wire-freeze 门) +> beta.2 已批 **S3 store 引擎实施门**(原子批、单写者、symlink 硬化、recovery-vs-mutation)。**不改**本 ADR Status:仍为 Proposed。 + - 原子批 crash/power-loss 矩阵(写前/写中/写后重开:旧完整态 | 新完整态 | fail-closed,绝无未验证混合态)。 - 并发写者/单写者证明;fencing 与 stale owner 拒绝。 - temporary-path symlink 硬化(archive Round 53 教训: 随机 UUID + 独占 `"wx"` 创建 + EEXIST 重试;rename 目标被并发替换为 symlink 的残余竞态必须 fail closed 或文档化为 OS 下限)。 diff --git a/package.json b/package.json index 27a26469..01e2710c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pi-taskflow-monorepo", - "version": "0.3.0-beta.1.1", + "version": "0.3.0-beta.2", "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 cffdd139..790a82bf 100644 --- a/packages/claude-taskflow/package.json +++ b/packages/claude-taskflow/package.json @@ -1,6 +1,6 @@ { "name": "claude-taskflow", - "version": "0.3.0-beta.1.1", + "version": "0.3.0-beta.2", "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 9bb58e69..15a2aae1 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.3.0-beta.1.1", + "version": "0.3.0-beta.2", "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 81d506cd..1da1b8a4 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.3.0-beta.1.1", "claude-taskflow-mcp"] + "args": ["-y", "-p", "claude-taskflow@0.3.0-beta.2", "claude-taskflow-mcp"] } } } diff --git a/packages/claude-taskflow/test/mcp-server.test.ts b/packages/claude-taskflow/test/mcp-server.test.ts index 0a64baf1..e2749d6f 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.3.0-beta.1.1"); + assert.equal(res.result.serverInfo.version, "0.3.0-beta.2"); }); 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 8f951ff0..24f5c2dd 100644 --- a/packages/codex-taskflow/package.json +++ b/packages/codex-taskflow/package.json @@ -1,6 +1,6 @@ { "name": "codex-taskflow", - "version": "0.3.0-beta.1.1", + "version": "0.3.0-beta.2", "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 a21a1d97..36a25385 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.3.0-beta.1.1", + "version": "0.3.0-beta.2", "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 df583fbc..90d1f5ec 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.3.0-beta.1.1", "codex-taskflow-mcp"], + "args": ["-y", "-p", "codex-taskflow@0.3.0-beta.2", "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 74788d76..453a8639 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.3.0-beta.1.1"); + assert.equal(res.result.serverInfo.version, "0.3.0-beta.2"); }); 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 87066b02..837a4f72 100644 --- a/packages/grok-taskflow/package.json +++ b/packages/grok-taskflow/package.json @@ -1,6 +1,6 @@ { "name": "grok-taskflow", - "version": "0.3.0-beta.1.1", + "version": "0.3.0-beta.2", "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 2bcd7732..361f0ab2 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.3.0-beta.1.1", + "version": "0.3.0-beta.2", "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 40d3f24d..362339ae 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.3.0-beta.1.1", "grok-taskflow-mcp"], + "args": ["-y", "-p", "grok-taskflow@0.3.0-beta.2", "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 391ff67b..9fb0c473 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.3.0-beta.1.1"); + assert.equal(res.result.serverInfo.version, "0.3.0-beta.2"); }); 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 06b57b23..1ebe2dd8 100644 --- a/packages/hermes-taskflow/package.json +++ b/packages/hermes-taskflow/package.json @@ -1,6 +1,6 @@ { "name": "hermes-taskflow", - "version": "0.3.0-beta.1.1", + "version": "0.3.0-beta.2", "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 236d4337..6ff8c046 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.3.0-beta.1.1", "hermes-taskflow-mcp"] + args: ["-y", "-p", "hermes-taskflow@0.3.0-beta.2", "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 fc940da8..81bae677 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.3.0-beta.1.1"); + assert.equal(res.result.serverInfo.version, "0.3.0-beta.2"); }); 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 7a79c549..dc58b44a 100644 --- a/packages/opencode-taskflow/package.json +++ b/packages/opencode-taskflow/package.json @@ -1,6 +1,6 @@ { "name": "opencode-taskflow", - "version": "0.3.0-beta.1.1", + "version": "0.3.0-beta.2", "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 1170b89b..ee3bee9a 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.3.0-beta.1.1", "opencode-taskflow-mcp"], + "command": ["npx", "-y", "-p", "opencode-taskflow@0.3.0-beta.2", "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 dd7de8bb..5b01b5d3 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.3.0-beta.1.1"); + assert.equal(res.result.serverInfo.version, "0.3.0-beta.2"); }); 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 29c0bd6b..117b4ac4 100644 --- a/packages/pi-taskflow/package.json +++ b/packages/pi-taskflow/package.json @@ -1,7 +1,7 @@ { "name": "pi-taskflow", - "version": "0.3.0-beta.1.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.", + "version": "0.3.0-beta.2", + "description": "A declarative, verifiable graph of task nodes for the Pi coding agent \u2014 statically verified before it runs, with dynamic fan-out, gates, isolated subagent context, resumable runs, and saveable commands.", "keywords": [ "pi-package", "pi", diff --git a/packages/taskflow-control/package.json b/packages/taskflow-control/package.json index cb70de2a..7e75ed21 100644 --- a/packages/taskflow-control/package.json +++ b/packages/taskflow-control/package.json @@ -1,89 +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": "*" - } + "name": "taskflow-control", + "version": "0.3.0-beta.2", + "description": "0.3-C control plane core (proposed wire): ControlHost daemon/supervisor/standalone contracts, Unix UDS singleton attach, files-only project ControlStore, hello-before-RPC negotiation, and the Trusted Effects (taskflow-core resources) provider as the only execution authority. TypeBox contracts and wire-freeze.md remain PROPOSED — not frozen.", + "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 index 9c22acb4..9c63b96f 100644 --- a/packages/taskflow-control/src/control-host.ts +++ b/packages/taskflow-control/src/control-host.ts @@ -30,7 +30,10 @@ import { type SingletonAcquireResult, type SingletonPaths, } from "./singleton.ts"; +import { connectUdsClient, startUdsServer, type UdsClient, type UdsServer } from "./uds.ts"; +import { openControlStore, type ControlStore } from "./store/index.ts"; import { CONTROL_WIRE_SCHEMA_VERSION, PROTOCOL_MAJOR, type NegotiationHandshake } from "./schema/index.ts"; +import type { CommandRecord, ControlEvent } from "./schema/index.ts"; import type { ExecutionProvider } from "./te-provider.ts"; import type { RunSnapshot } from "./schema/run.ts"; @@ -99,6 +102,11 @@ export class ControlHost { #fencingEpoch = 0; #holderId?: string; #releaseSingleton?: () => void; + #udsServer?: UdsServer; + #udsClient?: UdsClient; + #leaseTimer?: NodeJS.Timeout; + #store?: ControlStore; + #standaloneLeasePath?: string; constructor(options: ControlHostOptions) { if (!options.provider || options.provider.kind !== "te-resources") { @@ -173,18 +181,53 @@ export class ControlHost { this.#holderId = result.holderId; this.#fencingEpoch = result.fencingEpoch; this.#releaseSingleton = result.release; + // S2: the winner listens on the user singleton endpoint so + // losers attach over a real Unix socket (D32 — lock-layer + // labels are NOT attach). + try { + this.#udsServer = await startUdsServer({ + endpointPath: this.paths.endpointPath, + serverHello: this.#helloGate.serverHello, + requiredFeatures: this.#options.requiredFeatures, + getFencingEpoch: () => this.#fencingEpoch, + handleRpc: (method, params, fencingEpoch) => this.#dispatchCore(method, params, fencingEpoch), + }); + } catch (error) { + try { result.release(); } catch { /* best effort */ } + this.#releaseSingleton = undefined; + throw bootstrapFailed( + `won the user singleton but could not listen on control endpoint ${this.paths.endpointPath}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + this.#startLeaseTimer(); } else { - // Loser attaches as a client to the winner (D32) — never - // an independent multi-mount authority. + // Loser attaches as a client to the winner (D32) — the + // fencing epoch is received on the wire in the hello-ack, + // never trusted from the lock file alone (A2b). + let client: UdsClient; + try { + client = await connectUdsClient({ + endpointPath: result.endpoint, + clientHello: this.#clientHello(), + }); + } catch (error) { + throw bootstrapFailed( + `attached to the winner but could not reach its control endpoint ${result.endpoint}: ${error instanceof Error ? error.message : String(error)}`, + ); + } this.#singleton = "attached"; this.#holderId = result.holderId; - this.#fencingEpoch = result.fencingEpoch; + this.#fencingEpoch = client.fencingEpoch; + this.#udsClient = client; } this.#state = "started"; break; } } + this.#openProjectStore(); } catch (error) { + try { this.#store?.close(); } catch { /* best effort */ } + this.#store = undefined; 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)}`); @@ -195,15 +238,26 @@ export class ControlHost { /** * Hello-before-RPC dispatch. Every method call must follow a successful * hello (P4) and carry a fencingEpoch >= the current lease epoch (P16). + * + * An ATTACHED host routes its RPCs over the Unix socket to the winner's + * control plane (its own hello happened on the wire at connect time). */ async dispatch(method: string, params: unknown, context: { fencingEpoch: number }): Promise { + if (this.#singleton === "attached" && this.#udsClient !== undefined) { + return this.#udsClient.rpc(method, params, context.fencingEpoch); + } if (!this.#helloGate.greeted) { throw helloRequiredError(); } - if (context.fencingEpoch < this.#fencingEpoch) { + return this.#dispatchCore(method, params, context.fencingEpoch); + } + + /** RPC core without the in-process hello gate (the UDS layer enforces its own). */ + async #dispatchCore(method: string, params: unknown, fencingEpoch: number): Promise { + if (fencingEpoch < this.#fencingEpoch) { throw new ControlError( "TF_AUTHORITY_REVOKED", - `fencing epoch ${context.fencingEpoch} is stale; host epoch is ${this.#fencingEpoch}`, + `fencing epoch ${fencingEpoch} is stale; host epoch is ${this.#fencingEpoch}`, { recoveryAction: "refresh", sideEffects: "none" }, ); } @@ -213,6 +267,23 @@ export class ControlHost { case "control.probe": { return await this.provider.probe() as unknown as T; } + case "control.store.header": { + return this.#requireStore().header as unknown as T; + } + case "control.store.status": { + return this.#requireStore().snapshot() as unknown as T; + } + case "commands.submit": { + const body = params as { command?: CommandRecord; events?: ControlEvent[] }; + if (!body || typeof body !== "object" || body.command === undefined || !Array.isArray(body.events)) { + throw new ControlError( + "TF_COMMAND_FAILED", + "commands.submit requires { command, events }", + { recoveryAction: "retry-new-command", sideEffects: "none" }, + ); + } + return this.#requireStore().appendBatch({ command: body.command, events: body.events }) as unknown as T; + } case "runs.status": { const runId = typeof params === "object" && params !== null && "runId" in params ? String((params as { runId: unknown }).runId) @@ -245,6 +316,33 @@ export class ControlHost { } stop(): void { + if (this.#leaseTimer) { + clearInterval(this.#leaseTimer); + this.#leaseTimer = undefined; + } + if (this.#store) { + try { this.#store.close(); } catch { /* best effort */ } + this.#store = undefined; + } + if (this.#standaloneLeasePath) { + try { fs.unlinkSync(this.#standaloneLeasePath); } catch { /* best effort */ } + this.#standaloneLeasePath = undefined; + } + const server = this.#udsServer; + this.#udsServer = undefined; + const client = this.#udsClient; + this.#udsClient = undefined; + if (server) { + // Winner: stop accepting, drop live connections, then unlink OUR + // socket while we still hold the lock (a live owner's endpoint is + // never touched by recovery; unlinking after release could race a + // fresh winner's listen). + void server.close().catch(() => { /* best effort */ }); + try { fs.unlinkSync(this.paths.endpointPath); } catch { /* best effort */ } + } + if (client) { + try { client.close(); } catch { /* best effort */ } + } if (this.#releaseSingleton) { try { this.#releaseSingleton(); } catch { /* best effort */ } this.#releaseSingleton = undefined; @@ -254,6 +352,36 @@ export class ControlHost { this.#state = "stopped"; } + /** + * The host's client-side handshake when attaching to a winner: same build + * identity, but a connecting host demands nothing (requiredFeatures) and + * offers the features it would require from clients (P4 symmetric check). + */ + #clientHello(): NegotiationHandshake { + const base = this.#options.serverHello ?? defaultServerHello(); + return { + ...base, + requiredFeatures: [], + offeredFeatures: [...(this.#options.requiredFeatures ?? [])], + }; + } + + /** Keep the coordinator lease valid while the winner holds the singleton. */ + #startLeaseTimer(): void { + const ttl = this.#options.leaseTtlMs ?? 30_000; + const intervalMs = Math.max(1_000, Math.floor(ttl / 3)); + const timer = setInterval(() => { + try { + this.renewLease(); + } catch { + /* best effort — the lease only gates fencing */ + } + }, intervalMs); + // Never keep a process alive just to renew a lease. + timer.unref(); + this.#leaseTimer = timer; + } + #acquireStandaloneLease(): void { const leasePath = `${this.paths.controlHome}/standalone-lease.json`; const holderId = this.#options.holderId ?? `standalone-${defaultProcessIdentity().pid}`; @@ -280,6 +408,32 @@ export class ControlHost { } this.#holderId = holderId; this.#fencingEpoch = 1; + this.#standaloneLeasePath = leasePath; + } + + #openProjectStore(): void { + const storePath = this.#options.projectStorePath; + if (!storePath) return; + // Attached clients read store state over UDS; only the writer opens it. + if (this.#singleton === "attached") return; + try { + this.#store = openControlStore(storePath); + } catch (error) { + throw bootstrapFailed( + `could not open project ControlStore at ${storePath}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + #requireStore(): ControlStore { + if (!this.#store) { + throw new ControlError( + "TF_JOURNAL_UNAVAILABLE", + "ControlHost has no project store open (pass projectStorePath and start as winner/standalone)", + { recoveryAction: "none", sideEffects: "none" }, + ); + } + return this.#store; } } diff --git a/packages/taskflow-control/src/errors.ts b/packages/taskflow-control/src/errors.ts index 569a2f66..c267dea3 100644 --- a/packages/taskflow-control/src/errors.ts +++ b/packages/taskflow-control/src/errors.ts @@ -94,3 +94,27 @@ export function assertClosedControlCode(code: string): asserts code is ControlEr throw new Error(`TF_COMMAND_FAILED: not a closed 0.3-C wire error code: ${code}`); } } + +/** Map any thrown value to a closed wire ErrorEnvelope (P4). */ +export function errorToEnvelope(error: unknown): ErrorEnvelope { + if (error instanceof ControlError) return error.toEnvelope(); + const message = error instanceof Error ? error.message : String(error); + return { + code: "TF_COMMAND_FAILED", + message, + recoveryAction: "retry-new-command", + sideEffects: "none", + }; +} + +/** Rehydrate a ControlError from a wire ErrorEnvelope (transport layer). */ +export function errorFromEnvelope(envelope: ErrorEnvelope): ControlError { + return new ControlError(envelope.code, envelope.message, { + recoveryAction: envelope.recoveryAction, + sideEffects: envelope.sideEffects, + ...(envelope.commandId !== undefined ? { commandId: envelope.commandId } : {}), + ...(envelope.commitSeq !== undefined ? { commitSeq: envelope.commitSeq } : {}), + ...(envelope.controlDomainId !== undefined ? { controlDomainId: envelope.controlDomainId } : {}), + ...(envelope.projectId !== undefined ? { projectId: envelope.projectId } : {}), + }); +} diff --git a/packages/taskflow-control/src/index.ts b/packages/taskflow-control/src/index.ts index cfa47dbe..b9cae8a4 100644 --- a/packages/taskflow-control/src/index.ts +++ b/packages/taskflow-control/src/index.ts @@ -1,10 +1,10 @@ /** * 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). + * Exposes the proposed TypeBox wire contracts (`./schema` — not frozen), + * ControlHost daemon/supervisor/standalone, Unix UDS singleton attach, + * files-only project ControlStore, 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. @@ -15,8 +15,10 @@ export * from "./errors.ts"; export * from "./modes.ts"; export * from "./hello.ts"; export * from "./singleton.ts"; +export * from "./uds.ts"; export * from "./te-provider.ts"; export * from "./control-host.ts"; +export * from "./store/index.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/store/index.ts b/packages/taskflow-control/src/store/index.ts new file mode 100644 index 00000000..392faffd --- /dev/null +++ b/packages/taskflow-control/src/store/index.ts @@ -0,0 +1,14 @@ +export { + CONTROL_CRASH_ENV, + ControlStore, + controlStorePaths, + maybeCrash, + openControlStore, + writeJsonAtomicHardened, +} from "./store.ts"; +export type { + CommitBatchInput, + ControlCrashPoint, + ControlStoreSnapshot, + OpenControlStoreOptions, +} from "./store.ts"; diff --git a/packages/taskflow-control/src/store/store.ts b/packages/taskflow-control/src/store/store.ts new file mode 100644 index 00000000..f73b6c42 --- /dev/null +++ b/packages/taskflow-control/src/store/store.ts @@ -0,0 +1,523 @@ +/** + * Files-only ControlStore (beta.2 S3-minimum). + * + * Layout: header + commit-seq.json + journal/ + projections/ + empty + * commands/ + receipts/. Journal is the authority; projections are + * store-self only (submitted-command index). Single writer via exclusive + * lock file. Atomic writes use UUID temp + wx + fsync + rename, and fail + * closed if the destination is a symlink. + * + * P14 ADR Status stays Proposed. This is the S3 engine implementation gate. + */ +import * as crypto from "node:crypto"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { Value } from "typebox/value"; +import { ControlError } from "../errors.ts"; +import { + CONTROL_WIRE_SCHEMA_VERSION, + CommandRecordSchema, + ControlEventSchema, + ControlStoreHeaderSchema, + type CommandRecord, + type ControlEvent, + type ControlStoreHeader, + type ControlStoreStatus, +} from "../schema/index.ts"; + +export const CONTROL_CRASH_ENV = "TASKFLOW_CONTROL_CRASH_AT"; + +export type ControlCrashPoint = + | "header-fsynced" + | "journal-append" + | "projection-rebuild"; + +const LOCK_NAME = "writer.lock"; +const HEADER_NAME = "header"; +const COMMIT_SEQ_NAME = "commit-seq.json"; +const JOURNAL_DIR = "journal"; +const JOURNAL_SEGMENT = "000001.jsonl"; +const PROJECTIONS_DIR = "projections"; +const COMMAND_INDEX = "commands.json"; +const COMMANDS_DIR = "commands"; +const RECEIPTS_DIR = "receipts"; + +export interface OpenControlStoreOptions { + projectId?: string; + controlDomainId?: string; + now?: () => number; +} + +export interface CommitBatchInput { + command: CommandRecord; + events: readonly ControlEvent[]; +} + +export interface ControlStoreSnapshot { + header: ControlStoreHeader; + commitSeq: number; + status: ControlStoreStatus; +} + +interface JournalBatch { + recordKind: "commit-batch"; + commitSeq: number; + command: CommandRecord; + events: ControlEvent[]; +} + +interface WriterLock { + pid: number; + acquiredAt: number; +} + +export class ControlStore { + readonly storePath: string; + #header: ControlStoreHeader; + #commitSeq: number; + #status: ControlStoreStatus = "healthy"; + #closed = false; + #commandIndex = new Map(); + + constructor(storePath: string, header: ControlStoreHeader, commitSeq: number, commands: readonly CommandRecord[]) { + this.storePath = storePath; + this.#header = header; + this.#commitSeq = commitSeq; + for (const command of commands) this.#commandIndex.set(command.commandId, command); + } + + get header(): ControlStoreHeader { + return this.#header; + } + + get commitSeq(): number { + return this.#commitSeq; + } + + get status(): ControlStoreStatus { + return this.#status; + } + + readCommand(commandId: string): CommandRecord | undefined { + this.#assertOpen(); + return this.#commandIndex.get(commandId); + } + + snapshot(): ControlStoreSnapshot { + this.#assertOpen(); + return { header: this.#header, commitSeq: this.#commitSeq, status: this.#status }; + } + + appendBatch(input: CommitBatchInput): { commitSeq: number; command: CommandRecord } { + this.#assertOpen(); + if (this.#status === "fail-closed") { + throw durabilityFailed("control store is fail-closed; refusing mutation"); + } + if (input.command.kind !== "run.submit") { + throw durabilityFailed(`beta.2 store only accepts run.submit; got ${input.command.kind}`); + } + if (input.events.length === 0) { + throw durabilityFailed("atomic batch requires at least one ControlEvent"); + } + for (const event of input.events) { + if (!Value.Check(ControlEventSchema, event)) { + throw durabilityFailed("ControlEvent failed schema check"); + } + } + if (!Value.Check(CommandRecordSchema, { + ...input.command, + projectId: this.#header.projectId, + controlDomainId: this.#header.controlDomainId, + })) { + // Validate after identity rewrite below; pre-check kind/required fields first. + } + + const nextSeq = this.#commitSeq + 1; + const command: CommandRecord = { + ...input.command, + projectId: this.#header.projectId, + controlDomainId: this.#header.controlDomainId, + firstCommitSeq: nextSeq, + lastCommitSeq: nextSeq, + }; + if (!Value.Check(CommandRecordSchema, command)) { + throw durabilityFailed("CommandRecord failed schema check"); + } + if (this.#commandIndex.has(command.commandId)) { + throw new ControlError( + "TF_IDEMPOTENCY_CONFLICT", + `command ${command.commandId} is already committed`, + { recoveryAction: "none", sideEffects: "none" }, + ); + } + const events: ControlEvent[] = input.events.map((event, index) => ({ + ...event, + projectId: this.#header.projectId, + controlDomainId: this.#header.controlDomainId, + commitSeq: nextSeq, + commandId: command.commandId, + commandEventIndex: index, + })); + + const record: JournalBatch = { + recordKind: "commit-batch", + commitSeq: nextSeq, + command, + events, + }; + crashDuringJournalAppend(journalPath(this.storePath)); + appendJsonLineDurable(journalPath(this.storePath), record); + writeJsonAtomicHardened(commitSeqPath(this.storePath), { commitSeq: nextSeq }); + this.#commandIndex.set(command.commandId, command); + maybeCrash("projection-rebuild"); + writeJsonAtomicHardened(commandIndexPath(this.storePath), Object.fromEntries(this.#commandIndex)); + this.#commitSeq = nextSeq; + this.#status = "healthy"; + return { commitSeq: nextSeq, command }; + } + + close(): void { + if (this.#closed) return; + this.#closed = true; + releaseWriterLock(this.storePath); + } + + #assertOpen(): void { + if (this.#closed) throw durabilityFailed("control store is closed"); + } +} + +export function openControlStore(storePath: string, options: OpenControlStoreOptions = {}): ControlStore { + const resolved = path.resolve(storePath); + ensureDirectory(resolved); + acquireWriterLock(resolved); + + try { + const existingHeader = readHeader(resolved); + if (existingHeader === undefined) { + const header = createHeader(resolved, options); + writeJsonAtomicHardened(headerPath(resolved), header); + maybeCrash("header-fsynced"); + writeJsonAtomicHardened(commitSeqPath(resolved), { commitSeq: 0 }); + ensureDirectory(path.join(resolved, JOURNAL_DIR)); + ensureDirectory(path.join(resolved, PROJECTIONS_DIR)); + ensureDirectory(path.join(resolved, COMMANDS_DIR)); + ensureDirectory(path.join(resolved, RECEIPTS_DIR)); + fsyncDirectory(resolved); + return new ControlStore(resolved, header, 0, []); + } + + const recovered = recoverFromJournal(resolved, existingHeader); + return new ControlStore(resolved, existingHeader, recovered.commitSeq, recovered.commands); + } catch (error) { + releaseWriterLock(resolved); + throw error; + } +} + +function createHeader(storePath: string, options: OpenControlStoreOptions): ControlStoreHeader { + const st = fs.statSync(storePath); + return { + projectId: options.projectId ?? crypto.randomUUID(), + controlDomainId: options.controlDomainId ?? crypto.randomUUID(), + schemaVersion: CONTROL_WIRE_SCHEMA_VERSION, + directoryBinding: { + canonicalPath: fs.realpathSync(storePath), + device: String(st.dev), + inode: String(st.ino), + }, + }; +} + +function readHeader(storePath: string): ControlStoreHeader | undefined { + const file = headerPath(storePath); + if (!fs.existsSync(file)) return undefined; + assertNotSymlink(file); + const raw = JSON.parse(fs.readFileSync(file, "utf8")); + if (!Value.Check(ControlStoreHeaderSchema, raw)) { + throw durabilityFailed(`malformed control store header at ${file}`); + } + return raw; +} + +function recoverFromJournal( + storePath: string, + header: ControlStoreHeader, +): { commitSeq: number; commands: CommandRecord[] } { + ensureDirectory(path.join(storePath, JOURNAL_DIR)); + ensureDirectory(path.join(storePath, PROJECTIONS_DIR)); + ensureDirectory(path.join(storePath, COMMANDS_DIR)); + ensureDirectory(path.join(storePath, RECEIPTS_DIR)); + + const batches = readJournalBatches(journalPath(storePath)); + truncateTornJournal(journalPath(storePath)); + const commands: CommandRecord[] = []; + let commitSeq = 0; + for (const batch of batches) { + if (batch.recordKind !== "commit-batch") { + throw durabilityFailed("journal contains an unknown record kind"); + } + if (batch.commitSeq !== commitSeq + 1) { + throw durabilityFailed(`journal commitSeq gap: expected ${commitSeq + 1}, got ${batch.commitSeq}`); + } + if ( + batch.command.projectId !== header.projectId + || batch.command.controlDomainId !== header.controlDomainId + ) { + throw durabilityFailed("journal command identity does not match store header"); + } + commands.push(batch.command); + commitSeq = batch.commitSeq; + } + + const onDiskSeq = readCommitSeqFile(storePath); + if (onDiskSeq !== undefined && onDiskSeq > commitSeq) { + throw durabilityFailed( + `mixed state: commit-seq.json (${onDiskSeq}) is ahead of journal (${commitSeq})`, + ); + } + writeJsonAtomicHardened(commitSeqPath(storePath), { commitSeq }); + writeJsonAtomicHardened( + commandIndexPath(storePath), + Object.fromEntries(commands.map((command) => [command.commandId, command])), + ); + return { commitSeq, commands }; +} + +function readCommitSeqFile(storePath: string): number | undefined { + const file = commitSeqPath(storePath); + if (!fs.existsSync(file)) return undefined; + assertNotSymlink(file); + const raw = JSON.parse(fs.readFileSync(file, "utf8")) as { commitSeq?: unknown }; + if (typeof raw.commitSeq !== "number" || !Number.isInteger(raw.commitSeq) || raw.commitSeq < 0) { + throw durabilityFailed(`malformed commit-seq.json at ${file}`); + } + return raw.commitSeq; +} + +function readJournalBatches(filePath: string): JournalBatch[] { + if (!fs.existsSync(filePath)) return []; + assertNotSymlink(filePath); + const raw = fs.readFileSync(filePath); + const text = raw.toString("utf8"); + const lines = text.split("\n"); + const complete = text.endsWith("\n") ? lines.slice(0, -1) : lines.slice(0, -1); + // Torn tail (no terminating newline on last line) is discarded — old-complete. + const batches: JournalBatch[] = []; + for (const line of complete) { + if (!line) continue; + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + throw durabilityFailed("journal contains a complete but unreadable line"); + } + batches.push(parsed as JournalBatch); + } + return batches; +} + +function truncateTornJournal(filePath: string): void { + if (!fs.existsSync(filePath)) return; + assertNotSymlink(filePath); + const raw = fs.readFileSync(filePath); + if (raw.length === 0 || raw[raw.length - 1] === 0x0a) return; + const lastNewline = raw.lastIndexOf(0x0a); + const fd = fs.openSync(filePath, "r+"); + try { + fs.ftruncateSync(fd, lastNewline === -1 ? 0 : lastNewline + 1); + fs.fsyncSync(fd); + } finally { + fs.closeSync(fd); + } +} + +function acquireWriterLock(storePath: string): void { + const lockPath = path.join(storePath, LOCK_NAME); + for (let attempt = 0; attempt < 3; attempt++) { + try { + const fd = fs.openSync(lockPath, "wx", 0o600); + try { + fs.writeFileSync(fd, JSON.stringify({ pid: process.pid, acquiredAt: Date.now() } satisfies WriterLock)); + fs.fsyncSync(fd); + } finally { + fs.closeSync(fd); + } + fsyncDirectory(storePath); + return; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + if (!isLockHolderAlive(lockPath)) { + try { fs.unlinkSync(lockPath); } catch { /* retry wx */ } + continue; + } + throw durabilityFailed(`control store already has a live writer at ${storePath}`); + } + } + throw durabilityFailed(`could not acquire control store writer lock at ${storePath}`); +} + +function releaseWriterLock(storePath: string): void { + const lockPath = path.join(storePath, LOCK_NAME); + try { + const raw = JSON.parse(fs.readFileSync(lockPath, "utf8")) as WriterLock; + if (raw.pid !== process.pid) return; + fs.unlinkSync(lockPath); + } catch { + /* best effort */ + } +} + +function isLockHolderAlive(lockPath: string): boolean { + try { + const raw = JSON.parse(fs.readFileSync(lockPath, "utf8")) as WriterLock; + if (typeof raw.pid !== "number") return false; + process.kill(raw.pid, 0); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ESRCH") return false; + // EPERM: process exists but we cannot signal it — treat as live. + if ((error as NodeJS.ErrnoException).code === "EPERM") return true; + return false; + } +} + +export function maybeCrash(point: ControlCrashPoint): void { + if (process.env[CONTROL_CRASH_ENV] === point) { + process.kill(process.pid, "SIGKILL"); + } +} + +/** SIGKILL after a torn (unterminated) journal write — process-level A4. */ +function crashDuringJournalAppend(filePath: string): void { + if (process.env[CONTROL_CRASH_ENV] !== "journal-append") return; + ensureDirectory(path.dirname(filePath)); + const fd = fs.openSync(filePath, "a", 0o600); + try { + fs.writeSync(fd, Buffer.from("{\"recordKind\":\"commit-batch\"")); + fs.fsyncSync(fd); + } finally { + fs.closeSync(fd); + } + process.kill(process.pid, "SIGKILL"); +} + +export function writeJsonAtomicHardened(filePath: string, value: unknown): void { + assertNotSymlink(filePath); + const directory = path.dirname(filePath); + ensureDirectory(directory); + let temp = ""; + let fd = -1; + for (let attempt = 0; attempt < 8; attempt++) { + temp = path.join(directory, `.${crypto.randomUUID()}`); + try { + fd = fs.openSync(temp, "wx", 0o600); + break; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "EEXIST") continue; + throw error; + } + } + if (fd < 0) throw durabilityFailed(`could not create exclusive temp file for ${filePath}`); + try { + fs.writeFileSync(fd, `${JSON.stringify(value)}\n`); + fs.fsyncSync(fd); + } finally { + fs.closeSync(fd); + } + try { + assertNotSymlink(filePath); + fs.renameSync(temp, filePath); + fsyncDirectory(directory); + } catch (error) { + try { fs.unlinkSync(temp); } catch { /* best effort */ } + throw error; + } +} + +function appendJsonLineDurable(filePath: string, record: unknown): void { + assertNotSymlink(filePath); + ensureDirectory(path.dirname(filePath)); + const existed = fs.existsSync(filePath); + if (existed) { + const raw = fs.readFileSync(filePath); + if (raw.length > 0 && raw[raw.length - 1] !== 0x0a) { + const lastNewline = raw.lastIndexOf(0x0a); + const repairFd = fs.openSync(filePath, "r+"); + try { + fs.ftruncateSync(repairFd, lastNewline === -1 ? 0 : lastNewline + 1); + fs.fsyncSync(repairFd); + } finally { + fs.closeSync(repairFd); + } + } + } + const fd = fs.openSync(filePath, "a", 0o600); + try { + const data = Buffer.from(`${JSON.stringify(record)}\n`); + let offset = 0; + while (offset < data.length) offset += fs.writeSync(fd, data, offset, data.length - offset); + fs.fsyncSync(fd); + } finally { + fs.closeSync(fd); + } + if (!existed) fsyncDirectory(path.dirname(filePath)); +} + +function assertNotSymlink(filePath: string): void { + try { + const st = fs.lstatSync(filePath); + if (st.isSymbolicLink()) { + throw durabilityFailed(`refusing to touch symlink at ${filePath}`); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return; + throw error; + } +} + +function ensureDirectory(directory: string): void { + fs.mkdirSync(directory, { recursive: true, mode: 0o700 }); +} + +function fsyncDirectory(directory: string): void { + try { + const fd = fs.openSync(directory, "r"); + try { fs.fsyncSync(fd); } finally { fs.closeSync(fd); } + } catch { + /* some filesystems refuse directory fsync */ + } +} + +function durabilityFailed(message: string): ControlError { + return new ControlError("TF_DURABILITY_FAILED", message, { + recoveryAction: "none", + sideEffects: "unknown", + }); +} + +function headerPath(storePath: string): string { + return path.join(storePath, HEADER_NAME); +} + +function commitSeqPath(storePath: string): string { + return path.join(storePath, COMMIT_SEQ_NAME); +} + +function journalPath(storePath: string): string { + return path.join(storePath, JOURNAL_DIR, JOURNAL_SEGMENT); +} + +function commandIndexPath(storePath: string): string { + return path.join(storePath, PROJECTIONS_DIR, COMMAND_INDEX); +} + +export function controlStorePaths(storePath: string) { + return { + header: headerPath(storePath), + commitSeq: commitSeqPath(storePath), + journal: journalPath(storePath), + commandIndex: commandIndexPath(storePath), + lock: path.join(storePath, LOCK_NAME), + }; +} diff --git a/packages/taskflow-control/src/uds.ts b/packages/taskflow-control/src/uds.ts new file mode 100644 index 00000000..82b5c803 --- /dev/null +++ b/packages/taskflow-control/src/uds.ts @@ -0,0 +1,419 @@ +/** + * Unix domain socket transport for the ControlHost (beta.2 S2 收口 — A2/A2b). + * + * The winner of the user singleton lock `listen`s on `singletonPaths().endpointPath`; + * every other process `connect`s as a client. The wire is newline-delimited + * JSON (one frame per line): + * + * {"type":"hello","hello":} + * {"type":"hello-ack","ok":true,"serverHello":<...>,"fencingEpoch":N} + * {"type":"hello-ack","ok":false,"error":} + * {"type":"rpc","id":N,"method":"control.probe","params":...,"fencingEpoch":N} + * {"type":"rpc-result","id":N,"ok":true,"result":...} + * {"type":"rpc-result","id":N,"ok":false,"error":} + * + * Hello-before-RPC (P4) is enforced per connection: the first frame MUST be a + * hello, a failed hello closes the socket, and a `protocolMajor` mismatch is + * rejected on the wire (TF_PROTOCOL_INCOMPATIBLE). The socket permission is + * explicitly chmod'ed to 0o600 — never umask luck (plan §4.1). + * + * Unix-only transport: Windows named pipe is explicitly NON-GA in 0.3-C. + */ + +import * as fs from "node:fs"; +import * as net from "node:net"; +import { bootstrapFailed, ControlError, errorFromEnvelope, errorToEnvelope, protocolError } from "./errors.ts"; +import { createHelloGate } from "./hello.ts"; +import type { ErrorEnvelope, NegotiationHandshake } from "./schema/transport.ts"; + +// --------------------------------------------------------------------------- +// Wire frames +// --------------------------------------------------------------------------- + +export type UdsFrame = + | { type: "hello"; hello: NegotiationHandshake } + | { type: "hello-ack"; ok: true; serverHello: NegotiationHandshake; fencingEpoch: number } + | { type: "hello-ack"; ok: false; error: ErrorEnvelope } + | { type: "rpc"; id: number; method: string; params?: unknown; fencingEpoch: number } + | { type: "rpc-result"; id: number; ok: true; result: unknown } + | { type: "rpc-result"; id: number; ok: false; error: ErrorEnvelope }; + +function encodeFrame(frame: UdsFrame): string { + return JSON.stringify(frame) + "\n"; +} + +function parseFrame(line: string): Record { + const raw: unknown = JSON.parse(line); + if (raw === null || typeof raw !== "object" || Array.isArray(raw)) { + throw new Error("control wire frame must be a JSON object"); + } + return raw as Record; +} + +/** Control is unreachable/not answering → TF_JOURNAL_UNAVAILABLE (fail closed). */ +function controlUnavailable(message: string): ControlError { + return new ControlError("TF_JOURNAL_UNAVAILABLE", message, { recoveryAction: "refresh", sideEffects: "none" }); +} + +// --------------------------------------------------------------------------- +// Server (winner: listen/accept) +// --------------------------------------------------------------------------- + +export interface UdsServerOptions { + /** Socket path from `singletonPaths().endpointPath`. */ + endpointPath: string; + /** The server's own handshake, returned to compatible clients. */ + serverHello: NegotiationHandshake; + /** Features the server demands from clients (P4). */ + requiredFeatures?: readonly string[]; + /** The winner's current fencing epoch, sent in the hello-ack (P16). */ + getFencingEpoch: () => number; + /** Dispatch one RPC after a successful per-connection hello. */ + handleRpc: (method: string, params: unknown, fencingEpoch: number) => Promise; +} + +export interface UdsServer { + readonly endpointPath: string; + close(): Promise; +} + +/** + * Start listening on a Unix socket. The socket file is chmod'ed to 0o600 + * immediately after bind (plan §4.1). Listen failure (e.g. a live foreign + * socket already bound) fails closed with TF_BOOTSTRAP_FAILED — the caller + * owns the singleton lock and must release it. + */ +export function startUdsServer(options: UdsServerOptions): Promise { + const sockets = new Set(); + const server = net.createServer((socket) => { + sockets.add(socket); + socket.on("close", () => sockets.delete(socket)); + handleServerConnection(socket, options); + }); + + return new Promise((resolve, reject) => { + const onError = (error: NodeJS.ErrnoException): void => { + server.removeListener("listening", onListening); + reject(bootstrapFailed(`cannot listen on control endpoint ${options.endpointPath}: ${error.message}`)); + }; + const onListening = (): void => { + server.removeListener("error", onError); + try { + fs.chmodSync(options.endpointPath, 0o600); + } catch (error) { + server.close(); + reject(bootstrapFailed(`cannot set 0o600 on control endpoint ${options.endpointPath}: ${error instanceof Error ? error.message : String(error)}`)); + return; + } + // Post-listen server errors (EMFILE etc.) must not crash the winner. + server.on("error", () => { /* best effort */ }); + resolve({ + endpointPath: options.endpointPath, + close: () => + new Promise((closeResolve) => { + for (const socket of sockets) socket.destroy(); + server.close(() => closeResolve()); + }), + }); + }; + server.once("error", onError); + server.once("listening", onListening); + server.listen(options.endpointPath); + }); +} + +function handleServerConnection(socket: net.Socket, options: UdsServerOptions): void { + // Per-connection hello gate (P4): the first frame must be a hello. + const gate = createHelloGate(options.serverHello, { requiredFeatures: options.requiredFeatures }); + let greeted = false; + let buffer = ""; + let destroyed = false; + + const send = (frame: UdsFrame): void => { + if (destroyed) return; + socket.write(encodeFrame(frame)); + }; + const failClosed = (frame: UdsFrame): void => { + send(frame); + destroyed = true; + socket.destroy(); + }; + + socket.setEncoding("utf8"); + socket.on("data", (chunk: string) => { + buffer += chunk; + let index: number; + while ((index = buffer.indexOf("\n")) >= 0) { + const line = buffer.slice(0, index).trim(); + buffer = buffer.slice(index + 1); + if (!line) continue; + let raw: Record; + try { + raw = parseFrame(line); + } catch (error) { + failClosed({ + type: greeted ? "rpc-result" : "hello-ack", + ...(greeted ? { id: 0 } : {}), + ok: false, + error: errorToEnvelope(error), + } as UdsFrame); + return; + } + void handleFrame(raw); + } + }); + socket.on("error", () => { + destroyed = true; + socket.destroy(); + }); + + const handleFrame = async (raw: Record): Promise => { + if (raw.type === "hello") { + if (greeted) { + failClosed({ type: "hello-ack", ok: false, error: errorToEnvelope(protocolError("duplicate hello on a control channel")) }); + return; + } + const verdict = gate.hello(raw.hello); + if (!verdict.ok) { + failClosed({ type: "hello-ack", ok: false, error: verdict.error.toEnvelope() }); + return; + } + greeted = true; + send({ + type: "hello-ack", + ok: true, + serverHello: gate.serverHello, + fencingEpoch: options.getFencingEpoch(), + }); + return; + } + if (raw.type === "rpc") { + if (!greeted) { + failClosed({ + type: "rpc-result", + id: typeof raw.id === "number" ? raw.id : 0, + ok: false, + error: errorToEnvelope(protocolError("hello must precede any RPC on this control channel")), + }); + return; + } + const id = raw.id; + if (typeof id !== "number") { + failClosed({ type: "rpc-result", id: 0, ok: false, error: errorToEnvelope(protocolError("rpc frame requires a numeric id")) }); + return; + } + const method = typeof raw.method === "string" ? raw.method : ""; + if (!method) { + failClosed({ type: "rpc-result", id, ok: false, error: errorToEnvelope(protocolError("rpc frame requires a method name")) }); + return; + } + const fencingEpoch = typeof raw.fencingEpoch === "number" ? raw.fencingEpoch : 0; + try { + const result = await options.handleRpc(method, raw.params, fencingEpoch); + send({ type: "rpc-result", id, ok: true, result }); + } catch (error) { + send({ type: "rpc-result", id, ok: false, error: errorToEnvelope(error) }); + } + return; + } + failClosed({ + type: greeted ? "rpc-result" : "hello-ack", + ...(greeted ? { id: 0 } : {}), + ok: false, + error: errorToEnvelope(protocolError(`unknown control wire frame type ${JSON.stringify(raw.type)}`)), + } as UdsFrame); + }; +} + +// --------------------------------------------------------------------------- +// Client (loser: connect/attach) +// --------------------------------------------------------------------------- + +export interface UdsClientOptions { + endpointPath: string; + clientHello: NegotiationHandshake; + /** Budget for connect + hello (default 5s). */ + connectTimeoutMs?: number; + /** Budget for a single RPC round-trip (default 10s). */ + rpcTimeoutMs?: number; +} + +export interface UdsClient { + readonly endpointPath: string; + /** The winner's fencing epoch, received on the wire in the hello-ack (P16). */ + readonly fencingEpoch: number; + /** The winner's handshake, received on the wire. */ + readonly serverHello: NegotiationHandshake; + rpc(method: string, params?: unknown, fencingEpoch?: number): Promise; + close(): void; +} + +/** + * Connect to the winner's Unix socket, perform the online hello, and return a + * client bound to the winner's fencing epoch. A protocol/schema/feature + * rejection on the wire rejects with the corresponding ControlError + * (e.g. TF_PROTOCOL_INCOMPATIBLE); an unreachable endpoint fails closed with + * TF_JOURNAL_UNAVAILABLE. + */ +export function connectUdsClient(options: UdsClientOptions): Promise { + const connectTimeoutMs = options.connectTimeoutMs ?? 5_000; + const rpcTimeoutMs = options.rpcTimeoutMs ?? 10_000; + + return new Promise((resolve, reject) => { + const socket = net.connect(options.endpointPath); + let buffer = ""; + let settled = false; + let closed = false; + let nextId = 1; + let wireEpoch = 0; + let wireServerHello: NegotiationHandshake | undefined; + const pending = new Map void; reject: (error: unknown) => void }>(); + + const rejectPending = (error: unknown): void => { + for (const [, entry] of pending) entry.reject(error); + pending.clear(); + }; + + const timer = setTimeout(() => { + if (settled) return; + settled = true; + rejectPending(controlUnavailable(`control endpoint ${options.endpointPath} did not answer hello within ${connectTimeoutMs}ms`)); + socket.destroy(); + reject(controlUnavailable(`control endpoint ${options.endpointPath} did not answer hello within ${connectTimeoutMs}ms`)); + }, connectTimeoutMs); + + socket.on("connect", () => { + socket.write(encodeFrame({ type: "hello", hello: options.clientHello })); + }); + + socket.on("error", (error) => { + if (!settled) { + settled = true; + clearTimeout(timer); + reject(controlUnavailable(`cannot connect to control endpoint ${options.endpointPath}: ${error.message}`)); + return; + } + rejectPending(controlUnavailable(`control endpoint ${options.endpointPath} closed: ${error.message}`)); + }); + + socket.setEncoding("utf8"); + socket.on("data", (chunk: string) => { + buffer += chunk; + let index: number; + while ((index = buffer.indexOf("\n")) >= 0) { + const line = buffer.slice(0, index).trim(); + buffer = buffer.slice(index + 1); + if (!line) continue; + let raw: Record; + try { + raw = parseFrame(line); + } catch (error) { + rejectPending(controlUnavailable(`control endpoint ${options.endpointPath} sent a malformed frame: ${error instanceof Error ? error.message : String(error)}`)); + continue; + } + if (raw.type === "hello-ack") { + if (settled) continue; + settled = true; + clearTimeout(timer); + if (raw.ok === true) { + if (typeof raw.fencingEpoch !== "number" || raw.serverHello === undefined) { + reject(controlUnavailable(`control endpoint ${options.endpointPath} sent a malformed hello-ack`)); + socket.destroy(); + return; + } + wireEpoch = raw.fencingEpoch; + wireServerHello = raw.serverHello as NegotiationHandshake; + const client: UdsClient = { + endpointPath: options.endpointPath, + get fencingEpoch() { + return wireEpoch; + }, + get serverHello() { + return wireServerHello as NegotiationHandshake; + }, + rpc: (method: string, params?: unknown, fencingEpoch?: number) => + rpc(socket, pending, () => closed, () => wireEpoch, () => nextId++, rpcTimeoutMs, method, params, fencingEpoch), + close: () => { + closed = true; + rejectPending(controlUnavailable(`control client for ${options.endpointPath} is closed`)); + socket.destroy(); + }, + }; + resolve(client); + } else { + socket.destroy(); + reject(errorFromEnvelope(raw.error as ErrorEnvelope)); + } + return; + } + if (raw.type === "rpc-result") { + const id = raw.id; + if (typeof id !== "number") continue; + const entry = pending.get(id); + if (!entry) continue; + pending.delete(id); + if (raw.ok === true) entry.resolve(raw.result); + else entry.reject(errorFromEnvelope(raw.error as ErrorEnvelope)); + return; + } + } + }); + + socket.on("close", () => { + if (!settled) { + settled = true; + clearTimeout(timer); + reject(controlUnavailable(`control endpoint ${options.endpointPath} closed before hello`)); + return; + } + rejectPending(controlUnavailable(`control endpoint ${options.endpointPath} closed`)); + }); + }); +} + +function rpc( + socket: net.Socket, + pending: Map void; reject: (error: unknown) => void }>, + isClosed: () => boolean, + getEpoch: () => number, + nextId: () => number, + rpcTimeoutMs: number, + method: string, + params: unknown, + fencingEpoch?: number, +): Promise { + if (isClosed()) { + return Promise.reject(controlUnavailable(`control client for ${socket.remoteAddress ?? "unix"} is closed`)); + } + const id = nextId(); + return new Promise((resolve, reject) => { + const rpcTimer = setTimeout(() => { + pending.delete(id); + reject(controlUnavailable(`control RPC ${JSON.stringify(method)} timed out after ${rpcTimeoutMs}ms`)); + }, rpcTimeoutMs); + pending.set(id, { + resolve: (value: unknown) => { + clearTimeout(rpcTimer); + resolve(value as T); + }, + reject: (error: unknown) => { + clearTimeout(rpcTimer); + reject(error); + }, + }); + const frame: UdsFrame = { + type: "rpc", + id, + method, + ...(params !== undefined ? { params } : {}), + fencingEpoch: fencingEpoch ?? getEpoch(), + }; + try { + socket.write(encodeFrame(frame)); + } catch (error) { + pending.delete(id); + clearTimeout(rpcTimer); + reject(controlUnavailable(`control RPC ${JSON.stringify(method)} could not be sent: ${error instanceof Error ? error.message : String(error)}`)); + } + }); +} diff --git a/packages/taskflow-control/test/control-host.test.ts b/packages/taskflow-control/test/control-host.test.ts index fdcf8902..13e72d2e 100644 --- a/packages/taskflow-control/test/control-host.test.ts +++ b/packages/taskflow-control/test/control-host.test.ts @@ -190,3 +190,53 @@ test("control-host: stop releases the singleton so a fresh host can win", async assert.equal(status.singleton, "won"); second.stop(); }); + +test("control-host: standalone opens projectStorePath and a later host sees the same ledger (A3)", async () => { + const paths = makePaths(); + const storePath = path.join(paths.controlHome, "project-store"); + const first = new ControlHost(hostOptions(paths, { mode: "standalone", holderId: "s1", projectStorePath: storePath })); + await first.start(); + first.hello(clientHello); + const header = await first.dispatch<{ projectId: string }>("control.store.header", undefined, { fencingEpoch: 1 }); + assert.match(header.projectId, /^[0-9a-f-]{36}$/i); + const submitted = await first.dispatch<{ commitSeq: number }>("commands.submit", { + command: { + commandId: "00000000-0000-0000-0000-0000000000bb", + kind: "run.submit", + requestHash: "a".repeat(64), + callerPrincipal: "cli", + authorizationContextHash: "a".repeat(64), + projectId: header.projectId, + controlDomainId: header.projectId, + status: "accepted", + firstCommitSeq: 1, + lastCommitSeq: 1, + recordedAt: 1, + }, + events: [{ + eventId: "00000000-0000-0000-0000-0000000000cc", + schemaVersion: 1, + controlDomainId: header.projectId, + streamId: "command:00000000-0000-0000-0000-0000000000bb", + streamSeq: 1, + commitSeq: 1, + commandId: "00000000-0000-0000-0000-0000000000bb", + commandEventIndex: 0, + causationId: "00000000-0000-0000-0000-0000000000bb", + correlationId: "00000000-0000-0000-0000-0000000000bb", + projectId: header.projectId, + recordedAt: 1, + payload: { kind: "command.recorded", commandId: "00000000-0000-0000-0000-0000000000bb" }, + }], + }, { fencingEpoch: 1 }); + assert.equal(submitted.commitSeq, 1); + first.stop(); + + const second = new ControlHost(hostOptions(paths, { mode: "standalone", holderId: "s2", projectStorePath: storePath })); + await second.start(); + second.hello(clientHello); + const status = await second.dispatch<{ header: { projectId: string }; commitSeq: number }>("control.store.status", undefined, { fencingEpoch: 1 }); + assert.equal(status.header.projectId, header.projectId); + assert.equal(status.commitSeq, 1); + second.stop(); +}); diff --git a/packages/taskflow-control/test/fixtures/store-mutate.ts b/packages/taskflow-control/test/fixtures/store-mutate.ts new file mode 100644 index 00000000..80997727 --- /dev/null +++ b/packages/taskflow-control/test/fixtures/store-mutate.ts @@ -0,0 +1,67 @@ +/** + * ControlStore process-test fixture. + * + * Environment: + * TF_TEST_STORE_PATH — project store directory + * TF_TEST_ACTION — init | mutate + * TASKFLOW_CONTROL_CRASH_AT — header-fsynced | journal-append | projection-rebuild + */ +import { randomUUID } from "node:crypto"; +import { CONTROL_WIRE_SCHEMA_VERSION } from "../../src/schema/index.ts"; +import { openControlStore } from "../../src/store/index.ts"; + +const storePath = process.env.TF_TEST_STORE_PATH; +const action = process.env.TF_TEST_ACTION ?? "init"; +if (!storePath) { + console.log("ERROR fixture requires TF_TEST_STORE_PATH"); + process.exit(1); +} + +const SHA256 = "a".repeat(64); +const commandId = process.env.TF_TEST_COMMAND_ID ?? randomUUID(); +const projectId = process.env.TF_TEST_PROJECT_ID ?? "00000000-0000-0000-0000-000000000001"; +const domainId = process.env.TF_TEST_DOMAIN_ID ?? "00000000-0000-0000-0000-000000000001"; + +try { + const store = openControlStore(storePath, { projectId, controlDomainId: domainId }); + if (action === "mutate") { + store.appendBatch({ + command: { + commandId, + kind: "run.submit", + requestHash: SHA256, + callerPrincipal: "crash-fixture", + authorizationContextHash: SHA256, + projectId, + controlDomainId: domainId, + status: "accepted", + firstCommitSeq: 1, + lastCommitSeq: 1, + recordedAt: Date.now(), + }, + events: [ + { + eventId: randomUUID(), + schemaVersion: CONTROL_WIRE_SCHEMA_VERSION, + controlDomainId: domainId, + streamId: `command:${commandId}`, + streamSeq: 1, + commitSeq: 1, + commandId, + commandEventIndex: 0, + causationId: commandId, + correlationId: commandId, + projectId, + recordedAt: Date.now(), + payload: { kind: "command.recorded", commandId }, + }, + ], + }); + } + console.log(`READY ${JSON.stringify({ projectId: store.header.projectId, commitSeq: store.commitSeq })}`); + store.close(); + process.exit(0); +} catch (error) { + console.log(`ERROR ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); +} diff --git a/packages/taskflow-control/test/fixtures/uds-fail-closed.ts b/packages/taskflow-control/test/fixtures/uds-fail-closed.ts new file mode 100644 index 00000000..de37cb8c --- /dev/null +++ b/packages/taskflow-control/test/fixtures/uds-fail-closed.ts @@ -0,0 +1,67 @@ +/** + * UDS process-test fixture: `auto` mode must FAIL CLOSED at the process level + * when the control cannot run — it must never silently become `standalone`. + * + * The parent points TF_TEST_CONTROL_HOME at a path that is a regular file + * (not a directory), so the singleton bootstrap throws. Prints: + * FAILED-CLOSED — error code + message from start() + * STATE + * and exits non-zero. + */ + +import { ControlHost } from "../../src/control-host.ts"; +import { createTeExecutionProvider, type TeExecutionAuthority } from "../../src/te-provider.ts"; + +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); +} + +const controlHome = process.env.TF_TEST_CONTROL_HOME; +if (!controlHome) { + console.log("ERROR fixture requires TF_TEST_CONTROL_HOME"); + process.exit(1); +} + +const host = new ControlHost({ + mode: "auto", + controlHome, + provider: fakeProvider(), + holderId: "uds-fail-closed", +}); + +host.start() + .then((status) => { + console.log(`STARTED ${JSON.stringify({ state: status.state, singleton: status.singleton })}`); + }) + .catch((error: unknown) => { + const code = error instanceof Error && "code" in error ? String((error as { code: unknown }).code) : "UNKNOWN"; + console.log(`FAILED-CLOSED ${JSON.stringify({ code, message: error instanceof Error ? error.message : String(error) })}`); + console.log(`STATE ${host.status.state} ${host.status.singleton}`); + process.exitCode = 1; + }); diff --git a/packages/taskflow-control/test/fixtures/uds-loser.ts b/packages/taskflow-control/test/fixtures/uds-loser.ts new file mode 100644 index 00000000..4d850e4c --- /dev/null +++ b/packages/taskflow-control/test/fixtures/uds-loser.ts @@ -0,0 +1,106 @@ +/** + * UDS process-test fixture: a ControlHost LOSER in `auto` mode competing for + * the same user singleton lock + endpoint as a live winner. + * + * start() must attach to the winner over the Unix socket, receive the winner's + * fencing epoch on the wire, then route `control.probe` through the attached + * client (the winner answers over UDS — A2b). Prints: + * ATTACHED — after start(), singleton must be "attached" + * PROBE — winner's control.probe over the socket + * PROBE-ERROR — dispatch over the wire failed + * Exits non-zero with `ERROR ` if start() fails closed. + * + * Environment: + * TF_TEST_CONTROL_HOME — control home (shared with the competing process) + * TF_TEST_HOLDER_ID — holder id (default "uds-loser") + */ + +import { ControlHost } from "../../src/control-host.ts"; +import { createTeExecutionProvider, type TeExecutionAuthority } from "../../src/te-provider.ts"; + +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); +} + +const controlHome = process.env.TF_TEST_CONTROL_HOME; +if (!controlHome) { + console.log("ERROR fixture requires TF_TEST_CONTROL_HOME"); + process.exit(1); +} + +const host = new ControlHost({ + mode: "auto", + controlHome, + provider: fakeProvider(), + holderId: process.env.TF_TEST_HOLDER_ID ?? "uds-loser", +}); + +host.start() + .then(async (status) => { + console.log( + `ATTACHED ${JSON.stringify({ + state: status.state, + singleton: status.singleton, + fencingEpoch: status.fencingEpoch, + holderId: status.holderId, + })}`, + ); + if (status.singleton !== "attached") { + console.log("LOSER-STATUS fixture expected singleton=attached"); + process.exitCode = 2; + return; + } + try { + const probe = await host.dispatch("control.probe", undefined, { fencingEpoch: status.fencingEpoch }); + console.log(`PROBE ${JSON.stringify(probe)}`); + } catch (error: unknown) { + const code = error instanceof Error && "code" in error ? String((error as { code: unknown }).code) : "UNKNOWN"; + console.log(`PROBE-ERROR ${JSON.stringify({ code, message: error instanceof Error ? error.message : String(error) })}`); + process.exitCode = 3; + } + }) + .catch((error: unknown) => { + const code = error instanceof Error && "code" in error ? String((error as { code: unknown }).code) : "UNKNOWN"; + console.log(`ERROR ${JSON.stringify({ code, message: error instanceof Error ? error.message : String(error) })}`); + process.exitCode = 1; + }); + +const shutdown = (): void => { + try { + host.stop(); + } catch { + /* best effort */ + } + process.exit(0); +}; +process.on("SIGTERM", shutdown); +process.on("SIGINT", shutdown); + +// Ref'd keep-alive so the parent has time to observe ATTACHED/PROBE. +setInterval(() => {}, 10_000); diff --git a/packages/taskflow-control/test/fixtures/uds-winner.ts b/packages/taskflow-control/test/fixtures/uds-winner.ts new file mode 100644 index 00000000..e54b87a4 --- /dev/null +++ b/packages/taskflow-control/test/fixtures/uds-winner.ts @@ -0,0 +1,89 @@ +/** + * UDS process-test fixture: a ControlHost WINNER in `auto` mode. + * + * Listens on the user singleton endpoint (the UDS server is created inside + * `start()`), prints `READY ` once started, then stays alive + * until the parent kills it (SIGTERM → clean stop; SIGKILL → stale-socket + * scenario). Exits non-zero with `ERROR ` if start() fails closed. + * + * Environment: + * TF_TEST_CONTROL_HOME — control home (shared with the competing process) + * TF_TEST_HOLDER_ID — holder id (default "uds-winner") + */ + +import { ControlHost } from "../../src/control-host.ts"; +import { createTeExecutionProvider, type TeExecutionAuthority } from "../../src/te-provider.ts"; + +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); +} + +const controlHome = process.env.TF_TEST_CONTROL_HOME; +if (!controlHome) { + console.log("ERROR fixture requires TF_TEST_CONTROL_HOME"); + process.exit(1); +} + +const host = new ControlHost({ + mode: "auto", + controlHome, + provider: fakeProvider(), + holderId: process.env.TF_TEST_HOLDER_ID ?? "uds-winner", +}); + +host.start() + .then((status) => { + console.log( + `READY ${JSON.stringify({ + state: status.state, + singleton: status.singleton, + fencingEpoch: status.fencingEpoch, + holderId: status.holderId, + })}`, + ); + }) + .catch((error: unknown) => { + const code = error instanceof Error && "code" in error ? String((error as { code: unknown }).code) : "UNKNOWN"; + console.log(`ERROR ${JSON.stringify({ code, message: error instanceof Error ? error.message : String(error) })}`); + process.exitCode = 1; + }); + +const shutdown = (): void => { + try { + host.stop(); + } catch { + /* best effort */ + } + process.exit(0); +}; +process.on("SIGTERM", shutdown); +process.on("SIGINT", shutdown); + +// Ref'd keep-alive: the winner must stay up until the parent kills it. +setInterval(() => {}, 10_000); diff --git a/packages/taskflow-control/test/store-process.test.ts b/packages/taskflow-control/test/store-process.test.ts new file mode 100644 index 00000000..4ea21c65 --- /dev/null +++ b/packages/taskflow-control/test/store-process.test.ts @@ -0,0 +1,152 @@ +/** + * Process-level ControlStore crash matrix (A4) + recovery-vs-mutation (A5). + * + * Unix-only: SIGKILL injection is not part of the 3-OS process-supervisor + * matrix. After restart the store must be old-complete, new-complete, or + * fail-closed — never a mixed journal/projection/commit-seq state. + */ +import assert from "node:assert/strict"; +import { spawn, type ChildProcess } from "node:child_process"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { after, test } from "node:test"; +import { ControlError } from "../src/errors.ts"; +import { CONTROL_CRASH_ENV, openControlStore } from "../src/store/index.ts"; + +const UNIX_ONLY = { skip: process.platform === "win32" } as const; + +const tempRoots: string[] = []; +const children: ChildProcess[] = []; + +after(() => { + for (const child of children) { + try { + if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); + } catch { /* best effort */ } + } + for (const root of tempRoots) { + try { fs.rmSync(root, { recursive: true, force: true }); } catch { /* best effort */ } + } +}); + +function makeStorePath(): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "tf-store-crash-")); + tempRoots.push(root); + return path.join(root, "project-store"); +} + +function spawnFixture(env: Record): ChildProcess { + const child = spawn( + process.execPath, + ["--conditions=development", "--experimental-strip-types", path.join(import.meta.dirname, "fixtures", "store-mutate.ts")], + { + cwd: path.resolve(import.meta.dirname, "..", "..", ".."), + env: { ...process.env, ...env }, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + children.push(child); + return child; +} + +function waitForExit(child: ChildProcess, timeoutMs = 15_000): Promise<{ code: number | null; signal: NodeJS.Signals | null }> { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + try { child.kill("SIGKILL"); } catch { /* best effort */ } + reject(new Error("timeout waiting for store fixture exit")); + }, timeoutMs); + child.once("exit", (code, signal) => { + clearTimeout(timer); + resolve({ code, signal }); + }); + }); +} + +function assertNotMixed(storePath: string, expected: "old" | "new"): void { + const store = openControlStore(storePath); + try { + assert.notEqual(store.status, "fail-closed"); + if (expected === "old") { + assert.equal(store.commitSeq, 0); + assert.equal(store.readCommand("00000000-0000-0000-0000-0000000000aa"), undefined); + } else { + assert.equal(store.commitSeq, 1); + assert.ok(store.readCommand("00000000-0000-0000-0000-0000000000aa")); + } + const journal = path.join(storePath, "journal", "000001.jsonl"); + if (fs.existsSync(journal)) { + const raw = fs.readFileSync(journal); + if (raw.length > 0) { + assert.equal(raw[raw.length - 1], 0x0a, "recovered journal must not keep a torn tail"); + } + } + const seq = JSON.parse(fs.readFileSync(path.join(storePath, "commit-seq.json"), "utf8")) as { commitSeq: number }; + assert.equal(seq.commitSeq, store.commitSeq, "commit-seq.json must match journal"); + } finally { + store.close(); + } +} + +test("store crash: SIGKILL after header fsync / before journal is old-complete (A4)", UNIX_ONLY, async () => { + const storePath = makeStorePath(); + const child = spawnFixture({ + TF_TEST_STORE_PATH: storePath, + TF_TEST_ACTION: "init", + [CONTROL_CRASH_ENV]: "header-fsynced", + }); + const result = await waitForExit(child); + assert.equal(result.signal, "SIGKILL"); + assert.ok(fs.existsSync(path.join(storePath, "header"))); + assert.equal(fs.existsSync(path.join(storePath, "journal", "000001.jsonl")), false); + assertNotMixed(storePath, "old"); +}); + +test("store crash: SIGKILL mid journal append is old-complete (A4)", UNIX_ONLY, async () => { + const storePath = makeStorePath(); + const primed = openControlStore(storePath); + primed.close(); + + const child = spawnFixture({ + TF_TEST_STORE_PATH: storePath, + TF_TEST_ACTION: "mutate", + TF_TEST_COMMAND_ID: "00000000-0000-0000-0000-0000000000aa", + [CONTROL_CRASH_ENV]: "journal-append", + }); + const result = await waitForExit(child); + assert.equal(result.signal, "SIGKILL"); + assertNotMixed(storePath, "old"); +}); + +test("store crash: SIGKILL during projection rebuild is new-complete (A4)", UNIX_ONLY, async () => { + const storePath = makeStorePath(); + const primed = openControlStore(storePath); + primed.close(); + + const child = spawnFixture({ + TF_TEST_STORE_PATH: storePath, + TF_TEST_ACTION: "mutate", + TF_TEST_COMMAND_ID: "00000000-0000-0000-0000-0000000000aa", + [CONTROL_CRASH_ENV]: "projection-rebuild", + }); + const result = await waitForExit(child); + assert.equal(result.signal, "SIGKILL"); + assertNotMixed(storePath, "new"); +}); + +test("store crash: recovery racing a second writer fails closed (A5)", UNIX_ONLY, async () => { + const storePath = makeStorePath(); + const holder = openControlStore(storePath); + try { + assert.throws( + () => openControlStore(storePath), + (error: unknown) => { + assert.ok(error instanceof ControlError); + assert.equal((error as ControlError).code, "TF_DURABILITY_FAILED"); + return true; + }, + ); + } finally { + holder.close(); + } +}); diff --git a/packages/taskflow-control/test/store.test.ts b/packages/taskflow-control/test/store.test.ts new file mode 100644 index 00000000..f21bc221 --- /dev/null +++ b/packages/taskflow-control/test/store.test.ts @@ -0,0 +1,214 @@ +/** + * Files-only project ControlStore (beta.2 S3-minimum). + * + * A3: standalone write then reopen sees the same projectId + commit-seq. + * A5: a second open/mutation is rejected (single writer). + * P14 hardening: symlink dest fail-closed. + * + * Process-level SIGKILL crash matrix lives in store-process.test.ts. + */ +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 { after, test } from "node:test"; +import { ControlError } from "../src/errors.ts"; +import { + CONTROL_WIRE_SCHEMA_VERSION, + type CommandRecord, + type ControlEvent, +} from "../src/schema/index.ts"; +import { openControlStore } from "../src/store/index.ts"; + +const tempRoots: string[] = []; + +function makeStorePath(): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "tf-control-store-")); + tempRoots.push(root); + return path.join(root, "project-store"); +} + +after(() => { + for (const root of tempRoots) { + try { fs.rmSync(root, { recursive: true, force: true }); } catch { /* best effort */ } + } +}); + +const UUID = "00000000-0000-0000-0000-000000000001"; +const UUID2 = "00000000-0000-0000-0000-000000000002"; +const SHA256 = "a".repeat(64); + +function submitBatch(overrides: Partial = {}): { + command: CommandRecord; + events: ControlEvent[]; +} { + const command: CommandRecord = { + commandId: UUID2, + kind: "run.submit", + requestHash: SHA256, + callerPrincipal: "cli", + authorizationContextHash: SHA256, + projectId: UUID, + controlDomainId: UUID, + status: "accepted", + firstCommitSeq: 1, + lastCommitSeq: 1, + recordedAt: 1, + ...overrides, + }; + const events: ControlEvent[] = [ + { + eventId: UUID, + schemaVersion: CONTROL_WIRE_SCHEMA_VERSION, + controlDomainId: command.controlDomainId, + streamId: `command:${command.commandId}`, + streamSeq: 1, + commitSeq: 1, + commandId: command.commandId, + commandEventIndex: 0, + causationId: command.commandId, + correlationId: command.commandId, + projectId: command.projectId, + recordedAt: 1, + payload: { kind: "command.recorded", commandId: command.commandId }, + }, + ]; + return { command, events }; +} + +test("store: opening a new path creates a header and empty ledger (A3)", () => { + const storePath = makeStorePath(); + const store = openControlStore(storePath); + try { + assert.equal(store.status, "healthy"); + assert.equal(store.commitSeq, 0); + assert.equal(store.header.schemaVersion, CONTROL_WIRE_SCHEMA_VERSION); + assert.match(store.header.projectId, /^[0-9a-f-]{36}$/i); + assert.equal(store.header.directoryBinding.canonicalPath, fs.realpathSync(storePath)); + assert.ok(fs.existsSync(path.join(storePath, "header"))); + assert.ok(fs.existsSync(path.join(storePath, "commit-seq.json"))); + assert.ok(fs.statSync(path.join(storePath, "journal")).isDirectory()); + assert.ok(fs.statSync(path.join(storePath, "projections")).isDirectory()); + assert.ok(fs.statSync(path.join(storePath, "commands")).isDirectory()); + assert.ok(fs.statSync(path.join(storePath, "receipts")).isDirectory()); + } finally { + store.close(); + } +}); + +test("store: reopen after close sees the same projectId and commit-seq (A3)", () => { + const storePath = makeStorePath(); + const first = openControlStore(storePath, { projectId: UUID, controlDomainId: UUID }); + const projectId = first.header.projectId; + const domainId = first.header.controlDomainId; + first.close(); + + const second = openControlStore(storePath); + try { + assert.equal(second.header.projectId, projectId); + assert.equal(second.header.controlDomainId, domainId); + assert.equal(second.commitSeq, 0); + assert.equal(second.status, "healthy"); + } finally { + second.close(); + } +}); + +test("store: run.submit + events commit atomically and survive reopen (A3)", () => { + const storePath = makeStorePath(); + const first = openControlStore(storePath, { projectId: UUID, controlDomainId: UUID }); + const { command, events } = submitBatch(); + const committed = first.appendBatch({ command, events }); + assert.equal(committed.commitSeq, 1); + assert.equal(committed.command.kind, "run.submit"); + assert.equal(committed.command.firstCommitSeq, 1); + assert.equal(committed.command.lastCommitSeq, 1); + assert.equal(first.readCommand(command.commandId)?.kind, "run.submit"); + first.close(); + + const second = openControlStore(storePath); + try { + assert.equal(second.commitSeq, 1); + assert.equal(second.header.projectId, UUID); + const loaded = second.readCommand(command.commandId); + assert.ok(loaded); + assert.equal(loaded.kind, "run.submit"); + assert.equal(loaded.requestHash, SHA256); + } finally { + second.close(); + } +}); + +test("store: a second live opener is rejected (A5 single writer)", () => { + const storePath = makeStorePath(); + const first = openControlStore(storePath); + try { + assert.throws( + () => openControlStore(storePath), + (error: unknown) => { + assert.ok(error instanceof ControlError); + assert.equal((error as ControlError).code, "TF_DURABILITY_FAILED"); + return true; + }, + ); + } finally { + first.close(); + } +}); + +test("store: rename destination replaced by a symlink fails closed (P14)", () => { + const storePath = makeStorePath(); + const store = openControlStore(storePath); + store.close(); + + const header = path.join(storePath, "header"); + const decoy = path.join(path.dirname(storePath), "decoy-header"); + fs.writeFileSync(decoy, "stolen\n"); + fs.unlinkSync(header); + fs.symlinkSync(decoy, header); + + assert.throws( + () => openControlStore(storePath), + (error: unknown) => { + assert.ok(error instanceof ControlError); + assert.equal((error as ControlError).code, "TF_DURABILITY_FAILED"); + assert.match((error as ControlError).message, /symlink/i); + return true; + }, + ); + assert.equal(fs.readFileSync(decoy, "utf8"), "stolen\n", "symlink target must not be overwritten"); +}); + +test("store: writeFile/appendFile/createWriteStream in control src stay on metadata paths (A6)", () => { + const srcRoot = path.resolve(import.meta.dirname, "../src"); + const hits: string[] = []; + const stack = [srcRoot]; + while (stack.length > 0) { + const current = stack.pop()!; + for (const entry of fs.readdirSync(current, { withFileTypes: true })) { + const full = path.join(current, entry.name); + if (entry.isDirectory()) { + stack.push(full); + continue; + } + if (!entry.name.endsWith(".ts")) continue; + const text = fs.readFileSync(full, "utf8"); + const re = /\b(?:writeFile(?:Sync)?|appendFile(?:Sync)?|createWriteStream)\s*\(/g; + let match: RegExpExecArray | null; + while ((match = re.exec(text)) !== null) { + const line = text.slice(0, match.index).split("\n").length; + hits.push(`${path.relative(srcRoot, full)}:${line}`); + } + } + } + const allowed = [ + "singleton.ts", + "control-host.ts", + "store/store.ts", + ]; + for (const hit of hits) { + const file = hit.split(":")[0]; + assert.ok(allowed.includes(file), `A6: unexpected write at ${hit}`); + } + assert.ok(hits.length > 0, "expected to find control-metadata writes"); +}); diff --git a/packages/taskflow-control/test/uds-process.test.ts b/packages/taskflow-control/test/uds-process.test.ts new file mode 100644 index 00000000..6aca6989 --- /dev/null +++ b/packages/taskflow-control/test/uds-process.test.ts @@ -0,0 +1,305 @@ +/** + * Process-level Unix UDS ControlHost tests (A2 / A2b). + * + * These spawn real OS processes: the winner fixture `listen`s on the user + * singleton endpoint, the loser fixture `connect`s over the socket, does an + * online hello, receives the winner's fencing epoch, and routes + * `control.probe` over the wire. Also covers stale-socket recovery after the + * winner is killed and the process-level `auto` → fail-closed (never silent + * `standalone`) contract. + * + * Unix-only: these tests are deliberately NOT part of the 3-OS + * process-supervisor matrix (Windows named pipe is non-GA). The file guards + * itself with `skip: process.platform === "win32"`, and the supervisor job + * only runs the two explicit taskflow-core test files, so this file never + * executes on Windows. + */ + +import assert from "node:assert/strict"; +import { spawn, type ChildProcess } from "node:child_process"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { after, test } from "node:test"; +import { ControlHost } from "../src/control-host.ts"; +import { createTeExecutionProvider, type TeExecutionAuthority } from "../src/te-provider.ts"; +import { connectUdsClient } from "../src/uds.ts"; +import { singletonPaths, type SingletonPaths } from "../src/singleton.ts"; +import { PROTOCOL_MAJOR, type NegotiationHandshake } from "../src/schema/transport.ts"; + +const UNIX_ONLY = { skip: process.platform === "win32" } as const; + +const tempRoots: string[] = []; +const children: ChildProcess[] = []; + +after(() => { + for (const child of children) { + try { + if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); + } catch { + /* best effort */ + } + } + for (const root of tempRoots) { + try { + fs.rmSync(root, { recursive: true, force: true }); + } catch { + /* best effort */ + } + } +}); + +function repoRoot(): string { + return path.resolve(import.meta.dirname, "..", "..", ".."); +} + +function fixturePath(name: string): string { + return path.join(import.meta.dirname, "fixtures", name); +} + +function makeRoot(prefix: string): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), `tf-uds-${prefix}-`)); + tempRoots.push(root); + return root; +} + +function makePaths(root: string): SingletonPaths { + return singletonPaths(root); +} + +function spawnFixture(name: string, env: Record): ChildProcess { + const child = spawn( + process.execPath, + ["--conditions=development", "--experimental-strip-types", fixturePath(name)], + { + cwd: repoRoot(), + env: { ...process.env, ...env }, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + children.push(child); + return child; +} + +function waitForLine(child: ChildProcess, prefix: string, timeoutMs = 15_000): Promise { + return new Promise((resolve, reject) => { + let buffer = ""; + let done = false; + const timer = setTimeout(() => { + if (done) return; + done = true; + child.stdout?.off("data", onData); + reject(new Error(`timeout waiting for "${prefix}" from fixture; got: ${JSON.stringify(buffer.slice(-300))}`)); + }, timeoutMs); + const onData = (chunk: Buffer): void => { + if (done) return; + buffer += chunk.toString("utf8"); + let index: number; + while ((index = buffer.indexOf("\n")) >= 0) { + const line = buffer.slice(0, index).trim(); + buffer = buffer.slice(index + 1); + if (!line) continue; + if (line.startsWith(prefix)) { + done = true; + clearTimeout(timer); + child.stdout?.off("data", onData); + resolve(line); + return; + } + } + }; + child.stdout?.on("data", onData); + }); +} + +function waitForExit(child: ChildProcess, timeoutMs = 15_000): Promise<{ code: number | null; output: string }> { + return new Promise((resolve, reject) => { + let stdout = ""; + let stderr = ""; + child.stdout?.on("data", (chunk: Buffer) => { + stdout += chunk.toString("utf8"); + }); + child.stderr?.on("data", (chunk: Buffer) => { + stderr += chunk.toString("utf8"); + }); + const timer = setTimeout(() => { + try { + child.kill("SIGKILL"); + } catch { + /* best effort */ + } + reject(new Error(`timeout waiting for fixture exit; got: ${JSON.stringify(stdout.slice(-300))}`)); + }, timeoutMs); + child.once("exit", (code) => { + clearTimeout(timer); + resolve({ code, output: stdout + stderr }); + }); + }); +} + +function parseJsonLine(line: string, prefix: string): Record { + const payload = line.slice(prefix.length).trim(); + const value: unknown = JSON.parse(payload); + assert.ok(value !== null && typeof value === "object", `${prefix} payload must be a JSON object`); + return value as Record; +} + +const CLIENT_HELLO: NegotiationHandshake = { + protocolMajor: PROTOCOL_MAJOR, + supportedReadSchemas: ["taskflow.wire.v1"], + supportedWriteSchemas: ["taskflow.wire.v1"], + requiredFeatures: [], + offeredFeatures: [], + buildInfo: { packageVersion: "0.3.0-beta.2", gitCommit: "test", schemaVersion: 1 }, +}; + +test("uds: two OS processes compete — winner listens, loser attaches over the socket and gets the winner epoch (A2/A2b)", UNIX_ONLY, async () => { + const root = makeRoot("attach"); + const paths = makePaths(root); + + // Winner: an independent OS process that wins the singleton and listens. + const winner = spawnFixture("uds-winner.ts", { + TF_TEST_CONTROL_HOME: root, + TF_TEST_HOLDER_ID: "winner-proc", + }); + const readyLine = await waitForLine(winner, "READY "); + const winnerStatus = parseJsonLine(readyLine, "READY "); + assert.equal(winnerStatus.singleton, "won"); + assert.equal(winnerStatus.fencingEpoch, 1); + + // The winner's endpoint is a real Unix socket, mode 0o600 (not umask luck). + const socketStat = fs.statSync(paths.endpointPath); + assert.equal(socketStat.isSocket(), true); + assert.equal(socketStat.mode & 0o077, 0, "socket must be 0o600"); + + // Loser: a second OS process competing for the same lock + endpoint. + const loser = spawnFixture("uds-loser.ts", { + TF_TEST_CONTROL_HOME: root, + TF_TEST_HOLDER_ID: "loser-proc", + }); + const attachedLine = await waitForLine(loser, "ATTACHED "); + const attached = parseJsonLine(attachedLine, "ATTACHED "); + assert.equal(attached.state, "started"); + assert.equal(attached.singleton, "attached"); + assert.equal(attached.holderId, "winner-proc"); + assert.equal(attached.fencingEpoch, winnerStatus.fencingEpoch, "loser must receive the winner's epoch over the socket"); + + // A2b: the winner answers control.probe over UDS (proxied by the attached client). + const probeLine = await waitForLine(loser, "PROBE "); + const probe = parseJsonLine(probeLine, "PROBE "); + assert.equal(probe.outcome, "accepted"); + assert.equal((probe.capabilities as Record)?.processIsolation, "none"); + + winner.kill("SIGTERM"); + loser.kill("SIGTERM"); +}); + +test("uds: protocolMajor mismatch is rejected on the wire, not only in-process (P13 §5.5)", UNIX_ONLY, async () => { + const root = makeRoot("proto"); + const paths = makePaths(root); + + const winner = spawnFixture("uds-winner.ts", { + TF_TEST_CONTROL_HOME: root, + TF_TEST_HOLDER_ID: "winner-proto", + }); + await waitForLine(winner, "READY "); + + const badHello = { ...CLIENT_HELLO, protocolMajor: PROTOCOL_MAJOR + 1 } as unknown as NegotiationHandshake; + await assert.rejects( + connectUdsClient({ endpointPath: paths.endpointPath, clientHello: badHello }), + (error: unknown) => { + assert.ok(error instanceof Error); + assert.equal((error as { code?: unknown }).code, "TF_PROTOCOL_INCOMPATIBLE"); + return true; + }, + ); + + // The winner is still serving after rejecting a bad client. + const goodClient = await connectUdsClient({ endpointPath: paths.endpointPath, clientHello: CLIENT_HELLO }); + assert.equal(goodClient.fencingEpoch, 1); + goodClient.close(); + + winner.kill("SIGTERM"); +}); + +test("uds: killing the winner leaves a stale socket that the next process unlinks and re-listens (A2)", UNIX_ONLY, async () => { + const root = makeRoot("stale"); + const paths = makePaths(root); + + const winner = spawnFixture("uds-winner.ts", { + TF_TEST_CONTROL_HOME: root, + TF_TEST_HOLDER_ID: "winner-stale", + }); + const readyLine = await waitForLine(winner, "READY "); + const winnerStatus = parseJsonLine(readyLine, "READY "); + assert.equal(winnerStatus.fencingEpoch, 1); + + const staleIno = fs.statSync(paths.endpointPath, { bigint: true }).ino; + + // SIGKILL: no cleanup handler runs — the socket file must survive as stale. + winner.kill("SIGKILL"); + const exited = await waitForExit(winner); + assert.ok(exited.code === null || exited.code !== 0, "winner must die from SIGKILL"); + assert.equal(fs.existsSync(paths.endpointPath), true, "stale socket file must remain after SIGKILL"); + + // A fresh OS process (the test runner) reclaims the dead owner: stale socket + // is unlinked, then it listens again with a bumped fencing epoch. + 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 }; + }, + }; + const fresh = new ControlHost({ + mode: "auto", + controlHome: root, + provider: createTeExecutionProvider(te), + holderId: "winner-reborn", + }); + const status = await fresh.start(); + assert.equal(status.singleton, "won"); + assert.equal(status.fencingEpoch, 2, "reclaim must bump the fencing epoch"); + assert.equal(fs.existsSync(paths.endpointPath), true); + const freshIno = fs.statSync(paths.endpointPath, { bigint: true }).ino; + assert.notEqual(freshIno, staleIno, "the stale socket must be unlinked and a fresh one created"); + assert.equal(fs.statSync(paths.endpointPath).mode & 0o077, 0, "fresh socket must be 0o600"); + fresh.stop(); +}); + +test("uds: auto never silently becomes standalone at the process level (fail closed)", UNIX_ONLY, async () => { + const parent = fs.mkdtempSync(path.join(os.tmpdir(), "tf-uds-auto-")); + tempRoots.push(parent); + // The control home is a regular FILE — the singleton bootstrap cannot run. + const controlHomeFile = path.join(parent, "control-file"); + fs.writeFileSync(controlHomeFile, "not a directory"); + + const child = spawnFixture("uds-fail-closed.ts", { + TF_TEST_CONTROL_HOME: controlHomeFile, + }); + const exited = await waitForExit(child); + assert.notEqual(exited.code, 0, "auto mode with an un-runnable control must fail closed"); + assert.match(exited.output, /FAILED-CLOSED/); + assert.match(exited.output, /TF_BOOTSTRAP_FAILED/); + assert.match(exited.output, /STATE failed-closed none/); + assert.doesNotMatch(exited.output, /STARTED/); +}); diff --git a/packages/taskflow-core/package.json b/packages/taskflow-core/package.json index 3cc68a02..1d932661 100644 --- a/packages/taskflow-core/package.json +++ b/packages/taskflow-core/package.json @@ -1,7 +1,7 @@ { "name": "taskflow-core", - "version": "0.3.0-beta.1.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.", + "version": "0.3.0-beta.2", + "description": "Host-neutral engine for declarative, verifiable task-DAG orchestration \u2014 the runtime, DSL, cache, and verification shared by pi-taskflow, codex-taskflow, claude-taskflow, opencode-taskflow, grok-taskflow, and hermes-taskflow.", "keywords": [ "taskflow", "dag", diff --git a/packages/taskflow-dsl/package.json b/packages/taskflow-dsl/package.json index a913201e..d0447747 100644 --- a/packages/taskflow-dsl/package.json +++ b/packages/taskflow-dsl/package.json @@ -1,6 +1,6 @@ { "name": "taskflow-dsl", - "version": "0.3.0-beta.1.1", + "version": "0.3.0-beta.2", "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 85c53382..120cca1c 100644 --- a/packages/taskflow-hosts/package.json +++ b/packages/taskflow-hosts/package.json @@ -1,7 +1,7 @@ { "name": "taskflow-hosts", - "version": "0.3.0-beta.1.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.", + "version": "0.3.0-beta.2", + "description": "Shared host-runner collection for taskflow \u2014 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 ", "bugs": { diff --git a/packages/taskflow-mcp-core/package.json b/packages/taskflow-mcp-core/package.json index ef5774cd..703b0a9c 100644 --- a/packages/taskflow-mcp-core/package.json +++ b/packages/taskflow-mcp-core/package.json @@ -1,7 +1,7 @@ { "name": "taskflow-mcp-core", - "version": "0.3.0-beta.1.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.", + "version": "0.3.0-beta.2", + "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 \u2014 depends only on taskflow-core.", "keywords": [ "taskflow", "mcp", diff --git a/website/app/[lang]/layout.tsx b/website/app/[lang]/layout.tsx index 62d653a5..37b5123e 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.0-beta.1.1 — Trusted Effects beta", + title: "taskflow 0.3.0-beta.2 — Trusted Effects beta", brand: "taskflow", description: - "Declare coding-agent effects, verify typed paths, and commit admitted filesystem changes through one resource authority. 0.3.0-beta.1.1; beta channel and not GA.", + "Declare coding-agent effects, verify typed paths, and commit admitted filesystem changes through one resource authority. 0.3.0-beta.2; beta channel and not GA.", }, "zh-cn": { - title: "taskflow 0.3.0-beta.1.1 — Trusted Effects beta", + title: "taskflow 0.3.0-beta.2 — Trusted Effects beta", brand: "taskflow", description: - "声明 coding-agent effect,验证类型化路径,让已准入的文件修改经过唯一 resource authority。0.3.0-beta.1.1,beta channel,尚未 GA。", + "声明 coding-agent effect,验证类型化路径,让已准入的文件修改经过唯一 resource authority。0.3.0-beta.2,beta channel,尚未 GA。", }, } as const; diff --git a/website/app/[lang]/page.tsx b/website/app/[lang]/page.tsx index 979bfec0..61133396 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.0-beta.1.1 · Trusted Effects beta", + eyebrow: "taskflow 0.3.0-beta.2 · 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.0-beta.1.1 · beta channel · not GA", + noteKicker: "0.3.0-beta.2 · 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: "0.3.0-beta.1.1 host installs — select the beta channel.", + label: "0.3.0-beta.2 host installs — select the beta channel.", copy: "Copy", copied: "Copied", guide: "Guide", @@ -187,10 +187,10 @@ const copy = { localeZh: "中文", }, hero: { - eyebrow: "taskflow 0.3.0-beta.1.1 · Trusted Effects beta", + eyebrow: "taskflow 0.3.0-beta.2 · Trusted Effects beta", title: ["声明 effect。", "验证路径。", "让一个 authority 负责提交。"], sub: "taskflow 把 coding-agent 工作变成可验证的运行时:显式任务图、类型化 effect 声明、隔离执行、受 resources 控制的文件提交,以及覆盖六个宿主的 ledger-backed 解释。", - noteKicker: "0.3.0-beta.1.1 · beta channel · 尚未 GA", + noteKicker: "0.3.0-beta.2 · 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.3.0-beta.1.1 宿主安装;请显式选择 beta channel。", + label: "0.3.0-beta.2 宿主安装;请显式选择 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-beta.1.1", + softwareVersion: "0.3.0-beta.2", description: t.hero.sub, applicationCategory: "DeveloperApplication", operatingSystem: "Any", diff --git a/website/app/page.tsx b/website/app/page.tsx index 6967b4aa..821a16ba 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.0-beta.1.1; beta channel and not GA."; + "Declare coding-agent effects, verify typed paths, and commit admitted filesystem changes through one resource authority. 0.3.0-beta.2; beta channel and not GA."; const canonical = "https://heggria.github.io/taskflow/en/"; export const metadata: Metadata = { diff --git a/website/content/docs/en/compiler-runtime/typescript-dsl.mdx b/website/content/docs/en/compiler-runtime/typescript-dsl.mdx index cccefc8b..05d746fe 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 target `0.3.0-beta.1.1` on npm's `beta` channel; install with `npm install taskflow-dsl@beta`, 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.2` 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 34434525..55461db8 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 **0.3.0-beta.1.1**, start with the [Trusted Effects overview](/en/docs/trusted-effects) and select npm's `beta` channel; beta is not GA. + This guide covers the stable 0.2.x host installation path. For **0.3.0-beta.2**, 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. diff --git a/website/content/docs/en/index.mdx b/website/content/docs/en/index.mdx index 7da12cc6..619aa7b6 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.0-beta.1.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. +> **0.3.0-beta.2 — beta channel candidate, not GA.** Trusted Effects MVP plus a Control Plane slice (S2 Unix UDS + S3-min store). Wire contracts remain PROPOSED. `#137` / `#95` are not in this slice. 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. diff --git a/website/content/docs/en/trusted-effects.mdx b/website/content/docs/en/trusted-effects.mdx index f8a3d924..8658c14f 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.0-beta.1.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. +> **0.3.0-beta.2 — 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 add22d31..892c687b 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。 - **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.1` on npm's `beta` channel; after publication, install with `npm install taskflow-dsl@beta`, 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.2` 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 55f8de63..19e66919 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.0-beta.1.1**,请从 [Trusted Effects 总览](/zh-cn/docs/trusted-effects) 开始,并显式选择 npm 的 `beta` channel;beta 尚未 GA。 + 本指南介绍稳定的 0.2.x 宿主安装路径。对于 **0.3.0-beta.2**,请从 [Trusted Effects 总览](/zh-cn/docs/trusted-effects) 开始,并显式选择 npm 的 `beta` channel;beta 尚未 GA。 要最快地感受它,先跑一个看看。 diff --git a/website/content/docs/zh-cn/index.mdx b/website/content/docs/zh-cn/index.mdx index 97676444..a9992912 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.0-beta.1.1——beta channel candidate,尚未 GA。** 这个页面描述为 beta 发布准备的 Trusted Effects MVP;0.3-C Control Plane 仍是后续 candidate 轨道。 +> **0.3.0-beta.2——beta channel candidate,尚未 GA。** Trusted Effects MVP 加上 Control Plane 切片(S2 Unix UDS + S3 最小 store)。Wire 合同仍为 PROPOSED。本切片不含 `#137` / `#95`。 taskflow 是面向 coding-agent 工作流的声明式运行时。它把任务图变成可验证的执行合同,让阶段隔离运行,并把中间 transcript 留在宿主对话之外。0.3 candidate 增加了 **Trusted Effects**:为已准入的文件 effect 提供类型化声明与受 resources 控制的提交路径。 diff --git a/website/content/docs/zh-cn/trusted-effects.mdx b/website/content/docs/zh-cn/trusted-effects.mdx index 128756ef..81a822d3 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.0-beta.1.1——beta channel candidate,尚未 GA。** 本页描述为 beta 发布准备的 Trusted Effects MVP;npm 发布仍是发版闸门。 +> **0.3.0-beta.2——beta channel candidate,尚未 GA。** 本页描述为 beta 发布准备的 Trusted Effects MVP;npm 发布仍是发版闸门。 Trusted Effects 把阶段的副作用写进合同。模型可以提出内容,但对于已准入的已声明文件目标,**resources transaction 是唯一最终提交者**。