Compare commits

...

19 Commits

Author SHA1 Message Date
xingyue 9cb7d68abe feat: Dashboard workflow graph visualization with React Flow (#198)
Phase 1: API + static graph rendering

Backend:
- GET /workflows/:name now returns descriptor (with graph) from bundle YAML
- Graceful fallback to null if YAML missing/invalid

Frontend:
- New workflow-graph/ component module (7 files)
- React Flow + dagre auto-layout (TB direction)
- Custom nodes: RoleNode (rounded rect) + TerminalNode (circle for START/END)
- Custom edges: dashed for FALLBACK, solid with label for conditions
- Self-loop edges supported (e.g. coder → coder)
- Node states: default/completed/active with color-coded borders
- Active node pulse animation
- Collapsible graph panel (300px) above thread records
- Dark theme using existing CSS variables

Integration:
- ThreadDetail extracts workflow name → fetches descriptor → computes node states → renders graph
- Node states derived from ThreadRecord[] (completed/active/default)
2026-05-12 10:27:07 +08:00
xingyue 2c26be6ec6 docs: update graph visualization RFC — Phase 0 done (#198) 2026-05-12 10:13:39 +08:00
xiaomo f723daa014 Merge pull request 'feat(#194): Phase 2 — Engine layer Merkle call stack' (#202) from feat/194-merkle-call-stack-phase2 into main 2026-05-12 02:11:24 +00:00
xiaoju 1e9900bed3 feat(#194): Phase 2 — Engine layer Merkle call stack wiring
- Protocol: AgentFnResult = string | { output, childThread }, RoleOutput.childThread,
  ThreadContext.bundleHash for parent state lookup
- Runtime: create-workflow normalizes AgentFnResult, propagates childThread in RoleOutput
- Engine: ExecuteThreadOptions.parentStateHash, appendStateForStep writes childThread,
  putStartNode uses parentStateHash
- workflowAsAgent: reads parent head state from threads.json, passes parentStateHash
  to child, returns { output, childThread: rootHash }
- Integration test: 4 cases verifying bidirectional Merkle links (306 lines)

Phase 2 of #194 (Merkle Call Stack). Closes #196.

小橘 <xiaoju@shazhou.work>
2026-05-12 02:10:06 +00:00
xiaomo aebff8b906 Merge pull request 'refactor: replace Moderator function with ModeratorTable in WorkflowDefinition' (#201) from refactor/200-moderator-table into main 2026-05-12 02:06:03 +00:00
xingyue db45089922 refactor: replace Moderator function with ModeratorTable in WorkflowDefinition (#200)
- WorkflowDefinition.moderator → WorkflowDefinition.table (ModeratorTable)
- Moderator type + tableToModerator no longer exported from protocol/runtime
- tableToModerator internalized in workflow-execute engine layer
- WorkflowDescriptor gains graph: WorkflowGraph (auto-extracted from table)
- buildDescriptor extracts serializable graph edges from ModeratorTable
- validateWorkflowDescriptor validates graph structure
- All templates (develop, solve-issue) export table directly
- CLI init scaffold updated to use ModeratorTable
- 99 tests pass, 0 failures
2026-05-12 10:01:30 +08:00
xiaomo 9c1b018ffa Merge pull request 'feat(#194): Phase 1 — Merkle Call Stack protocol + CAS layer' (#199) from feat/194-merkle-call-stack-phase1 into main 2026-05-12 01:50:05 +00:00
xiaoju a98431a12a feat(#194): Phase 1 — parentState / childThread in CAS nodes
- Protocol: StartNodePayload.parentState, StateNodePayload.childThread
- CAS: putStartNode refs include parentState, collectRefs includes childThread
- Parsing: legacy nodes without new fields default to null
- Engine + fork: all callers pass parentState: null / childThread: null
- Tests: 8 new cases for refs, parsing, collect-refs (+208 lines)

Phase 1 of #194 (Merkle Call Stack). Closes #195.

小橘 <xiaoju@shazhou.work>
2026-05-12 01:42:10 +00:00
xingyue 0fe17b0fb2 docs: workflow graph visualization design plan (#198) 2026-05-12 09:38:58 +08:00
xiaoju e37dbc3f35 wip: Phase 1 protocol + CAS types for Merkle call stack
小橘 <xiaoju@shazhou.work>
2026-05-12 01:35:45 +00:00
xiaoju 82d9abf260 rfc: Merkle Call Stack — cross-thread DAG linking
Design doc for parent-child workflow Merkle linking:
- StartNodePayload.parentState: child → parent head state at spawn time
- StateNodePayload.childThread: parent → child final state hash
- Both also in refs[] for GC reachability
- 4-phase implementation plan

小橘 <xiaoju@shazhou.work>
2026-05-12 01:29:38 +00:00
xiaoju 50aec2d0cf fix: use unique log tags per call site in extract-workspace
W8KN3QYT — extraction failed
H4PM7RXV — non-absolute path
V3KM8QWP — success

小橘 <xiaoju@shazhou.work>
2026-05-12 00:58:37 +00:00
xiaomo e979a55f8a Merge pull request 'feat: cursor agent auto-extracts workspace from context' (#193) from feat/cursor-agent-workspace-extract into main 2026-05-12 00:57:33 +00:00
xiaoju 30f1582046 fix: address review feedback on cursor agent PR
1. Replace manual OpenAI response parsing with createThreadReactor —
   workspace extraction now uses the same ReAct loop as extract/summarizer,
   with a zod schema and structured tool call
2. Remove non-null assertion on llmProvider, replaced with explicit guard
3. Add structured logging (LogFn) to extraction — failures, non-absolute
   paths, and successful extractions all logged with tag V3KM8QWP
4. export const run = wf is correct: createWorkflow returns WorkflowFn
   directly, old bundle-entry had stale .run access + unused 3rd arg

小橘 <xiaoju@shazhou.work>
2026-05-11 14:05:01 +00:00
xiaoju cf0540d7fa fix: stabilize kill-thread test by polling instead of fixed delay
Replace 50ms setTimeout with waitUntilPredicate polling for first role
completion before issuing kill. Same pattern used by pause/resume test.

小橘 <xiaoju@shazhou.work>
2026-05-11 13:56:14 +00:00
xiaoju c05fac746c feat: cursor agent auto-extracts workspace from context via LLM
- workflow-agent-cursor: workspace config now optional (string | null)
- When null, uses LLM call to extract workspace path from AgentContext
  (previous steps' meta, start prompt) before spawning cursor-agent CLI
- Requires llmProvider when workspace is null
- develop bundle-entry: switched from hermes to cursor agent for all roles
- solve-issue bundle-entry: created, developer role uses workflowAsAgent('develop'),
  preparer/submitter remain hermes

小橘 <xiaoju@shazhou.work>
2026-05-11 13:51:41 +00:00
xiaoju 34efd25e91 chore: split release into publish.sh + deploy.sh
- publish.sh: version bump → workspace:* swap → npm publish → restore → commit
- deploy.sh: build + deploy dashboard/gateway to Cloudflare (supports single target)

小橘 <xiaoju@shazhou.work>
2026-05-11 12:22:06 +00:00
xiaoju cc0bc6c8aa chore: add release.sh script
Automates: version bump → workspace:* replace → npm publish (topo order)
→ restore workspace:* → dashboard build+deploy → git commit+push.

Env: GITEA_TOKEN, CLOUDFLARE_API_TOKEN (from cfg).

小橘 <xiaoju@shazhou.work>
2026-05-11 12:21:07 +00:00
xiaoju 626cb5d98e Merge pull request 'fix: sort thread list newest-first and differentiate status colors' (#192) from fix/191-dashboard-thread-sort into main 2026-05-11 12:16:19 +00:00
73 changed files with 2251 additions and 115 deletions
+197
View File
@@ -0,0 +1,197 @@
# RFC: Merkle Call Stack — Cross-Thread DAG Linking
**Author:** 小橘 🍊(NEKO Team)
**Date:** 2026-05-11
**Status:** Draft
## Problem
`workflowAsAgent` 在父 workflow 中 spawn 子 workflow 时,父子 thread 之间没有任何 Merkle 链接:
1. **子 thread 不知道自己从哪来** — start node 只有 prompt hash,无法追溯父 thread 的上下文(preparer 分析出的 repoPath、conventions 等)
2. **父 thread 不知道子 thread 在哪** — developer role 的 state node 里只有 agent 返回的文本,child thread root hash 埋在字符串里,不是结构化 ref
3. **上下文传递靠序列化到 prompt** — 父 workflow 前置 role 的产出只能通过拼字符串传给子 workflow,丢失了 Merkle DAG 的可遍历性
## Proposal
在 CAS 节点中建立父子 thread 之间的 **双向 Merkle 链接**,形成调用栈结构。
### 新增字段
#### StartNodePayload(子 → 父)
```typescript
type StartNodePayload = {
name: string;
hash: string;
depth: number;
parentState: string | null; // NEW: 父 thread 调用时的 head state hash
};
```
`parentState` 指向子 workflow 被 spawn 时,父 thread 的最后一个 state node hash。这是"调用发生时的调用栈帧"。
#### StateNodePayload(父 → 子)
```typescript
type StateNodePayload = {
role: string;
meta: Record<string, unknown>;
start: string;
content: string;
ancestors: string[];
compact: string | null;
timestamp: number;
childThread: string | null; // NEW: 子 thread 最终 state hash(执行结果)
};
```
`childThread` 指向子 thread 完成后的**最终 state hash**(不是 start)——语义上是"函数返回值",从这里沿 ancestors 可回溯子 thread 的完整执行历史。
### refs 同步
新增的 hash 也必须放进 `refs[]`
- `StartNode.refs`: `[promptHash, parentState]`(parentState 非 null 时)
- `StateNode.refs`: `[...existingRefs, childThread]`(childThread 非 null 时)
原因:GC 的 `findReachableHashes` 只走 `refs`,不解析 payload 字段。字段提供语义,refs 保证可达性。
### 具体 DAG 结构
`solve-issue`(fix #191)为例,developer role 委托给 `develop` 子 workflow:
```
父 thread: solve-issue
═══════════════════════════════════════════════════════════
content("fix #191")
hash: ABCD1234
start(solve-issue)
hash: START001
payload: { name: "solve-issue", hash: BUNDLE_SI, depth: 0, parentState: null }
refs: [ABCD1234]
state(preparer)
hash: STATE_P1
payload: { role: "preparer", meta: { repoPath: "...", ... }, childThread: null, ... }
refs: [PREP_CONTENT]
state(developer) ──────── 父→子 ────────
hash: STATE_D1 │
payload: { role: "developer", meta: { ... }, childThread: ★CSTATE_END, ... }
refs: [DEV_CONTENT, ★CSTATE_END] │
state(submitter) │
hash: STATE_S1 │
payload: { role: "submitter", ..., childThread: null } │
子 thread: develop │
═══════════════════════════════════════════════════════════ │
content("fix #191") (CAS 去重,可能同 ABCD1234) │
hash: CPROMPT1 │
──────── 子→父 ──────── │
start(develop) │ │
hash: CHILD_START │ │
payload: { name: "develop", hash: BUNDLE_DEV, depth: 1, │
parentState: ★STATE_P1 } │ │
refs: [CPROMPT1, ★STATE_P1] │ │
│ │
state(planner) │ │
hash: CSTATE_1 │ │
... │ │
│ │
state(coder) │ │
hash: CSTATE_2 │ │
... │ │
│ │
state(reviewer) → state(tester) → state(committer) │
│ │
hash: ★CSTATE_END ◄─────────────────┼─────────────────────────┘
```
### 遍历路径
**子 thread agent 获取父上下文(上行):**
```
当前 step → start(CHILD_START)
→ refs[1] = STATE_P1(父 preparer 的 state)
→ payload.meta.repoPath = "/home/.../workflow"
→ refs → PREP_CONTENT(完整 preparer 输出)
→ payload.start = START001(父的 start node)
→ refs[0] = ABCD1234(原始 prompt)
```
**从父 thread 追踪子 thread 执行(下行):**
```
STATE_D1(父 developer state)
→ payload.childThread = CSTATE_END
→ 子 thread 最终 state
→ 沿 ancestors 回溯:committer → tester → reviewer → coder → planner
→ payload.start = CHILD_START(子 thread 入口)
```
**完整调用栈还原:**
```
任意节点 → 沿 start 找到所属 thread 的 StartNode
→ parentState 非 null?沿 parentState 进入父 thread
→ 递归直到 parentState = null(顶层 workflow)
```
## Implementation Plan
### Phase 1: Protocol + CAS 层
1. `workflow-protocol/src/cas-types.ts``StartNodePayload``parentState: string | null``StateNodePayload``childThread: string | null`
2. `workflow-cas/src/nodes.ts``putStartNode` 接受可选 `parentStateHash`,放入 refs;`putStateNode` 接受可选 `childThreadHash`,放入 refs
3. `workflow-cas/src/nodes.ts` — 解析逻辑兼容新字段(缺失时视为 null)
### Phase 2: Engine 层
4. `workflow-execute/src/engine/engine.ts``executeThread` 接受 `parentStateHash: string | null`,传给 `putStartNode`
5. `workflow-execute/src/workflow-as-agent.ts` — spawn 子 thread 时传入父 thread 当前 head state hash 作为 `parentStateHash`;子 thread 完成后返回最终 state hash
6. Engine 写 developer role 的 state node 时,把子 thread 最终 hash 写入 `childThread` 字段
### Phase 3: Agent 可观测性
7. Agent prompt 构建(`buildAgentPrompt`)— 当 start node 有 `parentState` 时,提示 agent 可通过 `cas get` 遍历父上下文
8. CLI `thread show` — 显示 parentState / childThread 链接关系
### Phase 4: 验证
9. 已有测试适配新字段(向后兼容,旧节点 parentState/childThread 为 null)
10. 新增集成测试:workflowAsAgent 场景下验证双向链接正确写入
## Design Decisions
### 为什么 childThread 指向 end 而不是 start?
- 语义是"函数返回值"——父 role 执行完才产出 state,此时子 thread 已跑完
- 从 end 沿 ancestors 可回溯到 start;反过来 start 写入时子 thread 还没跑完,无法知道 end
### 为什么 parentState 指向 state 而不是 start?
- 指向父 thread 调用点的**前一个 state**(即调用发生时的 head)
- 这是子 workflow 能看到的父上下文的"切面"——所有已完成的前置 role 都可达
- 如果是第一个 role 就 spawn 子 workflow(没有前置 state),parentState 指向父的 start node
### 为什么同时放字段和 refs?
- `refs[]` 服务于 GC(`findReachableHashes` 只遍历 refs)和通用 DAG 遍历
- `payload.parentState` / `payload.childThread` 服务于语义读取(明确知道哪个 ref 是什么)
- 不改 GC 逻辑,只加字段,GC 自然正确
### 向后兼容
- 新字段默认 `null`,旧节点解析时缺失字段视为 `null`
- 不影响已有 thread 的遍历和 GC
- `depth` 可通过沿 parentState 链上溯来交叉验证(数据自证)
## Open Questions
1. **多子 thread** — 如果一个 role 需要 spawn 多个子 workflow(目前不存在这个场景),`childThread` 应该改成 `childThreads: string[]` 还是保持单个?
2. **Agent prompt 注入深度** — 子 workflow 的 agent 应该自动遍历多少层父上下文?全部还是限制深度?
3. **CLI 展示**`thread show` 要不要递归展示整个调用栈,还是只显示直接链接?
@@ -0,0 +1,224 @@
# Dashboard Workflow Graph Visualization
**Issue**: #198
**Status**: In Progress
**Author**: xingyue
## Overview
在 Dashboard 的 ThreadDetail 页面中嵌入一个交互式流程图,将 workflow 的 `ModeratorTable` 可视化为有向图。用户可以一眼看到角色流转结构和当前执行进度。
## 数据层(✅ 已完成 — PR #201)
### WorkflowGraph 类型
`WorkflowDefinition.moderator`(函数)已替换为 `WorkflowDefinition.table`(声明式 `ModeratorTable`),`buildDescriptor` 自动从 table 提取 graph:
```ts
type WorkflowGraphEdge = {
from: string; // source role 或 "__start__"
to: string; // target role 或 "__end__"
condition: string; // condition.name 或 "FALLBACK"
conditionDescription: string | null;
};
type WorkflowGraph = {
edges: readonly WorkflowGraphEdge[];
};
type WorkflowDescriptor = {
description: string;
roles: Record<string, WorkflowRoleDescriptor>;
graph: WorkflowGraph; // 必填,新 bundle 自动生成
};
```
### 数据流
```
ModeratorTable (WorkflowDefinition.table)
→ buildDescriptor() 自动提取 graph
→ descriptor.yaml 持久化(hash.yaml)
→ CLI serve /workflows/:name API 返回 descriptor
→ Dashboard 前端拿到 graph
```
### 剩余数据层工作
**serve API 需要返回 descriptor**:当前 `GET /workflows/:name` 只返回 registry entry(hash + timestamp),不含 descriptor。需要从 `bundles/{hash}.yaml` 读取 descriptor 并返回给前端。
方案:在 `routes-workflow.ts``GET /workflows/:name` 响应中附带 `descriptor` 字段。或者:thread-detail 发现 workflow name 后,请求 `GET /workflows/:name/descriptor` 拿到 graph。
## 前端渲染
### 库选型:React Flow + dagre
| 库 | 优势 | 劣势 |
|---|---|---|
| **React Flow** ✅ | React 原生、自定义节点/边、dagre 自动布局、~50KB gzip | 需要学 API |
| Mermaid | 声明式简单 | 无交互、无法高亮当前步骤 |
| D3 | 完全控制 | 太底层,手撸成本高 |
| Cytoscape | 图论强 | React 集成差 |
**依赖新增**
```json
{
"@xyflow/react": "^12",
"@dagrejs/dagre": "^1"
}
```
### 图结构映射
```
WorkflowGraph.edges → React Flow nodes + edges
节点(自动从 edges 推导):
- __start__ → 圆形小节点(入口)
- role → 圆角矩形,显示 role name + description
- __end__ → 圆形小节点(终止)
边:
- FALLBACK → 虚线(dashed),无 label
- condition → 实线,label = condition
hover tooltip = conditionDescription
```
### 布局
使用 dagre 自动计算 TB(top-to-bottom)方向布局:
```ts
import Dagre from "@dagrejs/dagre";
function layoutGraph(nodes, edges) {
const g = new Dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({}));
g.setGraph({ rankdir: "TB", nodesep: 60, ranksep: 80 });
for (const node of nodes) {
g.setNode(node.id, { width: 180, height: 60 });
}
for (const edge of edges) {
g.setEdge(edge.source, edge.target);
}
Dagre.layout(g);
return nodes.map((node) => {
const pos = g.node(node.id);
return { ...node, position: { x: pos.x - 90, y: pos.y - 30 } };
});
}
```
### 运行时高亮
ThreadDetail 已有 `records: ThreadRecord[]`,其中 `RoleRecord.role` 就是当前/历史执行的 role。
高亮逻辑:
```ts
function getNodeStates(records: ThreadRecord[]): Map<string, "completed" | "active"> {
const states = new Map<string, "completed" | "active">();
const roleRecords = records.filter((r) => r.type === "role");
for (let i = 0; i < roleRecords.length; i++) {
const role = roleRecords[i].role;
states.set(role, i === roleRecords.length - 1 ? "active" : "completed");
}
// 如果有 workflow-result,最后一个 role 也是 completed
if (records.some((r) => r.type === "workflow-result")) {
for (const [k] of states) {
states.set(k, "completed");
}
states.set("__end__", "completed");
}
states.set("__start__", "completed");
return states;
}
```
节点样式:
| 状态 | 样式 |
|------|------|
| default | `border: var(--color-border)`, 暗色背景 |
| completed | `border: var(--color-success)`, 绿色边框 + ✓ 图标 |
| active | `border: var(--color-accent)`, 蓝色边框 + 脉冲动画 |
边高亮:当 source 和 target 都至少 completed 时,边变绿。
## 组件结构
```
workflow-dashboard/src/
components/
workflow-graph/
types.ts — NodeState 等前端类型
index.ts — export { WorkflowGraph }
workflow-graph.tsx — 主组件,React Flow canvas
role-node.tsx — 自定义 role 节点
terminal-node.tsx — START/END 圆形节点
condition-edge.tsx — 自定义边(虚线/实线 + label)
use-layout.ts — dagre 布局 hook
```
### 集成到 ThreadDetail
在 ThreadDetail 中,records 列表上方插入可折叠的图面板:
```tsx
// thread-detail.tsx
{graph && (
<div className="mb-4 border rounded-lg overflow-hidden" style={{ height: 300 }}>
<WorkflowGraph graph={graph} nodeStates={getNodeStates(records)} />
</div>
)}
```
图高度固定 300px,React Flow 支持 pan + zoom,不影响下方 records 滚动。
## 实施计划
### ~~Phase 0: 数据层~~ ✅ Done (PR #201)
- [x] `WorkflowDefinition.moderator``table` (ModeratorTable)
- [x] `WorkflowDescriptor` 新增 `graph: WorkflowGraph`
- [x] `buildDescriptor` 自动提取 graph
- [x] `validateWorkflowDescriptor` 校验 graph
### Phase 1: API + 静态图渲染
1. serve API:`GET /workflows/:name` 返回 descriptor(含 graph),或新增 `GET /workflows/:name/descriptor`
2. Dashboard `api.ts` 新增 `getWorkflowDescriptor(agent, name)` 函数
3. 安装 `@xyflow/react` + `@dagrejs/dagre`
4. 实现 `workflow-graph/` 组件集
5. ThreadDetail 中集成:从 thread-start record 拿 workflow name → 请求 descriptor → 渲染图
**产出**:打开 ThreadDetail 看到 workflow 流程图,无高亮。
### Phase 2: 运行时高亮
1. ThreadDetail 根据 records 计算 nodeStates
2. 节点/边样式响应状态变化
3. SSE live 模式下实时更新高亮
**产出**:正在运行的 thread 能看到当前执行到哪个 role。
### Phase 3: 交互增强
1. 点击节点滚动到对应 role 的 RecordCard
2. 边 hover 显示 conditionDescription tooltip
3. 节点 hover 显示 role description + schema summary
**产出**:图和记录列表联动。
## 注意事项
- **自循环边**:如 `coder → coder (FALLBACK)`,React Flow 支持自循环,dagre 需要特殊处理(self-edge 用 loop 路径)
- **大图性能**:dagre 在 <50 节点时性能无忧,workflow 通常 <10 个 role
- **暗色主题**:Dashboard 已使用 CSS variables,节点/边样式复用现有色板
- **不提交 pnpm-lock.yaml**
@@ -17,7 +17,7 @@ import {
} from "../src/commands/workflow/index.js";
import { addCliArgs } from "./bundle-fixture.js";
const fixtureDescriptor = `export const descriptor = { description: "fixture", roles: {} };
const fixtureDescriptor = `export const descriptor = { description: "fixture", roles: {}, graph: { edges: [] } };
`;
const wfPutImport = `import { putContentMerkleNode } from "@uncaged/workflow-cas";
@@ -153,6 +153,7 @@ export const run = async function* (input) { return { returnCode: 0, summary: in
schema: { type: "object", properties: { greeting: { type: "string" } } },
},
},
graph: { edges: [] },
};
${wfPutImport}
export const run = async function* (input, options) {
@@ -24,6 +24,7 @@ export const descriptor = {
coder: { description: "coder", schema: {} },
reviewer: { description: "reviewer", schema: {} },
},
graph: { edges: [] },
};
export const run = async function* (input, options) {
const cas = options.cas;
@@ -45,8 +45,8 @@ describe("gc cli and garbageCollectCas", () => {
{
name: "demo",
hash: bundleHash,
depth: 0,
parentState: null,
},
promptHash,
);
@@ -100,8 +100,8 @@ describe("gc cli and garbageCollectCas", () => {
{
name: "demo",
hash: bundleHash,
depth: 0,
parentState: null,
},
promptHash,
);
@@ -135,8 +135,8 @@ describe("gc cli and garbageCollectCas", () => {
{
name: "demo",
hash: bundleHash,
depth: 0,
parentState: null,
},
promptHash,
);
@@ -64,6 +64,7 @@ describe("init template", () => {
const moder = await readFile(join(tdir, "src", "moderator.ts"), "utf8");
expect(moder).not.toContain("export default");
expect(moder).toContain("ModeratorTable");
});
test("finds workspace walking up from nested cwd", async () => {
@@ -82,7 +82,7 @@ describe("init workspace", () => {
for (const term of [
"RoleDefinition",
"WorkflowDefinition",
"Moderator",
"ModeratorTable",
"AgentFn",
"ExtractFn",
"RoleMeta",
@@ -36,6 +36,7 @@ const threadFixtureDescriptor = `export const descriptor = {
only: { description: "only", schema: {} },
noop: { description: "noop", schema: {} },
},
graph: { edges: [] },
};
`;
@@ -305,8 +306,13 @@ describe("cli thread commands", () => {
}
const threadId = ran.value.threadId;
const killBundleDir = getBundleDir(storageRoot, added.value.hash);
await new Promise((r) => setTimeout(r, 50));
await waitUntilPredicate(async () => {
const idx = await readThreadsIndex(killBundleDir);
const ent = idx[threadId];
return ent !== undefined && ent.head !== ent.start;
}, 80);
const killed = await cmdKill(storageRoot, threadId);
expect(killed.ok).toBe(true);
@@ -57,17 +57,13 @@ export const greeterRole: RoleDefinition<HelloTemplateMeta["greeter"]> = {
}
export function templateModeratorTs(): string {
return `import { END, type Moderator, type ModeratorContext } from "@uncaged/workflow-runtime";
return `import { END, START, type ModeratorTable } from "@uncaged/workflow-runtime";
import type { HelloTemplateMeta } from "./roles.js";
export const helloTemplateModerator: Moderator<HelloTemplateMeta> = (
ctx: ModeratorContext<HelloTemplateMeta>,
) => {
if (ctx.steps.length === 0) {
return "greeter";
}
return END;
export const helloTemplateTable: ModeratorTable<HelloTemplateMeta> = {
[START]: [{ condition: "FALLBACK", role: "greeter" }],
greeter: [{ condition: "FALLBACK", role: END }],
};
`;
}
@@ -75,7 +71,7 @@ export const helloTemplateModerator: Moderator<HelloTemplateMeta> = (
export function templateIndexTs(): string {
return `import type { WorkflowDefinition } from "@uncaged/workflow-runtime";
import { helloTemplateModerator } from "./moderator.js";
import { helloTemplateTable } from "./moderator.js";
import {
HELLO_TEMPLATE_DESCRIPTION,
type HelloTemplateMeta,
@@ -87,14 +83,14 @@ export {
type HelloTemplateMeta,
greeterRole,
} from "./roles.js";
export { helloTemplateModerator } from "./moderator.js";
export { helloTemplateTable } from "./moderator.js";
export const helloTemplateWorkflowDefinition: WorkflowDefinition<HelloTemplateMeta> = {
description: HELLO_TEMPLATE_DESCRIPTION,
roles: {
greeter: greeterRole,
},
moderator: helloTemplateModerator,
table: helloTemplateTable,
};
`;
}
@@ -85,7 +85,7 @@ function agentsMd(): string {
| 层级 | 目录 / 产物 | 职责 |
|------|----------------|------|
| **Workspace** | 仓库根(\`package.json\`\`workspaces: ["templates/*", "workflows"]\`) | Bun monorepo:统一管理本地模板包与 workflow 实例 |
| **Template** | \`templates/<name>/\`(如 \`src/roles.ts\`\`src/moderator.ts\`\`src/index.ts\`) | 纯数据:**WorkflowDefinition**(各 **RoleDefinition** + **Moderator**),**不绑定**具体 Agent |
| **Template** | \`templates/<name>/\`(如 \`src/roles.ts\`\`src/moderator.ts\`\`src/index.ts\`) | 纯数据:**WorkflowDefinition**(各 **RoleDefinition** + **ModeratorTable**),**不绑定**具体 Agent |
| **Workflow instance** | \`workflows/\`(或单独包) | 把模板与运行时 **AgentFn** / **ExtractFn** 组合,产出可注册的 **单文件 ESM bundle**(\`run\` + \`descriptor\` 命名导出) |
Init 生成的骨架:\`templates/\` 下放可复用定义,\`workflows/\` 下放绑定与打包入口。
@@ -94,19 +94,19 @@ Init 生成的骨架:\`templates/\` 下放可复用定义,\`workflows/\` 下
- **RoleMeta**:\`Record<string, Record<string, unknown>>\`,角色名 → 该角色结构化 meta 的形状约定。
- **RoleDefinition<Meta>**:纯数据——\`description\`\`systemPrompt\`\`schema\`(Zod v4)。不含执行逻辑。
- **WorkflowDefinition<M extends RoleMeta>**:\`description\` + \`roles\`(各角色定义)+ **Moderator**
- **Moderator**:\`(ctx: ModeratorContext<M>) => (角色名) | END\`。同步、纯函数,只做路由
- **WorkflowDefinition<M extends RoleMeta>**:\`description\` + \`roles\`(各角色定义)+ **ModeratorTable**(声明式路由表)
- **ModeratorTable**:\`START\` 与各角色名映射到有序 transition 列表(条件 + 下一角色或 \`END\`);可序列化,供描述符提取 **graph**
- **AgentFn**:\`(ctx: AgentContext) => Promise<string>\`,原始文本输出;从上下文读取当前角色的 \`systemPrompt\`
- **ExtractFn**:从 CAS content hash 解析结构化数据(引擎与 Agent 都可使用)。
引擎循环简述:**Moderator** → 选角色 → **Agent** 产出文本 → **Extract** 写入 **meta** → 追加 step,重复直至 **END**。详见 \`docs/architecture.md\` 中的三阶段说明。
引擎循环简述:**ModeratorTable** 选下一角色 → **Agent** 产出文本 → **Extract** 写入 **meta** → 追加 step,重复直至 **END**。详见 \`docs/architecture.md\` 中的三阶段说明。
## 3. 开发流程
1. **定义 RoleMeta**:为每个角色约定 meta 的 TypeScript 类型(与 Zod schema 对齐)。
2. **编写 RoleDefinition**:为每个角色写 Zod \`schema\`,补齐 \`systemPrompt\` / \`description\`
3. **编写 Moderator**:根据 \`ctx.steps\`业务状态返回下一个角色名或 \`END\`
4. **组装 WorkflowDefinition**:在模板 \`index\` 中导出 definition(以及必要的角色 / moderator 导出)。
3. **编写 ModeratorTable**: \`START\`各角色声明 transition(\`FALLBACK\` 或命名条件 + \`check\`
4. **组装 WorkflowDefinition**:在模板 \`index\` 中导出 definition(以及必要的角色 / table 导出)。
5. **实例化**:在 workflow 包中使用 \`createWorkflow(def, binding)\`(或项目约定的封装)绑定 **AgentFn**;**ExtractFn** 由引擎从 **workflow.yaml** 注入 \`WorkflowRuntime\`
6. **构建**:打包为单个 **.esm.js** bundle,使用 **uncaged-workflow add** 注册。
@@ -153,7 +153,7 @@ uncaged-workflow add <name> <path/to/bundle.esm.js>
---
编写新 workflow 时,先对齐 **RoleMeta → RoleDefinition(Zod)→ Moderator → 绑定 → 单文件 bundle**,再对照本节规范自检。
编写新 workflow 时,先对齐 **RoleMeta → RoleDefinition(Zod)→ ModeratorTable → 绑定 → 单文件 bundle**,再对照本节规范自检。
`;
}
@@ -164,7 +164,7 @@ Local workflow development workspace (Bun monorepo).
## Layout
- \`templates/\` — reusable workflow definition packages (roles + moderator), no agent binding
- \`templates/\` — reusable workflow definition packages (roles + ModeratorTable), no agent binding
- \`workflows/\` — workflow instances that bind templates to agents and export \`run\` + \`descriptor\`
## Commands
@@ -1,9 +1,14 @@
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import type { WorkflowDescriptor } from "@uncaged/workflow-protocol";
import {
getRegisteredWorkflow,
listRegisteredWorkflowNames,
readWorkflowRegistry,
validateWorkflowDescriptor,
} from "@uncaged/workflow-register";
import { Hono } from "hono";
import { parse as parseYaml } from "yaml";
export function createWorkflowRoutes(storageRoot: string): Hono {
const app = new Hono();
@@ -35,7 +40,17 @@ export function createWorkflowRoutes(storageRoot: string): Hono {
if (entry === null) {
return c.json({ error: `workflow not found: ${name}` }, 404);
}
return c.json({ name, ...entry });
let descriptor: WorkflowDescriptor | null = null;
try {
const yamlPath = join(storageRoot, "bundles", `${entry.hash}.yaml`);
const yamlText = await readFile(yamlPath, "utf8");
const parsed: unknown = parseYaml(yamlText);
const validated = validateWorkflowDescriptor(parsed);
descriptor = validated.ok ? validated.value : null;
} catch {
descriptor = null;
}
return c.json({ name, ...entry, descriptor });
});
app.get("/:name/history", async (c) => {
+14 -11
View File
@@ -189,25 +189,28 @@ export const run: WorkflowRun;
## WorkflowDescriptor
Defines the workflow's metadata and role sequence:
Serialized metadata for the registry (per-role JSON Schema plus a static routing graph):
\`\`\`typescript
type WorkflowDescriptor = {
name: string; // verb-first kebab-case, e.g. "solve-issue"
description: string; // one-line summary
roles: string[]; // ordered role names, e.g. ["planner", "coder", "reviewer"]
description: string;
roles: Record<string, { description: string; schema: unknown /* JSON Schema */ }>;
graph: {
edges: Array<{
from: string;
to: string;
condition: string;
conditionDescription: string | null;
}>;
};
};
\`\`\`
## WorkflowRun
The main function that creates and returns a moderator:
Async generator from \`createWorkflow(definition, binding)\` (**@uncaged/workflow-runtime**) — yields each role output until the workflow completes.
\`\`\`typescript
type WorkflowRun = (ctx: WorkflowContext) => Moderator;
\`\`\`
The **Moderator** controls the flow — it decides which role runs next, handles retries, and determines when the workflow is complete.
The **ModeratorTable** on **WorkflowDefinition** is declarative routing (from each role and \`START\` to the next role or \`END\`); the engine evaluates conditions at runtime.
## Role Definition
@@ -226,7 +229,7 @@ Each role has:
# 1. Initialize a workspace
uncaged-workflow init workspace my-workflow
# 2. Write your template (roles + moderator + descriptor)
# 2. Write your template (roles + ModeratorTable + descriptor)
# 3. Build the ESM bundle
bun run build
@@ -2,20 +2,32 @@ import { describe, expect, test } from "bun:test";
import { createCursorAgent, validateCursorAgentConfig } from "../src/index.js";
describe("validateCursorAgentConfig", () => {
test("accepts valid config", () => {
test("accepts valid config with explicit workspace", () => {
const r = validateCursorAgentConfig({
model: null,
timeout: 0,
workspace: "/tmp/test-project",
llmProvider: null,
});
expect(r.ok).toBe(true);
});
test("rejects non-function extract", () => {
test("accepts valid config with null workspace and llmProvider", () => {
const r = validateCursorAgentConfig({
model: null,
timeout: 0,
workspace: null,
llmProvider: { baseUrl: "http://localhost", apiKey: "test", model: "test" },
});
expect(r.ok).toBe(true);
});
test("rejects empty workspace string", () => {
const r = validateCursorAgentConfig({
model: null,
timeout: 0,
workspace: "",
llmProvider: null,
});
expect(r.ok).toBe(false);
if (!r.ok) {
@@ -23,22 +35,47 @@ describe("validateCursorAgentConfig", () => {
}
});
test("rejects null workspace without llmProvider", () => {
const r = validateCursorAgentConfig({
model: null,
timeout: 0,
workspace: null,
llmProvider: null,
});
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.error).toContain("llmProvider");
}
});
test("rejects negative timeout", () => {
const r = validateCursorAgentConfig({
model: null,
timeout: -1,
workspace: "/tmp/test-project",
llmProvider: null,
});
expect(r.ok).toBe(false);
});
});
describe("createCursorAgent", () => {
test("returns an AgentFn", () => {
test("returns an AgentFn with explicit workspace", () => {
const agent = createCursorAgent({
model: null,
timeout: 0,
workspace: "/tmp/test-project",
llmProvider: null,
});
expect(typeof agent).toBe("function");
});
test("returns an AgentFn with null workspace and llmProvider", () => {
const agent = createCursorAgent({
model: null,
timeout: 0,
workspace: null,
llmProvider: { baseUrl: "http://localhost", apiKey: "test", model: "test" },
});
expect(typeof agent).toBe("function");
});
@@ -49,6 +86,18 @@ describe("createCursorAgent", () => {
model: null,
timeout: -1,
workspace: "/tmp/test-project",
llmProvider: null,
}),
).toThrow();
});
test("throws when null workspace without llmProvider", () => {
expect(() =>
createCursorAgent({
model: null,
timeout: 0,
workspace: null,
llmProvider: null,
}),
).toThrow();
});
@@ -8,7 +8,10 @@
"test": "bun test"
},
"dependencies": {
"@uncaged/workflow-protocol": "workspace:*",
"@uncaged/workflow-reactor": "workspace:*",
"@uncaged/workflow-runtime": "workspace:*",
"@uncaged/workflow-util": "workspace:*",
"@uncaged/workflow-util-agent": "workspace:*",
"zod": "^4.0.0"
}
@@ -0,0 +1,73 @@
import type { AgentContext, LlmProvider } from "@uncaged/workflow-protocol";
import { createLlmFn, createThreadReactor } from "@uncaged/workflow-reactor";
import type { LogFn } from "@uncaged/workflow-util";
import * as z from "zod/v4";
const workspaceSchema = z.object({
workspace: z.string().describe("Absolute filesystem path of the project workspace"),
});
const EXTRACT_SYSTEM_FN = (_toolName: string) =>
`You are a workspace-path extractor. Given a workflow agent context (task description and previous step outputs), identify the absolute filesystem path of the project workspace where code changes should be made. Call the tool with the absolute path.`;
function buildExtractionInput(ctx: AgentContext): string {
const lines: string[] = [];
lines.push("## Task");
lines.push(ctx.start.content);
for (const step of ctx.steps) {
lines.push("");
lines.push(`## Step: ${step.role}`);
lines.push(`Meta: ${JSON.stringify(step.meta)}`);
}
return lines.join("\n");
}
export async function extractWorkspacePath(
ctx: AgentContext,
provider: LlmProvider,
logger: LogFn,
): Promise<string | null> {
const reactor = createThreadReactor<null>({
llm: createLlmFn(provider),
maxRounds: 2,
staticTools: [],
structuredToolFromSchema: (schema) => {
const jsonSchema = z.toJSONSchema(schema);
return {
name: "set_workspace",
tool: {
type: "function" as const,
function: {
name: "set_workspace",
description: "Set the extracted workspace path",
parameters: jsonSchema as Record<string, unknown>,
},
},
};
},
systemPromptForStructuredTool: EXTRACT_SYSTEM_FN,
toolHandler: async () => "unknown tool",
});
const result = await reactor({
thread: null,
input: buildExtractionInput(ctx),
schema: workspaceSchema,
});
if (!result.ok) {
logger("W8KN3QYT", `workspace extraction failed: ${result.error}`);
return null;
}
const workspace = result.value.workspace.trim();
if (!workspace.startsWith("/")) {
logger("H4PM7RXV", `workspace extraction returned non-absolute path: ${workspace}`);
return null;
}
logger("V3KM8QWP", `extracted workspace: ${workspace}`);
return workspace;
}
+22 -2
View File
@@ -1,6 +1,8 @@
import type { AgentFn } from "@uncaged/workflow-runtime";
import { createLogger } from "@uncaged/workflow-util";
import { buildAgentPrompt, type SpawnCliError, spawnCli } from "@uncaged/workflow-util-agent";
import { extractWorkspacePath } from "./extract-workspace.js";
import type { CursorAgentConfig } from "./types.js";
import { validateCursorAgentConfig } from "./validate-config.js";
@@ -26,7 +28,7 @@ function resolveCursorModel(model: string | null): string {
return model === null ? "auto" : model;
}
/** Runs `cursor-agent` with workspace from {@link CursorAgentConfig.extract} and prompt from context. */
/** Runs `cursor-agent` with workspace from config or extracted from context via LLM. */
export function createCursorAgent(config: CursorAgentConfig): AgentFn {
const validated = validateCursorAgentConfig(config);
if (!validated.ok) {
@@ -35,9 +37,27 @@ export function createCursorAgent(config: CursorAgentConfig): AgentFn {
const modelFlag = resolveCursorModel(config.model);
const timeoutMs = config.timeout > 0 ? config.timeout : null;
const logger = createLogger({ sink: { kind: "stderr" } });
return async (ctx) => {
const workspace = config.workspace;
let workspace: string;
if (config.workspace !== null) {
workspace = config.workspace;
} else {
if (config.llmProvider === null) {
throw new Error("cursor-agent: llmProvider is required when workspace is null");
}
const extracted = await extractWorkspacePath(ctx, config.llmProvider, logger);
if (extracted === null) {
throw new Error(
"cursor-agent: failed to extract workspace path from context. Provide an explicit workspace or ensure previous steps include a repoPath.",
);
}
workspace = extracted;
}
logger("R5HN3YKQ", `cursor-agent workspace: ${workspace}`);
const fullPrompt = await buildAgentPrompt(ctx);
const args = [
"-p",
+6 -1
View File
@@ -1,5 +1,10 @@
import type { LlmProvider } from "@uncaged/workflow-protocol";
export type CursorAgentConfig = {
model: string | null;
timeout: number;
workspace: string;
/** Explicit workspace path. When `null`, the agent extracts workspace from AgentContext via a ReAct LLM call. */
workspace: string | null;
/** Required when `workspace` is `null` — LLM provider used for workspace extraction. */
llmProvider: LlmProvider | null;
};
@@ -1,10 +1,13 @@
import { err, ok, type Result } from "@uncaged/workflow-runtime";
import { err, ok, type Result } from "@uncaged/workflow-protocol";
import type { CursorAgentConfig } from "./types.js";
export function validateCursorAgentConfig(config: CursorAgentConfig): Result<void, string> {
if (typeof config.workspace !== "string" || config.workspace.length === 0) {
return err("workspace must be a non-empty string (absolute path)");
if (config.workspace !== null && config.workspace.length === 0) {
return err("workspace must be a non-empty string (absolute path) or null for auto-detection");
}
if (config.workspace === null && config.llmProvider === null) {
return err("llmProvider is required when workspace is null (needed for workspace extraction)");
}
if (config.timeout < 0) {
return err("timeout must be a non-negative number (milliseconds); use 0 for no limit");
@@ -12,6 +12,7 @@ function makeCtx(userContent: string): AgentContext {
timestamp: 1,
},
depth: 0,
bundleHash: "TESTHASH00001",
steps: [],
threadId: "01TEST000000000000000000TR",
currentRole: { name: "planner", systemPrompt: "system instructions" },
@@ -14,6 +14,7 @@ function payload(
ancestors: partial.ancestors ?? [],
compact: partial.compact ?? null,
timestamp: partial.timestamp ?? 0,
childThread: partial.childThread ?? null,
};
}
@@ -62,4 +63,32 @@ describe("collectRefs", () => {
);
expect(refs).toEqual(["S2", "C2"]);
});
test("includes childThread hash when childThread is non-null", () => {
const refs = collectRefs(
payload({
role: "developer",
start: "S3",
content: "C3",
ancestors: ["A3"],
compact: null,
childThread: "CHILDEND000000000000001",
}),
);
expect(refs).toEqual(["S3", "C3", "A3", "CHILDEND000000000000001"]);
});
test("does not include childThread when childThread is null", () => {
const refs = collectRefs(
payload({
role: "developer",
start: "S4",
content: "C4",
ancestors: [],
compact: null,
childThread: null,
}),
);
expect(refs).toEqual(["S4", "C4"]);
});
});
@@ -0,0 +1,161 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { stringify } from "yaml";
import { createCasStore } from "../src/cas.js";
import { parseCasThreadNode, putStartNode, putStateNode } from "../src/nodes.js";
describe("putStartNode — parentState in refs", () => {
let dir: string;
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), "wf-cas-nodes-"));
});
afterEach(async () => {
await rm(dir, { recursive: true, force: true });
});
test("refs contains only promptHash when parentState is null", async () => {
const cas = createCasStore(join(dir, "cas"));
const promptHash = await cas.put("hello");
const startHash = await putStartNode(
cas,
{ name: "demo", hash: "BUNDLEAAAAAAAAA", depth: 0, parentState: null },
promptHash,
);
const blob = await cas.get(startHash);
expect(blob).not.toBeNull();
const parsed = parseCasThreadNode(blob ?? "");
expect(parsed).not.toBeNull();
expect(parsed?.kind).toBe("start");
if (parsed?.kind !== "start") return;
expect(parsed.node.refs).toEqual([promptHash]);
expect(parsed.node.payload.parentState).toBeNull();
});
test("refs contains [promptHash, parentStateHash] when parentState is set", async () => {
const cas = createCasStore(join(dir, "cas"));
const parentStateHash = await cas.put("fake-parent-state");
const promptHash = await cas.put("child-prompt");
const startHash = await putStartNode(
cas,
{ name: "develop", hash: "BUNDLEBBBBBBBBB", depth: 1, parentState: parentStateHash },
promptHash,
);
const blob = await cas.get(startHash);
expect(blob).not.toBeNull();
const parsed = parseCasThreadNode(blob ?? "");
expect(parsed).not.toBeNull();
expect(parsed?.kind).toBe("start");
if (parsed?.kind !== "start") return;
expect(parsed.node.refs).toEqual([promptHash, parentStateHash]);
expect(parsed.node.payload.parentState).toBe(parentStateHash);
});
});
describe("putStateNode — childThread in refs", () => {
let dir: string;
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), "wf-cas-nodes-state-"));
});
afterEach(async () => {
await rm(dir, { recursive: true, force: true });
});
test("refs does not include childThread when childThread is null", async () => {
const cas = createCasStore(join(dir, "cas"));
const startHash = await cas.put("start");
const contentHash = await cas.put("content");
const stateHash = await putStateNode(cas, {
role: "planner",
meta: {},
start: startHash,
content: contentHash,
ancestors: [],
compact: null,
timestamp: 1000,
childThread: null,
});
const blob = await cas.get(stateHash);
expect(blob).not.toBeNull();
const parsed = parseCasThreadNode(blob ?? "");
expect(parsed?.kind).toBe("state");
if (parsed?.kind !== "state") return;
expect(parsed.node.refs).not.toContain("anything-else");
expect(parsed.node.refs).toEqual([startHash, contentHash]);
expect(parsed.node.payload.childThread).toBeNull();
});
test("refs includes childThread hash when childThread is set", async () => {
const cas = createCasStore(join(dir, "cas"));
const startHash = await cas.put("start");
const contentHash = await cas.put("content");
const childEndHash = await cas.put("child-end-state");
const stateHash = await putStateNode(cas, {
role: "developer",
meta: { pr: 42 },
start: startHash,
content: contentHash,
ancestors: [],
compact: null,
timestamp: 2000,
childThread: childEndHash,
});
const blob = await cas.get(stateHash);
expect(blob).not.toBeNull();
const parsed = parseCasThreadNode(blob ?? "");
expect(parsed?.kind).toBe("state");
if (parsed?.kind !== "state") return;
expect(parsed.node.refs).toContain(childEndHash);
expect(parsed.node.payload.childThread).toBe(childEndHash);
});
});
describe("parseCasThreadNode — legacy node compatibility", () => {
test("start node without parentState field defaults to null", () => {
const yaml = stringify({
type: "start",
payload: { name: "demo", hash: "BUNDLEAAAAAAAAA", depth: 0 },
refs: ["PROMPTHASH00001"],
});
const parsed = parseCasThreadNode(yaml);
expect(parsed).not.toBeNull();
expect(parsed?.kind).toBe("start");
if (parsed?.kind !== "start") return;
expect(parsed.node.payload.parentState).toBeNull();
});
test("state node without childThread field defaults to null", () => {
const yaml = stringify({
type: "state",
payload: {
role: "planner",
meta: {},
start: "STARTHASH00001",
content: "CONTENTHASH0001",
ancestors: [],
compact: null,
timestamp: 1000,
},
refs: ["STARTHASH00001", "CONTENTHASH0001"],
});
const parsed = parseCasThreadNode(yaml);
expect(parsed).not.toBeNull();
expect(parsed?.kind).toBe("state");
if (parsed?.kind !== "state") return;
expect(parsed.node.payload.childThread).toBeNull();
});
});
@@ -9,5 +9,8 @@ export function collectRefs(payload: StateNode["payload"]): string[] {
if (payload.compact !== null) {
out.push(payload.compact);
}
if (payload.childThread !== null) {
out.push(payload.childThread);
}
return out;
}
+47 -3
View File
@@ -18,6 +18,10 @@ function isStartPayload(value: unknown): value is StartNodePayload {
if (!isRecord(value)) {
return false;
}
const parentState = value.parentState;
if (parentState !== undefined && parentState !== null && typeof parentState !== "string") {
return false;
}
return (
typeof value.name === "string" &&
typeof value.hash === "string" &&
@@ -25,6 +29,16 @@ function isStartPayload(value: unknown): value is StartNodePayload {
);
}
/** Normalizes a raw start payload, defaulting `parentState` to `null` for legacy nodes. */
function normalizeStartPayload(raw: StartNodePayload): StartNodePayload {
return {
name: raw.name,
hash: raw.hash,
depth: raw.depth,
parentState: raw.parentState ?? null,
};
}
function isStatePayload(value: unknown): value is StateNodePayload {
if (!isRecord(value)) {
return false;
@@ -41,6 +55,10 @@ function isStatePayload(value: unknown): value is StateNodePayload {
if (!isRecord(meta)) {
return false;
}
const childThread = value.childThread;
if (childThread !== undefined && childThread !== null && typeof childThread !== "string") {
return false;
}
return (
typeof value.role === "string" &&
typeof value.start === "string" &&
@@ -49,6 +67,20 @@ function isStatePayload(value: unknown): value is StateNodePayload {
);
}
/** Normalizes a raw state payload, defaulting `childThread` to `null` for legacy nodes. */
function normalizeStatePayload(raw: StateNodePayload): StateNodePayload {
return {
role: raw.role,
meta: raw.meta,
start: raw.start,
content: raw.content,
ancestors: raw.ancestors,
compact: raw.compact,
timestamp: raw.timestamp,
childThread: raw.childThread ?? null,
};
}
/** Parses a YAML CAS blob into a typed RFC v3 thread node (or legacy content layout with `children`). */
export function parseCasThreadNode(yamlText: string): ParsedCasThreadNode | null {
let raw: unknown;
@@ -86,14 +118,22 @@ export function parseCasThreadNode(yamlText: string): ParsedCasThreadNode | null
if (!isStartPayload(raw.payload)) {
return null;
}
const node: StartNode = { type: "start", payload: raw.payload, refs: [...refs] };
const node: StartNode = {
type: "start",
payload: normalizeStartPayload(raw.payload),
refs: [...refs],
};
return { kind: "start", node };
}
if (!isStatePayload(raw.payload)) {
return null;
}
const node: StateNode = { type: "state", payload: raw.payload, refs: [...refs] };
const node: StateNode = {
type: "state",
payload: normalizeStatePayload(raw.payload),
refs: [...refs],
};
return { kind: "state", node };
}
@@ -143,10 +183,14 @@ export async function putStartNode(
payload: StartNode["payload"],
promptHash: string,
): Promise<string> {
const refs = [promptHash];
if (payload.parentState !== null) {
refs.push(payload.parentState);
}
const node: StartNode = {
type: "start",
payload,
refs: [promptHash],
refs,
};
return store.put(serializeCasNode(node));
}
+2
View File
@@ -9,6 +9,8 @@
"preview": "vite preview"
},
"dependencies": {
"@dagrejs/dagre": "^3.0.0",
"@xyflow/react": "^12.10.2",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-markdown": "^10.1.0",
+39
View File
@@ -104,6 +104,36 @@ export type WorkflowResultRecord = {
export type ThreadRecord = ThreadStartRecord | RoleRecord | WorkflowResultRecord;
export type WorkflowGraphEdge = {
from: string;
to: string;
condition: string;
conditionDescription: string | null;
};
export type WorkflowGraph = {
edges: readonly WorkflowGraphEdge[];
};
export type WorkflowRoleDescriptor = {
description: string;
schema: Record<string, unknown>;
};
export type WorkflowDescriptor = {
description: string;
roles: Record<string, WorkflowRoleDescriptor>;
graph: WorkflowGraph;
};
export type WorkflowDetail = {
name: string;
hash: string;
timestamp: number;
history: unknown[];
descriptor: WorkflowDescriptor | null;
};
// ── Gateway endpoints ───────────────────────────────────────────────
export function listAgents(): Promise<AgentEndpoint[]> {
@@ -117,6 +147,15 @@ export function listWorkflows(agent: string): Promise<{ workflows: WorkflowSumma
return fetchJson(agentBase(agent), "/workflows");
}
export function getWorkflowDescriptor(
agent: string,
name: string,
): Promise<WorkflowDescriptor | null> {
return fetchJson<WorkflowDetail>(agentBase(agent), `/workflows/${encodeURIComponent(name)}`).then(
(res) => res.descriptor,
);
}
export function listThreads(agent: string): Promise<{ threads: ThreadSummary[] }> {
return fetchJson(agentBase(agent), "/threads");
}
@@ -1,8 +1,17 @@
import { useEffect, useRef, useState } from "react";
import { getThread, killThread, pauseThread, resumeThread } from "../api.ts";
import { useEffect, useMemo, useRef, useState } from "react";
import {
getThread,
getWorkflowDescriptor,
killThread,
pauseThread,
resumeThread,
type ThreadRecord,
type WorkflowDescriptor,
} from "../api.ts";
import { useFetch } from "../hooks.ts";
import { useSSE } from "../use-sse.ts";
import { RecordCard } from "./record-card.tsx";
import { type NodeState, WorkflowGraph } from "./workflow-graph/index.ts";
type Props = {
agent: string;
@@ -10,6 +19,84 @@ type Props = {
onBack: () => void;
};
function extractWorkflowName(records: readonly ThreadRecord[]): string | null {
for (const r of records) {
if (r.type === "thread-start") return r.workflow;
}
return null;
}
type GraphPanelProps = {
descriptor: WorkflowDescriptor;
workflowName: string | null;
nodeStates: Map<string, NodeState>;
};
function GraphPanel({ descriptor, workflowName, nodeStates }: GraphPanelProps) {
const [open, setOpen] = useState(true);
const edgeCount = descriptor.graph.edges.length;
return (
<div
className="mb-4 rounded-lg border overflow-hidden"
style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }}
>
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="w-full flex items-center justify-between px-3 py-2 text-xs"
style={{ color: "var(--color-text-muted)" }}
>
<span className="font-mono">
{open ? "▼" : "▶"} Workflow graph
{workflowName !== null && (
<span className="ml-2" style={{ color: "var(--color-text)" }}>
{workflowName}
</span>
)}
</span>
<span>
{edgeCount} edge{edgeCount === 1 ? "" : "s"}
</span>
</button>
{open && (
<div style={{ height: 300, width: "100%" }}>
<WorkflowGraph
graph={descriptor.graph}
roles={descriptor.roles}
nodeStates={nodeStates}
/>
</div>
)}
</div>
);
}
function computeNodeStates(records: readonly ThreadRecord[]): Map<string, NodeState> {
const states = new Map<string, NodeState>();
const roleRecords = records.filter(
(r): r is Extract<ThreadRecord, { type: "role" }> => r.type === "role",
);
const hasResult = records.some((r) => r.type === "workflow-result");
for (let i = 0; i < roleRecords.length; i++) {
const role = roleRecords[i].role;
const isLast = i === roleRecords.length - 1;
states.set(role, !hasResult && isLast ? "active" : "completed");
}
if (roleRecords.length > 0) {
states.set("__start__", "completed");
}
if (hasResult) {
states.set("__end__", "completed");
for (const [k, v] of states) {
if (v === "active") states.set(k, "completed");
}
}
return states;
}
export function ThreadDetail({ agent, threadId, onBack }: Props) {
const sse = useSSE(agent, threadId);
const { status, data, error } = useFetch(() => getThread(agent, threadId), [agent, threadId]);
@@ -23,6 +110,17 @@ export function ThreadDetail({ agent, threadId, onBack }: Props) {
? data.records
: ([] as typeof sse.records);
const workflowName = useMemo(() => extractWorkflowName(records), [records]);
const descriptorFetch = useFetch<WorkflowDescriptor | null>(
() =>
workflowName === null ? Promise.resolve(null) : getWorkflowDescriptor(agent, workflowName),
[agent, workflowName],
);
const descriptor = descriptorFetch.status === "ok" ? descriptorFetch.data : null;
const nodeStates = useMemo(() => computeNodeStates(records), [records]);
// biome-ignore lint/correctness/useExhaustiveDependencies: scroll when the rendered record list grows
useEffect(() => {
recordsEndRef.current?.scrollIntoView({ behavior: "smooth" });
@@ -95,6 +193,10 @@ export function ThreadDetail({ agent, threadId, onBack }: Props) {
</p>
)}
{descriptor !== null && descriptor.graph.edges.length > 0 && (
<GraphPanel descriptor={descriptor} workflowName={workflowName} nodeStates={nodeStates} />
)}
{status === "loading" && !liveActive && records.length === 0 && (
<p style={{ color: "var(--color-text-muted)" }}>Loading...</p>
)}
@@ -0,0 +1,76 @@
import {
BaseEdge,
EdgeLabelRenderer,
type EdgeProps,
getBezierPath,
getSmoothStepPath,
} from "@xyflow/react";
import type { ConditionEdgeData } from "./types.ts";
export function ConditionEdge(props: EdgeProps) {
const {
id,
source,
target,
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
data,
markerEnd,
} = props;
const edgeData = data as ConditionEdgeData | undefined;
const isFallback = edgeData?.isFallback ?? false;
const isSelfLoop = source === target;
const [path, labelX, labelY] = isSelfLoop
? getSmoothStepPath({
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
borderRadius: 20,
})
: getBezierPath({
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
});
const stroke = isFallback ? "var(--color-text-muted)" : "var(--color-text)";
const strokeDasharray = isFallback ? "5 4" : undefined;
return (
<>
<BaseEdge
id={id}
path={path}
markerEnd={markerEnd}
style={{ stroke, strokeWidth: 1.5, strokeDasharray }}
/>
{edgeData && !isFallback && edgeData.condition !== "" && (
<EdgeLabelRenderer>
<div
className="absolute px-1.5 py-0.5 rounded text-[10px] font-mono pointer-events-auto"
style={{
transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`,
background: "var(--color-bg)",
border: "1px solid var(--color-border)",
color: "var(--color-text)",
}}
title={edgeData.conditionDescription ?? undefined}
>
{edgeData.condition}
</div>
</EdgeLabelRenderer>
)}
</>
);
}
@@ -0,0 +1,2 @@
export type { NodeState } from "./types.ts";
export { WorkflowGraph } from "./workflow-graph.tsx";
@@ -0,0 +1,69 @@
import { Handle, type NodeProps, Position } from "@xyflow/react";
import type { RoleNodeData } from "./types.ts";
function borderColor(state: RoleNodeData["state"]): string {
switch (state) {
case "completed":
return "var(--color-success)";
case "active":
return "var(--color-accent)";
default:
return "var(--color-border)";
}
}
function stateIcon(state: RoleNodeData["state"]): string | null {
if (state === "completed") return "✓";
if (state === "active") return "●";
return null;
}
export function RoleNode(props: NodeProps) {
const data = props.data as RoleNodeData;
const icon = stateIcon(data.state);
const isActive = data.state === "active";
const handleStyle = {
background: "var(--color-text-muted)",
width: 6,
height: 6,
border: "none",
} as const;
return (
<div
className={`px-3 py-2 rounded-md border-2 text-xs font-medium ${isActive ? "wf-node-pulse" : ""}`}
style={{
width: 180,
height: 60,
background: "var(--color-surface)",
borderColor: borderColor(data.state),
color: "var(--color-text)",
display: "flex",
flexDirection: "column",
justifyContent: "center",
boxSizing: "border-box",
}}
title={data.description}
>
<Handle type="target" position={Position.Top} style={handleStyle} isConnectable={false} />
<div className="flex items-center gap-1.5 font-mono">
{icon !== null && (
<span
style={{
color: data.state === "active" ? "var(--color-accent)" : "var(--color-success)",
}}
>
{icon}
</span>
)}
<span className="truncate">{data.label}</span>
</div>
{data.description !== "" && (
<div className="text-[10px] truncate mt-0.5" style={{ color: "var(--color-text-muted)" }}>
{data.description}
</div>
)}
<Handle type="source" position={Position.Bottom} style={handleStyle} isConnectable={false} />
</div>
);
}
@@ -0,0 +1,57 @@
import { Handle, type NodeProps, Position } from "@xyflow/react";
import type { TerminalNodeData } from "./types.ts";
function borderColor(state: TerminalNodeData["state"]): string {
switch (state) {
case "completed":
return "var(--color-success)";
case "active":
return "var(--color-accent)";
default:
return "var(--color-border)";
}
}
function bgColor(state: TerminalNodeData["state"]): string {
if (state === "completed") return "var(--color-success)";
if (state === "active") return "var(--color-accent)";
return "var(--color-surface)";
}
export function TerminalNode(props: NodeProps) {
const data = props.data as TerminalNodeData;
const isStart = data.kind === "start";
const isActive = data.state === "active";
const handleStyle = {
background: "var(--color-text-muted)",
width: 6,
height: 6,
border: "none",
} as const;
return (
<div
className={`rounded-full border-2 flex items-center justify-center text-[10px] font-bold ${isActive ? "wf-node-pulse" : ""}`}
style={{
width: 40,
height: 40,
background: bgColor(data.state),
borderColor: borderColor(data.state),
color: data.state === "default" ? "var(--color-text-muted)" : "var(--color-bg)",
}}
title={isStart ? "Start" : "End"}
>
{isStart ? (
<Handle
type="source"
position={Position.Bottom}
style={handleStyle}
isConnectable={false}
/>
) : (
<Handle type="target" position={Position.Top} style={handleStyle} isConnectable={false} />
)}
{isStart ? "▶" : "■"}
</div>
);
}
@@ -0,0 +1,29 @@
import type { WorkflowGraphEdge } from "../../api.ts";
export type NodeState = "default" | "completed" | "active";
export type TerminalKind = "start" | "end";
export type RoleNodeData = {
label: string;
description: string;
state: NodeState;
[key: string]: unknown;
};
export type TerminalNodeData = {
kind: TerminalKind;
state: NodeState;
[key: string]: unknown;
};
export type ConditionEdgeData = {
condition: string;
conditionDescription: string | null;
isFallback: boolean;
[key: string]: unknown;
};
export type GraphInput = {
edges: readonly WorkflowGraphEdge[];
};
@@ -0,0 +1,127 @@
import Dagre from "@dagrejs/dagre";
import type { Edge, Node } from "@xyflow/react";
import { useMemo } from "react";
import type { WorkflowGraphEdge } from "../../api.ts";
import type { ConditionEdgeData, NodeState, RoleNodeData, TerminalNodeData } from "./types.ts";
const START_ID = "__start__";
const END_ID = "__end__";
const ROLE_NODE_WIDTH = 180;
const ROLE_NODE_HEIGHT = 60;
const TERMINAL_NODE_SIZE = 40;
type LayoutInput = {
edges: readonly WorkflowGraphEdge[];
roles: Record<string, { description: string }>;
nodeStates: Map<string, NodeState>;
};
type LayoutResult = {
nodes: Node[];
edges: Edge[];
};
function collectNodeIds(edges: readonly WorkflowGraphEdge[]): Set<string> {
const ids = new Set<string>();
for (const e of edges) {
ids.add(e.from);
ids.add(e.to);
}
return ids;
}
function nodeSize(id: string): { width: number; height: number } {
if (id === START_ID || id === END_ID) {
return { width: TERMINAL_NODE_SIZE, height: TERMINAL_NODE_SIZE };
}
return { width: ROLE_NODE_WIDTH, height: ROLE_NODE_HEIGHT };
}
function buildRoleNode(
id: string,
pos: { x: number; y: number },
roles: Record<string, { description: string }>,
state: NodeState,
): Node<RoleNodeData> {
const description = roles[id]?.description ?? "";
return {
id,
type: "role",
position: pos,
data: { label: id, description, state },
draggable: false,
};
}
function buildTerminalNode(
id: string,
pos: { x: number; y: number },
state: NodeState,
): Node<TerminalNodeData> {
return {
id,
type: "terminal",
position: pos,
data: { kind: id === START_ID ? "start" : "end", state },
draggable: false,
selectable: false,
};
}
function edgeKey(e: WorkflowGraphEdge): string {
return `${e.from}->${e.to}::${e.condition}`;
}
function buildEdge(e: WorkflowGraphEdge): Edge<ConditionEdgeData> {
const isFallback = e.condition === "FALLBACK";
return {
id: edgeKey(e),
source: e.from,
target: e.to,
type: "condition",
data: {
condition: e.condition,
conditionDescription: e.conditionDescription,
isFallback,
},
};
}
export function useLayout(input: LayoutInput): LayoutResult {
return useMemo(() => {
const ids = collectNodeIds(input.edges);
const g = new Dagre.graphlib.Graph({ multigraph: true }).setDefaultEdgeLabel(() => ({}));
g.setGraph({ rankdir: "TB", nodesep: 60, ranksep: 80 });
for (const id of ids) {
const size = nodeSize(id);
g.setNode(id, { width: size.width, height: size.height });
}
for (const e of input.edges) {
if (e.from === e.to) {
continue;
}
g.setEdge(e.from, e.to, {}, edgeKey(e));
}
Dagre.layout(g);
const nodes: Node[] = [];
for (const id of ids) {
const dagNode = g.node(id);
const size = nodeSize(id);
const pos = { x: dagNode.x - size.width / 2, y: dagNode.y - size.height / 2 };
const state = input.nodeStates.get(id) ?? "default";
if (id === START_ID || id === END_ID) {
nodes.push(buildTerminalNode(id, pos, state));
} else {
nodes.push(buildRoleNode(id, pos, input.roles, state));
}
}
const edges: Edge[] = input.edges.map(buildEdge);
return { nodes, edges };
}, [input.edges, input.roles, input.nodeStates]);
}
@@ -0,0 +1,61 @@
import { Background, type EdgeTypes, MarkerType, type NodeTypes, ReactFlow } from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import { useMemo } from "react";
import type { WorkflowGraph as WorkflowGraphData } from "../../api.ts";
import { ConditionEdge } from "./condition-edge.tsx";
import { RoleNode } from "./role-node.tsx";
import { TerminalNode } from "./terminal-node.tsx";
import type { NodeState } from "./types.ts";
import { useLayout } from "./use-layout.ts";
type Props = {
graph: WorkflowGraphData;
roles: Record<string, { description: string }>;
nodeStates: Map<string, NodeState>;
};
const nodeTypes: NodeTypes = {
role: RoleNode,
terminal: TerminalNode,
};
const edgeTypes: EdgeTypes = {
condition: ConditionEdge,
};
export function WorkflowGraph({ graph, roles, nodeStates }: Props) {
const layout = useLayout({ edges: graph.edges, roles, nodeStates });
const styledEdges = useMemo(
() =>
layout.edges.map((e) => ({
...e,
markerEnd: {
type: MarkerType.ArrowClosed,
width: 14,
height: 14,
color: "var(--color-text)",
},
})),
[layout.edges],
);
return (
<ReactFlow
nodes={layout.nodes}
edges={styledEdges}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
fitView
fitViewOptions={{ padding: 0.15 }}
nodesDraggable={false}
nodesConnectable={false}
elementsSelectable={false}
proOptions={{ hideAttribution: true }}
colorMode="dark"
style={{ background: "var(--color-bg)" }}
>
<Background color="var(--color-border)" gap={20} size={1} />
</ReactFlow>
);
}
+14
View File
@@ -19,3 +19,17 @@ body {
color: var(--color-text);
font-family: "Inter", system-ui, -apple-system, sans-serif;
}
@keyframes wf-node-pulse {
0%,
100% {
box-shadow: 0 0 0 0 rgba(124, 109, 240, 0.55);
}
50% {
box-shadow: 0 0 0 6px rgba(124, 109, 240, 0);
}
}
.wf-node-pulse {
animation: wf-node-pulse 1.6s ease-in-out infinite;
}
@@ -35,6 +35,7 @@ function noLogger(): (tag: string, content: string) => void {
function makeOptions(overrides: Partial<ExecuteThreadOptions>): ExecuteThreadOptions {
return {
depth: 0,
parentStateHash: null,
signal: new AbortController().signal,
awaitAfterEachYield: async () => {},
forkSourceThreadId: null,
@@ -144,9 +145,9 @@ describe("executeThread (Phase 2 — CAS thread storage)", () => {
runtime: WorkflowRuntime,
): AsyncGenerator<RoleOutput, WorkflowCompletion> {
const h1 = await runtime.cas.put("plan-text");
yield { role: "planner", contentHash: h1, meta: { plan: 1 }, refs: [h1] };
yield { role: "planner", contentHash: h1, meta: { plan: 1 }, refs: [h1], childThread: null };
const h2 = await runtime.cas.put("code-text");
yield { role: "coder", contentHash: h2, meta: { diff: "y" }, refs: [h2] };
yield { role: "coder", contentHash: h2, meta: { diff: "y" }, refs: [h2], childThread: null };
return { returnCode: 0, summary: "done" };
};
@@ -210,7 +211,7 @@ describe("executeThread (Phase 2 — CAS thread storage)", () => {
runtime: WorkflowRuntime,
): AsyncGenerator<RoleOutput, WorkflowCompletion> {
const h = await runtime.cas.put("only-step");
yield { role: "only", contentHash: h, meta: {}, refs: [h] };
yield { role: "only", contentHash: h, meta: {}, refs: [h], childThread: null };
return { returnCode: 0, summary: "completed" };
};
@@ -261,7 +262,7 @@ describe("executeThread (Phase 2 — CAS thread storage)", () => {
runtime: WorkflowRuntime,
): AsyncGenerator<RoleOutput, WorkflowCompletion> {
const h = await runtime.cas.put("step");
yield { role: "only", contentHash: h, meta: {}, refs: [h] };
yield { role: "only", contentHash: h, meta: {}, refs: [h], childThread: null };
return { returnCode: 0, summary: "done" };
};
@@ -46,6 +46,7 @@ describe("garbageCollectCas (mark-and-sweep)", () => {
name: "demo",
hash: bundleHash,
depth: 0,
parentState: null,
},
promptHash,
);
@@ -59,6 +60,7 @@ describe("garbageCollectCas (mark-and-sweep)", () => {
ancestors: [],
compact: null,
timestamp: 1,
childThread: null,
} satisfies StateNodePayload);
const c2 = await putContentNodeWithRefs(cas, "c1", []);
@@ -70,6 +72,7 @@ describe("garbageCollectCas (mark-and-sweep)", () => {
ancestors: [h1],
compact: null,
timestamp: 2,
childThread: null,
} satisfies StateNodePayload);
const ec = await putContentNodeWithRefs(cas, "", []);
@@ -81,6 +84,7 @@ describe("garbageCollectCas (mark-and-sweep)", () => {
ancestors: [h1],
compact: null,
timestamp: 3,
childThread: null,
} satisfies StateNodePayload);
await upsertThreadEntry(bundleDir, "THREAD_AAAAAAA", {
@@ -0,0 +1,306 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { CasStore } from "@uncaged/workflow-cas";
import { createCasStore, parseCasThreadNode } from "@uncaged/workflow-cas";
import type { StartNode, StateNode } from "@uncaged/workflow-protocol";
import type {
RoleOutput,
ThreadContext,
WorkflowCompletion,
WorkflowFn,
WorkflowRuntime,
} from "@uncaged/workflow-runtime";
import { executeThread } from "../src/engine/engine.js";
import type { ExecuteThreadIo, ExecuteThreadOptions } from "../src/engine/types.js";
const TEST_REGISTRY_YAML = `config:
maxDepth: 3
supervisorInterval: 0
providers:
stub:
baseUrl: http://127.0.0.1:9
apiKey: test
models:
default: stub/m
workflows: {}
`;
function noLogger(): (tag: string, content: string) => void {
return () => {};
}
function makeOptions(overrides: Partial<ExecuteThreadOptions>): ExecuteThreadOptions {
return {
depth: 0,
parentStateHash: null,
signal: new AbortController().signal,
awaitAfterEachYield: async () => {},
forkSourceThreadId: null,
prefilledDiskSteps: null,
forkContinuation: null,
replayTimestamps: null,
storageRoot: "/tmp/never",
...overrides,
};
}
async function setupStorage(): Promise<{
storageRoot: string;
casDir: string;
}> {
const storageRoot = await mkdtemp(join(tmpdir(), "uncaged-wf-merkle-"));
await writeFile(join(storageRoot, "workflow.yaml"), TEST_REGISTRY_YAML, "utf8");
const casDir = join(storageRoot, "cas");
await mkdir(casDir, { recursive: true });
return { storageRoot, casDir };
}
async function loadStartNode(cas: CasStore, endHash: string): Promise<StartNode> {
const endBlob = await cas.get(endHash);
const endParsed = parseCasThreadNode(endBlob ?? "");
if (endParsed?.kind !== "state") throw new Error("expected state node");
const startBlob = await cas.get(endParsed.node.payload.start);
const startParsed = parseCasThreadNode(startBlob ?? "");
if (startParsed?.kind !== "start") throw new Error("expected start node");
return startParsed.node;
}
async function loadStateNode(cas: CasStore, hash: string): Promise<StateNode> {
const blob = await cas.get(hash);
const parsed = parseCasThreadNode(blob ?? "");
if (parsed?.kind !== "state") throw new Error("expected state node");
return parsed.node;
}
describe("Merkle call stack — cross-thread DAG linking (Phase 2)", () => {
let storageRoot: string;
let casDir: string;
beforeEach(async () => {
const setup = await setupStorage();
storageRoot = setup.storageRoot;
casDir = setup.casDir;
});
afterEach(async () => {
await rm(storageRoot, { recursive: true, force: true });
});
test("parentStateHash is written into child start node's parentState and refs", async () => {
const cas = createCasStore(casDir);
// biome-ignore lint/correctness/useYield: testing start-only path
const parentWf: WorkflowFn = async function* (
_thread: ThreadContext,
_runtime: WorkflowRuntime,
): AsyncGenerator<RoleOutput, WorkflowCompletion> {
return { returnCode: 0, summary: "parent done" };
};
const parentResult = await executeThread(
parentWf,
"parent-wf",
{ prompt: "parent task", steps: [] },
makeOptions({ storageRoot }),
{
threadId: "P_THREAD_01",
hash: "PARENTHASH0001",
infoJsonlPath: join(storageRoot, "logs", "PARENTHASH0001", "P1.info.jsonl"),
cas,
},
noLogger(),
);
// biome-ignore lint/correctness/useYield: testing start-only path
const childWf: WorkflowFn = async function* (
_thread: ThreadContext,
_runtime: WorkflowRuntime,
): AsyncGenerator<RoleOutput, WorkflowCompletion> {
return { returnCode: 0, summary: "child done" };
};
const childResult = await executeThread(
childWf,
"child-wf",
{ prompt: "child task", steps: [] },
makeOptions({ storageRoot, depth: 1, parentStateHash: parentResult.rootHash }),
{
threadId: "C_THREAD_01",
hash: "CHILDHASH00001",
infoJsonlPath: join(storageRoot, "logs", "CHILDHASH00001", "C1.info.jsonl"),
cas,
},
noLogger(),
);
const childStart = await loadStartNode(cas, childResult.rootHash);
expect(childStart.payload.parentState).toBe(parentResult.rootHash);
expect(childStart.refs).toContain(parentResult.rootHash);
});
test("childThread on parent state node points to child's final state and is in refs", async () => {
const cas = createCasStore(casDir);
const childFinalHash = "CHILD_FINAL_001";
const parentWf: WorkflowFn = async function* (
_thread: ThreadContext,
runtime: WorkflowRuntime,
): AsyncGenerator<RoleOutput, WorkflowCompletion> {
const h = await runtime.cas.put("developer output");
yield {
role: "developer",
contentHash: h,
meta: { action: "delegate" },
refs: [h],
childThread: childFinalHash,
};
return { returnCode: 0, summary: "parent complete" };
};
const result = await executeThread(
parentWf,
"parent-wf",
{ prompt: "parent task", steps: [] },
makeOptions({ storageRoot }),
{
threadId: "P_THREAD_02",
hash: "CTHREAD_TEST01",
infoJsonlPath: join(storageRoot, "logs", "CTHREAD_TEST01", "P2.info.jsonl"),
cas,
},
noLogger(),
);
const endNode = await loadStateNode(cas, result.rootHash);
const devStateHash = endNode.payload.ancestors[0] ?? "";
const devNode = await loadStateNode(cas, devStateHash);
expect(devNode.payload.role).toBe("developer");
expect(devNode.payload.childThread).toBe(childFinalHash);
expect(devNode.refs).toContain(childFinalHash);
});
test("parent state with no child has childThread: null", async () => {
const cas = createCasStore(casDir);
const wf: WorkflowFn = async function* (
_thread: ThreadContext,
runtime: WorkflowRuntime,
): AsyncGenerator<RoleOutput, WorkflowCompletion> {
const h = await runtime.cas.put("prep output");
yield { role: "preparer", contentHash: h, meta: {}, refs: [h], childThread: null };
return { returnCode: 0, summary: "done" };
};
const result = await executeThread(
wf,
"test-wf",
{ prompt: "task", steps: [] },
makeOptions({ storageRoot }),
{
threadId: "NULL_CT_01",
hash: "NULLCT_TEST001",
infoJsonlPath: join(storageRoot, "logs", "NULLCT_TEST001", "N1.info.jsonl"),
cas,
},
noLogger(),
);
const endNode = await loadStateNode(cas, result.rootHash);
const prepHash = endNode.payload.ancestors[0] ?? "";
const prepNode = await loadStateNode(cas, prepHash);
expect(prepNode.payload.childThread).toBeNull();
expect(prepNode.refs).not.toContain(null);
});
test("full bidirectional: child parentState is traversable to parent's context", async () => {
const cas = createCasStore(casDir);
const parentHash = "BIDIR_PARENT01";
const parentWf: WorkflowFn = async function* (
_thread: ThreadContext,
runtime: WorkflowRuntime,
): AsyncGenerator<RoleOutput, WorkflowCompletion> {
const h1 = await runtime.cas.put("preparation output");
yield {
role: "preparer",
contentHash: h1,
meta: { repoPath: "/test" },
refs: [h1],
childThread: null,
};
const h2 = await runtime.cas.put("developer output");
yield {
role: "developer",
contentHash: h2,
meta: { action: "code" },
refs: [h2],
childThread: "CHILD_END_HASH1",
};
return { returnCode: 0, summary: "all done" };
};
const observedHeads: string[] = [];
const opts = makeOptions({
storageRoot,
awaitAfterEachYield: async () => {
const bundleDir = join(storageRoot, "bundles", parentHash);
const text = await readFile(join(bundleDir, "threads.json"), "utf8");
const parsed = JSON.parse(text) as Record<string, { head: string }>;
const head = parsed.BIDIR_T_001?.head ?? null;
if (head !== null) observedHeads.push(head);
},
});
await executeThread(
parentWf,
"bidir-wf",
{ prompt: "bidir test", steps: [] },
opts,
{
threadId: "BIDIR_T_001",
hash: parentHash,
infoJsonlPath: join(storageRoot, "logs", parentHash, "BD1.info.jsonl"),
cas,
},
noLogger(),
);
expect(observedHeads.length).toBe(2);
const preparerStateHash = observedHeads[0] ?? "";
// Execute child with parentState pointing to parent's preparer state
// biome-ignore lint/correctness/useYield: testing start-only path
const childWf: WorkflowFn = async function* (
_t: ThreadContext,
_r: WorkflowRuntime,
): AsyncGenerator<RoleOutput, WorkflowCompletion> {
return { returnCode: 0, summary: "child ok" };
};
const childResult = await executeThread(
childWf,
"bidir-child",
{ prompt: "child bidir", steps: [] },
makeOptions({ storageRoot, depth: 1, parentStateHash: preparerStateHash }),
{
threadId: "BIDIR_C_001",
hash: "BIDIR_CHILD001",
infoJsonlPath: join(storageRoot, "logs", "BIDIR_CHILD001", "BC1.info.jsonl"),
cas,
},
noLogger(),
);
// Upward traversal: child start → parentState → preparer state → meta.repoPath
const childStart = await loadStartNode(cas, childResult.rootHash);
expect(childStart.payload.parentState).toBe(preparerStateHash);
const parentPrep = await loadStateNode(cas, preparerStateHash);
expect(parentPrep.payload.meta.repoPath).toBe("/test");
});
});
@@ -94,6 +94,7 @@ async function appendStateForStep(params: {
meta: Record<string, unknown>;
refs: readonly string[];
timestamp: number;
childThread: string | null;
}): Promise<{ stateHash: string; chain: ChainState }> {
const text = await getContentMerklePayload(params.cas, params.contentHash);
if (text === null) {
@@ -112,6 +113,7 @@ async function appendStateForStep(params: {
ancestors,
compact: null,
timestamp: params.timestamp,
childThread: params.childThread,
};
const stateHash = await putStateNode(params.cas, payload);
return {
@@ -137,6 +139,7 @@ async function appendEndState(params: {
ancestors,
compact: null,
timestamp: params.timestamp,
childThread: null,
};
return putStateNode(params.cas, payload);
}
@@ -329,6 +332,7 @@ async function driveWorkflowGenerator(params: {
meta: step.meta,
refs: step.refs,
timestamp: ts,
childThread: step.childThread ?? null,
});
chain = written_.chain;
await publishHead({ bundleDir, threadId, startHash, headHash: written_.stateHash });
@@ -439,6 +443,7 @@ export async function executeThread(
name: workflowName,
hash: io.hash,
depth: options.depth,
parentState: options.parentStateHash,
},
promptHash,
);
@@ -466,6 +471,7 @@ export async function executeThread(
meta: row.meta,
refs: row.refs,
timestamp: row.timestamp,
childThread: null,
});
chain = written.chain;
await publishHead({
@@ -487,6 +493,7 @@ export async function executeThread(
const thread: ThreadContext = {
threadId: io.threadId,
depth: options.depth,
bundleHash: io.hash,
start: {
role: START,
content: input.prompt,
@@ -144,6 +144,7 @@ async function payloadToRoleOutput(cas: CasStore, payload: StateNodePayload): Pr
contentHash: payload.content,
meta: payload.meta,
refs,
childThread: payload.childThread,
};
}
@@ -240,6 +241,7 @@ async function buildForkContinuation(params: {
ancestors: ancestorsMarker,
compact: null,
timestamp: Date.now(),
childThread: null,
};
const markerHash = await putStateNode(cas, markerPayload);
@@ -41,6 +41,8 @@ export type PrefilledDiskStep = {
export type ExecuteThreadOptions = {
/** Passed to the bundle thread context as `ThreadContext.depth`. */
depth: number;
/** Parent thread's head state hash at spawn time; `null` for top-level threads. */
parentStateHash: string | null;
signal: AbortSignal;
/** Invoked after each successful yield (and outer-loop checks); used for pause/resume. */
awaitAfterEachYield: () => Promise<void>;
@@ -72,11 +72,13 @@ function parseRoleOutputRecord(obj: Record<string, unknown>): RoleOutput | null
if (meta === null || typeof meta !== "object") {
return null;
}
const childThread = obj.childThread;
return {
role,
contentHash,
meta: meta as Record<string, unknown>,
refs: normalizeRefsField(obj.refs),
childThread: typeof childThread === "string" ? childThread : null,
};
}
@@ -497,6 +499,7 @@ async function main(): Promise<void> {
{ prompt: cmd.prompt, steps: cmd.steps },
{
...cmd.options,
parentStateHash: null,
signal: ac.signal,
awaitAfterEachYield: () => pauseGate.awaitAfterYield(),
forkSourceThreadId: cmd.forkSourceThreadId,
@@ -6,7 +6,7 @@ import {
getRegisteredWorkflow,
readWorkflowRegistry,
} from "@uncaged/workflow-register";
import type { AgentContext, AgentFn } from "@uncaged/workflow-runtime";
import type { AgentContext, AgentFn, AgentFnResult } from "@uncaged/workflow-runtime";
import {
createLogger,
generateUlid,
@@ -14,7 +14,7 @@ import {
getGlobalCasDir,
} from "@uncaged/workflow-util";
import type { ExecuteThreadIo } from "./engine/index.js";
import { executeThread } from "./engine/index.js";
import { executeThread, getBundleDir, readThreadsIndex } from "./engine/index.js";
const DEFAULT_WORKFLOW_AS_AGENT_MAX_DEPTH = 3;
@@ -37,6 +37,13 @@ function resolveWorkflowAsAgentStorageRoot(options: WorkflowAsAgentOptions | nul
return getDefaultWorkflowStorageRoot();
}
async function readParentHeadState(storageRoot: string, ctx: AgentContext): Promise<string | null> {
const bundleDir = getBundleDir(storageRoot, ctx.bundleHash);
const index = await readThreadsIndex(bundleDir);
const entry = index[ctx.threadId] ?? null;
return entry !== null ? entry.head : null;
}
/**
* Returns an {@link AgentFn} that runs another registered workflow in a new thread,
* using the parent thread's initial prompt (`ctx.start.content`) as the child prompt.
@@ -45,7 +52,7 @@ export function workflowAsAgent(
workflowName: string,
options: WorkflowAsAgentOptions | null = null,
): AgentFn {
return async (ctx: AgentContext): Promise<string> => {
return async (ctx: AgentContext): Promise<AgentFnResult> => {
const nextDepth = ctx.depth + 1;
const storageRoot = resolveWorkflowAsAgentStorageRoot(options);
@@ -89,6 +96,8 @@ export function workflowAsAgent(
const logger = createLogger({ sink: { kind: "file", path: infoJsonlPath } });
const signalNever = new AbortController();
const parentHeadState = await readParentHeadState(storageRoot, ctx);
try {
const result = await executeThread(
bundleExportsResult.value.run,
@@ -96,6 +105,7 @@ export function workflowAsAgent(
input,
{
depth: nextDepth,
parentStateHash: parentHeadState,
signal: signalNever.signal,
awaitAfterEachYield: async () => {},
forkSourceThreadId: ctx.threadId,
@@ -107,7 +117,8 @@ export function workflowAsAgent(
io,
logger,
);
return `Child workflow "${workflowName}" completed (returnCode=${result.returnCode}).\n\nSummary: ${result.summary}\n\nChild thread root hash: ${result.rootHash}`;
const summary = `Child workflow "${workflowName}" completed (returnCode=${result.returnCode}).\n\nSummary: ${result.summary}\n\nChild thread root hash: ${result.rootHash}`;
return { output: summary, childThread: result.rootHash };
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
return `ERROR: ${message}`;
@@ -21,6 +21,7 @@ function makeCtx(roles: (keyof TestMeta & string)[]): ModeratorContext<TestMeta>
return {
threadId: "test-thread",
depth: 0,
bundleHash: "TESTHASH00001",
start: {
role: START,
content: "test",
+4
View File
@@ -6,6 +6,10 @@
".": {
"types": "./dist/index.d.ts",
"import": "./src/index.ts"
},
"./moderator-table.js": {
"types": "./dist/moderator-table.d.ts",
"import": "./src/moderator-table.ts"
}
},
"peerDependencies": {
@@ -4,6 +4,8 @@ export type StartNodePayload = {
name: string;
hash: string;
depth: number;
/** Parent thread's head state hash at spawn time. `null` for top-level workflows. */
parentState: string | null;
};
export type StartNode = {
@@ -20,6 +22,8 @@ export type StateNodePayload = {
ancestors: string[];
compact: string | null;
timestamp: number;
/** Child thread's final state hash (workflow-as-agent). `null` when no child spawned. */
childThread: string | null;
};
export type StateNode = {
+3 -5
View File
@@ -13,12 +13,12 @@ export type {
AgentBinding,
AgentContext,
AgentFn,
AgentFnResult,
CasStore,
ExtractFn,
ExtractResult,
FALLBACK,
LlmProvider,
Moderator,
ModeratorCondition,
ModeratorContext,
ModeratorTable,
@@ -37,6 +37,8 @@ export type {
WorkflowDefinition,
WorkflowDescriptor,
WorkflowFn,
WorkflowGraph,
WorkflowGraphEdge,
WorkflowResult,
WorkflowRoleDescriptor,
WorkflowRoleSchema,
@@ -50,7 +52,3 @@ export { END, START } from "./types.js";
// ── Constructor functions ──────────────────────────────────────────
export { err, ok } from "./result.js";
// ── Moderator Table ────────────────────────────────────────────────
export { tableToModerator } from "./moderator-table.js";
+19 -2
View File
@@ -27,9 +27,22 @@ export type WorkflowRoleDescriptor = {
schema: WorkflowRoleSchema;
};
/** Serializable routing edges derived from a moderator transition table. */
export type WorkflowGraphEdge = {
from: string;
to: string;
condition: string;
conditionDescription: string | null;
};
export type WorkflowGraph = {
edges: readonly WorkflowGraphEdge[];
};
export type WorkflowDescriptor = {
description: string;
roles: Record<string, WorkflowRoleDescriptor>;
graph: WorkflowGraph;
};
// ── Role & Thread ──────────────────────────────────────────────────
@@ -41,6 +54,7 @@ export type RoleOutput = {
contentHash: string;
meta: Record<string, unknown>;
refs: string[];
childThread: string | null;
};
export type StartStep = {
@@ -63,6 +77,7 @@ export type RoleStep<M extends RoleMeta> = {
export type ThreadContext<M extends RoleMeta = RoleMeta> = {
threadId: string;
depth: number;
bundleHash: string;
start: StartStep;
steps: RoleStep<M>[];
};
@@ -127,7 +142,9 @@ export type ExtractFn = <T extends Record<string, unknown>>(
contentHash: string,
) => Promise<ExtractResult<T>>;
export type AgentFn = (ctx: AgentContext) => Promise<string>;
export type AgentFnResult = string | { output: string; childThread: string | null };
export type AgentFn = (ctx: AgentContext) => Promise<AgentFnResult>;
export type AgentBinding = {
agent: AgentFn;
@@ -160,7 +177,7 @@ export type Moderator<M extends RoleMeta> = (
export type WorkflowDefinition<M extends RoleMeta> = {
description: string;
roles: { [K in keyof M & string]: RoleDefinition<M[K]> };
moderator: Moderator<M>;
table: ModeratorTable<M>;
};
// ── Declarative Moderator Table ────────────────────────────────────
@@ -1,12 +1,35 @@
import type { RoleMeta, WorkflowDefinition } from "@uncaged/workflow-protocol";
import type {
ModeratorTable,
ModeratorTransition,
RoleMeta,
WorkflowDefinition,
WorkflowDescriptor,
WorkflowGraph,
WorkflowGraphEdge,
} from "@uncaged/workflow-protocol";
import { END } from "@uncaged/workflow-protocol";
import * as z from "zod/v4";
import type { WorkflowDescriptor, WorkflowRoleSchema } from "./types.js";
import type { WorkflowRoleSchema } from "./types.js";
function stripJsonSchemaMeta(json: Record<string, unknown>): WorkflowRoleSchema {
const { $schema: _drop, ...rest } = json;
return rest as WorkflowRoleSchema;
}
function graphFromTable<M extends RoleMeta>(table: ModeratorTable<M>): WorkflowGraph {
const edges: WorkflowGraphEdge[] = [];
const entries = Object.entries(table) as Array<[string, ModeratorTransition<M>[]]>;
for (const [from, transitions] of entries) {
for (const t of transitions) {
const conditionName = t.condition === "FALLBACK" ? "FALLBACK" : t.condition.name;
const conditionDescription = t.condition === "FALLBACK" ? null : t.condition.description;
const to = t.role === END ? END : t.role;
edges.push({ from, to, condition: conditionName, conditionDescription });
}
}
return { edges };
}
export function buildDescriptor<M extends RoleMeta>(
def: WorkflowDefinition<M>,
): WorkflowDescriptor {
@@ -20,5 +43,9 @@ export function buildDescriptor<M extends RoleMeta>(
schema: stripJsonSchemaMeta(rawJsonSchema),
};
}
return { description: def.description, roles };
return {
description: def.description,
roles,
graph: graphFromTable(def.table),
};
}
@@ -404,7 +404,7 @@ export function validateWorkflowBundle(input: WorkflowBundleValidationInput): Re
if (!descriptorExportExists(program)) {
return err(
'workflow bundle must export descriptor (e.g. "export const descriptor = { description, roles }")',
'workflow bundle must export descriptor (e.g. "export const descriptor = { description, roles, graph }")',
);
}
@@ -9,6 +9,8 @@ export type {
ExtractedBundleExports,
WorkflowBundleValidationInput,
WorkflowDescriptor,
WorkflowGraph,
WorkflowGraphEdge,
WorkflowRoleDescriptor,
WorkflowRoleSchema,
} from "./types.js";
@@ -3,6 +3,8 @@ import type { WorkflowDescriptor, WorkflowFn } from "@uncaged/workflow-protocol"
export type {
WorkflowDescriptor,
WorkflowFn,
WorkflowGraph,
WorkflowGraphEdge,
WorkflowRoleDescriptor,
WorkflowRoleSchema,
} from "@uncaged/workflow-protocol";
@@ -1,6 +1,64 @@
import { err, ok, type Result } from "@uncaged/workflow-util";
import type { WorkflowDescriptor, WorkflowRoleDescriptor, WorkflowRoleSchema } from "./types.js";
import type {
WorkflowDescriptor,
WorkflowGraph,
WorkflowGraphEdge,
WorkflowRoleDescriptor,
WorkflowRoleSchema,
} from "./types.js";
function validateDescriptorGraphEdge(
item: unknown,
index: number,
): Result<WorkflowGraphEdge, string> {
if (item === null || typeof item !== "object" || Array.isArray(item)) {
return err(`descriptor.graph.edges[${index}] must be a non-array object`);
}
const e = item as Record<string, unknown>;
if (typeof e.from !== "string") {
return err(`descriptor.graph.edges[${index}].from must be a string`);
}
if (typeof e.to !== "string") {
return err(`descriptor.graph.edges[${index}].to must be a string`);
}
if (typeof e.condition !== "string") {
return err(`descriptor.graph.edges[${index}].condition must be a string`);
}
const cdRaw = e.conditionDescription;
if (cdRaw !== null && cdRaw !== undefined && typeof cdRaw !== "string") {
return err(`descriptor.graph.edges[${index}].conditionDescription must be a string or null`);
}
const conditionDescription: string | null = cdRaw === undefined || cdRaw === null ? null : cdRaw;
return ok({
from: e.from,
to: e.to,
condition: e.condition,
conditionDescription,
});
}
function validateDescriptorGraph(graphRaw: unknown): Result<WorkflowGraph, string> {
if (graphRaw === null || typeof graphRaw !== "object" || Array.isArray(graphRaw)) {
return err("descriptor.graph must be a non-array object");
}
const graphRecord = graphRaw as Record<string, unknown>;
const edgesRaw = graphRecord.edges;
if (!Array.isArray(edgesRaw)) {
return err("descriptor.graph.edges must be an array");
}
const edges: WorkflowGraphEdge[] = [];
for (let i = 0; i < edgesRaw.length; i++) {
const edgeResult = validateDescriptorGraphEdge(edgesRaw[i], i);
if (!edgeResult.ok) {
return edgeResult;
}
edges.push(edgeResult.value);
}
return ok({ edges });
}
export function validateWorkflowDescriptor(value: unknown): Result<WorkflowDescriptor, string> {
if (value === null || typeof value !== "object" || Array.isArray(value)) {
@@ -36,5 +94,10 @@ export function validateWorkflowDescriptor(value: unknown): Result<WorkflowDescr
};
}
return ok({ description, roles });
const graphResult = validateDescriptorGraph(root.graph);
if (!graphResult.ok) {
return graphResult;
}
return ok({ description, roles, graph: graphResult.value });
}
+2
View File
@@ -3,6 +3,8 @@ export type {
ExtractedBundleExports,
WorkflowBundleValidationInput,
WorkflowDescriptor,
WorkflowGraph,
WorkflowGraphEdge,
WorkflowRoleDescriptor,
WorkflowRoleSchema,
} from "./bundle/index.js";
@@ -27,7 +27,7 @@ describe("buildThreadContext", () => {
const bundleHash = "BHAAAAAAAAAAA";
const startHash = await putStartNode(
cas,
{ name: "demo", hash: bundleHash, depth: 2 },
{ name: "demo", hash: bundleHash, depth: 2, parentState: null },
promptHash,
);
@@ -41,6 +41,7 @@ describe("buildThreadContext", () => {
ancestors: [],
compact: null,
timestamp: 1000,
childThread: null,
});
const chCode = await putContentNodeWithRefs(cas, "code body", []);
@@ -52,6 +53,7 @@ describe("buildThreadContext", () => {
ancestors: [statePlan],
compact: null,
timestamp: 2000,
childThread: null,
});
const ctx = await buildThreadContext(stateCode, cas);
@@ -71,7 +73,7 @@ describe("buildThreadContext", () => {
const promptHash = await cas.put("only-prompt");
const startHash = await putStartNode(
cas,
{ name: "solo", hash: "BHBBBBBBBBBBB", depth: 1 },
{ name: "solo", hash: "BHBBBBBBBBBBB", depth: 1, parentState: null },
promptHash,
);
@@ -87,7 +89,7 @@ describe("buildThreadContext", () => {
const bundleHash = "BHCCCCCCCCCCC";
const startHash = await putStartNode(
cas,
{ name: "demo", hash: bundleHash, depth: 0 },
{ name: "demo", hash: bundleHash, depth: 0, parentState: null },
promptHash,
);
@@ -100,6 +102,7 @@ describe("buildThreadContext", () => {
ancestors: [],
compact: null,
timestamp: 500,
childThread: null,
});
const endContent = await putContentNodeWithRefs(cas, "finished", []);
@@ -111,6 +114,7 @@ describe("buildThreadContext", () => {
ancestors: [state1],
compact: null,
timestamp: 600,
childThread: null,
});
const ctx = await buildThreadContext(endState, cas);
@@ -54,6 +54,7 @@ async function threadFromStartHead<M extends RoleMeta>(
return {
threadId: "",
depth: p.depth,
bundleHash: p.hash,
start: {
role: START,
content: prompt,
@@ -113,6 +114,7 @@ async function threadFromStateHead<M extends RoleMeta>(
return {
threadId: "",
depth: sp.depth,
bundleHash: sp.hash,
start: {
role: START,
content: prompt,
@@ -1,4 +1,5 @@
import { putContentNodeWithRefs } from "@uncaged/workflow-cas";
import { tableToModerator } from "@uncaged/workflow-protocol/moderator-table.js";
import type * as z from "zod/v4";
import {
@@ -6,6 +7,7 @@ import {
type AgentBinding,
type AgentContext,
type AgentFn,
type AgentFnResult,
END,
type ModeratorContext,
type RoleDefinition,
@@ -49,6 +51,16 @@ function mergeUniqueHashes(a: readonly string[], b: readonly string[]): string[]
return out;
}
function normalizeAgentResult(result: AgentFnResult): {
output: string;
childThread: string | null;
} {
if (typeof result === "string") {
return { output: result, childThread: null };
}
return result;
}
function agentForRole(binding: AgentBinding, roleName: string): AgentFn {
const overrides = binding.overrides;
const overrideFn: AgentFn | undefined =
@@ -57,7 +69,9 @@ function agentForRole(binding: AgentBinding, roleName: string): AgentFn {
}
async function advanceOneRound<M extends RoleMeta>(
def: Pick<WorkflowDefinition<M>, "roles" | "moderator">,
def: Pick<WorkflowDefinition<M>, "roles"> & {
pickNext: (ctx: ModeratorContext<M>) => (keyof M & string) | typeof END;
},
binding: AgentBinding,
params: {
thread: ModeratorContext<M>;
@@ -67,7 +81,7 @@ async function advanceOneRound<M extends RoleMeta>(
const { thread, runtime } = params;
const modCtx: ModeratorContext<M> = thread;
const next = def.moderator(modCtx);
const next = def.pickNext(modCtx);
if (!isRoleNext(next)) {
return {
kind: "complete",
@@ -86,9 +100,9 @@ async function advanceOneRound<M extends RoleMeta>(
};
const agent = agentForRole(binding, next);
const raw = await agent(agentCtx as unknown as AgentContext);
const agentResult = normalizeAgentResult(await agent(agentCtx as unknown as AgentContext));
const agentContentHash = await putContentNodeWithRefs(runtime.cas, raw, []);
const agentContentHash = await putContentNodeWithRefs(runtime.cas, agentResult.output, []);
const extracted = await runtime.extract(
roleDef.schema as z.ZodType<Record<string, unknown>>,
@@ -122,22 +136,26 @@ async function advanceOneRound<M extends RoleMeta>(
contentHash: step.contentHash,
meta: step.meta,
refs: step.refs,
childThread: agentResult.childThread,
},
step,
};
}
/**
* Binds pure role definitions + moderator to runtime agents.
* Binds pure role definitions + moderator table to runtime agents.
* Assign with `export const run = createWorkflow(def, binding)`.
*
* Structured meta extraction is delegated to {@link WorkflowRuntime.extract}, which the
* engine resolves from the workflow registry's `extract` scene.
*/
export function createWorkflow<M extends RoleMeta>(
def: Pick<WorkflowDefinition<M>, "roles" | "moderator">,
def: Pick<WorkflowDefinition<M>, "roles" | "table">,
binding: AgentBinding,
): WorkflowFn {
const pickNext = tableToModerator(def.table);
const loopDef = { roles: def.roles, pickNext };
return async function* workflowLoop(
thread: ThreadContext,
runtime: WorkflowRuntime,
@@ -148,7 +166,7 @@ export function createWorkflow<M extends RoleMeta>(
let currentThread = thread as ModeratorContext<M>;
while (true) {
const outcome = await advanceOneRound(def, binding, {
const outcome = await advanceOneRound(loopDef, binding, {
thread: currentThread,
runtime,
});
+4 -2
View File
@@ -5,12 +5,12 @@ export type {
AgentBinding,
AgentContext,
AgentFn,
AgentFnResult,
CasStore,
ExtractFn,
ExtractResult,
FALLBACK,
LlmProvider,
Moderator,
ModeratorCondition,
ModeratorContext,
ModeratorTable,
@@ -26,9 +26,11 @@ export type {
WorkflowDefinition,
WorkflowDescriptor,
WorkflowFn,
WorkflowGraph,
WorkflowGraphEdge,
WorkflowResult,
WorkflowRoleDescriptor,
WorkflowRoleSchema,
WorkflowRuntime,
} from "./types.js";
export { END, START, tableToModerator } from "./types.js";
export { END, START } from "./types.js";
+4 -2
View File
@@ -7,12 +7,12 @@ export type {
AgentBinding,
AgentContext,
AgentFn,
AgentFnResult,
CasStore,
ExtractFn,
ExtractResult,
FALLBACK,
LlmProvider,
Moderator,
ModeratorCondition,
ModeratorContext,
ModeratorTable,
@@ -30,10 +30,12 @@ export type {
WorkflowDefinition,
WorkflowDescriptor,
WorkflowFn,
WorkflowGraph,
WorkflowGraphEdge,
WorkflowResult,
WorkflowRoleDescriptor,
WorkflowRoleSchema,
WorkflowRuntime,
} from "@uncaged/workflow-protocol";
export { END, START, tableToModerator } from "@uncaged/workflow-protocol";
export { END, START } from "@uncaged/workflow-protocol";
@@ -1,11 +1,14 @@
import { describe, expect, test } from "bun:test";
import { tableToModerator } from "@uncaged/workflow-protocol/moderator-table.js";
import { validateWorkflowDescriptor } from "@uncaged/workflow-register";
import { END, type ModeratorContext, type RoleStep, START } from "@uncaged/workflow-runtime";
import { buildDevelopDescriptor } from "../src/descriptor.js";
import { developModerator } from "../src/index.js";
import { developTable } from "../src/moderator.js";
import type { CommitterMeta, PlannerMeta } from "../src/roles/index.js";
import type { DevelopMeta } from "../src/roles.js";
const developModerator = tableToModerator(developTable);
const DEFAULT_PHASES: PlannerMeta["phases"] = [
{
hash: "4KNMR2PX",
@@ -26,6 +29,7 @@ function makeCtx(steps: ModeratorContext<DevelopMeta>["steps"]): ModeratorContex
return {
threadId: "01TEST000000000000000000TR",
depth: 0,
bundleHash: "TESTHASH00001",
start: makeStart(),
steps,
};
@@ -232,6 +236,7 @@ describe("buildDevelopDescriptor", () => {
"reviewer",
"tester",
]);
expect(validated.value.graph.edges.length).toBeGreaterThan(0);
for (const key of ["planner", "coder", "reviewer", "tester", "committer"] as const) {
const role = validated.value.roles[key];
expect(role).toBeDefined();
@@ -1,8 +1,9 @@
/**
* develop bundle entry — 小橘 🍊
*
* All roles use cursor-agent with workspace auto-extracted from context.
*/
import { createHermesAgent } from "@uncaged/workflow-agent-hermes";
import { createExtract } from "@uncaged/workflow-execute";
import { createCursorAgent } from "@uncaged/workflow-agent-cursor";
import { createWorkflow } from "@uncaged/workflow-runtime";
import { buildDevelopDescriptor, developWorkflowDefinition } from "./src/index.js";
@@ -22,23 +23,23 @@ function optionalEnv(name: string): string | null {
return value;
}
const provider = {
const llmProvider = {
baseUrl:
optionalEnv("WORKFLOW_LLM_BASE_URL") ?? "https://dashscope.aliyuncs.com/compatible-mode/v1",
apiKey: requireEnv("WORKFLOW_LLM_API_KEY"),
model: optionalEnv("WORKFLOW_LLM_MODEL") ?? "qwen-plus",
};
const agent = createHermesAgent({
model: optionalEnv("WORKFLOW_HERMES_MODEL"),
timeout: optionalEnv("WORKFLOW_HERMES_TIMEOUT")
? Number(optionalEnv("WORKFLOW_HERMES_TIMEOUT"))
: null,
const agent = createCursorAgent({
model: optionalEnv("WORKFLOW_CURSOR_MODEL"),
timeout: optionalEnv("WORKFLOW_CURSOR_TIMEOUT")
? Number(optionalEnv("WORKFLOW_CURSOR_TIMEOUT"))
: 0,
workspace: null,
llmProvider,
});
const extract = createExtract(provider);
const wf = createWorkflow(developWorkflowDefinition, { agent }, extract);
const wf = createWorkflow(developWorkflowDefinition, { agent, overrides: null });
export const descriptor = buildDevelopDescriptor();
export const run = wf.run;
export const run = wf;
@@ -15,5 +15,8 @@
"@uncaged/workflow-register": "workspace:*",
"@uncaged/workflow-runtime": "workspace:*",
"zod": "^4.0.0"
},
"devDependencies": {
"@uncaged/workflow-protocol": "workspace:*"
}
}
@@ -1,12 +1,12 @@
import { buildDescriptor } from "@uncaged/workflow-register";
import { developModerator } from "./moderator.js";
import { developTable } from "./moderator.js";
import { DEVELOP_WORKFLOW_DESCRIPTION, developRoles } from "./roles.js";
export function buildDevelopDescriptor() {
return buildDescriptor({
description: DEVELOP_WORKFLOW_DESCRIPTION,
roles: developRoles,
moderator: developModerator,
table: developTable,
});
}
@@ -1,10 +1,10 @@
import type { WorkflowDefinition } from "@uncaged/workflow-runtime";
import { developModerator } from "./moderator.js";
import { developTable } from "./moderator.js";
import { DEVELOP_WORKFLOW_DESCRIPTION, type DevelopMeta, developRoles } from "./roles.js";
export { buildDevelopDescriptor } from "./descriptor.js";
export { developModerator } from "./moderator.js";
export { developTable } from "./moderator.js";
export {
type CoderMeta,
type CommitterMeta,
@@ -33,5 +33,5 @@ export {
export const developWorkflowDefinition: WorkflowDefinition<DevelopMeta> = {
description: DEVELOP_WORKFLOW_DESCRIPTION,
roles: developRoles,
moderator: developModerator,
table: developTable,
};
@@ -3,7 +3,6 @@ import {
type ModeratorCondition,
type ModeratorTable,
START,
tableToModerator,
} from "@uncaged/workflow-runtime";
import type { DevelopMeta } from "./roles.js";
@@ -88,4 +87,4 @@ const table: ModeratorTable<DevelopMeta> = {
committer: [{ condition: "FALLBACK", role: END }],
};
export const developModerator = tableToModerator(table);
export { table as developTable };
@@ -4,6 +4,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { createCasStore } from "@uncaged/workflow-cas";
import { createExtract } from "@uncaged/workflow-execute";
import { tableToModerator } from "@uncaged/workflow-protocol/moderator-table.js";
import { validateWorkflowDescriptor } from "@uncaged/workflow-register";
import {
createWorkflow,
@@ -14,10 +15,12 @@ import {
} from "@uncaged/workflow-runtime";
import { buildSolveIssueDescriptor } from "../src/descriptor.js";
import type { DeveloperMeta } from "../src/developer.js";
import { solveIssueModerator, solveIssueWorkflowDefinition } from "../src/index.js";
import { solveIssueTable, solveIssueWorkflowDefinition } from "../src/index.js";
import type { PreparerMeta, SubmitterMeta } from "../src/roles/index.js";
import type { SolveIssueMeta } from "../src/roles.js";
const solveIssueModerator = tableToModerator(solveIssueTable);
function jsonResponse(payload: Record<string, unknown>): Response {
return new Response(JSON.stringify(payload), {
status: 200,
@@ -113,6 +116,7 @@ function makeCtx(
return {
threadId: "01TEST000000000000000000TR",
depth: 0,
bundleHash: "TESTHASH00001",
start: makeStart(),
steps,
};
@@ -178,6 +182,7 @@ function makeThread(prompt: string) {
return {
threadId: "01TEST000000000000000000TR",
depth: 0,
bundleHash: "TESTHASH00001",
start: {
role: START,
content: prompt,
@@ -388,6 +393,7 @@ describe("buildSolveIssueDescriptor", () => {
"preparer",
"submitter",
]);
expect(validated.value.graph.edges.length).toBe(4);
for (const key of ["preparer", "developer", "submitter"] as const) {
const role = validated.value.roles[key];
expect(role).toBeDefined();
@@ -0,0 +1,37 @@
/**
* solve-issue bundle entry — 小橘 🍊
*
* preparer + submitter → hermes agent
* developer → workflow-as-agent (delegates to "develop" workflow)
*/
import { createHermesAgent } from "@uncaged/workflow-agent-hermes";
import { workflowAsAgent } from "@uncaged/workflow-execute";
import { createWorkflow } from "@uncaged/workflow-runtime";
import { buildSolveIssueDescriptor, solveIssueWorkflowDefinition } from "./src/index.js";
function optionalEnv(name: string): string | null {
const value = process.env[name];
if (value === undefined || value === "") {
return null;
}
return value;
}
const hermesAgent = createHermesAgent({
model: optionalEnv("WORKFLOW_HERMES_MODEL"),
timeout: optionalEnv("WORKFLOW_HERMES_TIMEOUT")
? Number(optionalEnv("WORKFLOW_HERMES_TIMEOUT"))
: null,
});
const developerAgent = workflowAsAgent("develop");
const wf = createWorkflow(solveIssueWorkflowDefinition, {
agent: hermesAgent,
overrides: {
developer: developerAgent,
},
});
export const descriptor = buildSolveIssueDescriptor();
export const run = wf;
@@ -18,6 +18,7 @@
},
"devDependencies": {
"@uncaged/workflow-cas": "workspace:*",
"@uncaged/workflow-execute": "workspace:*"
"@uncaged/workflow-execute": "workspace:*",
"@uncaged/workflow-protocol": "workspace:*"
}
}
@@ -1,12 +1,12 @@
import { buildDescriptor } from "@uncaged/workflow-register";
import { solveIssueModerator } from "./moderator.js";
import { solveIssueTable } from "./moderator.js";
import { SOLVE_ISSUE_WORKFLOW_DESCRIPTION, solveIssueRoles } from "./roles.js";
export function buildSolveIssueDescriptor() {
return buildDescriptor({
description: SOLVE_ISSUE_WORKFLOW_DESCRIPTION,
roles: solveIssueRoles,
moderator: solveIssueModerator,
table: solveIssueTable,
});
}
@@ -1,6 +1,6 @@
import type { WorkflowDefinition } from "@uncaged/workflow-runtime";
import { solveIssueModerator } from "./moderator.js";
import { solveIssueTable } from "./moderator.js";
import { SOLVE_ISSUE_WORKFLOW_DESCRIPTION, type SolveIssueMeta, solveIssueRoles } from "./roles.js";
export { buildSolveIssueDescriptor } from "./descriptor.js";
@@ -9,7 +9,7 @@ export {
developerMetaSchema,
developerRole,
} from "./developer.js";
export { solveIssueModerator } from "./moderator.js";
export { solveIssueTable } from "./moderator.js";
export {
type PreparerMeta,
preparerMetaSchema,
@@ -28,5 +28,5 @@ export {
export const solveIssueWorkflowDefinition: WorkflowDefinition<SolveIssueMeta> = {
description: SOLVE_ISSUE_WORKFLOW_DESCRIPTION,
roles: solveIssueRoles,
moderator: solveIssueModerator,
table: solveIssueTable,
};
@@ -1,4 +1,4 @@
import { END, type ModeratorTable, START, tableToModerator } from "@uncaged/workflow-runtime";
import { END, type ModeratorTable, START } from "@uncaged/workflow-runtime";
import type { SolveIssueMeta } from "./roles.js";
@@ -9,4 +9,4 @@ const table: ModeratorTable<SolveIssueMeta> = {
submitter: [{ condition: "FALLBACK", role: END }],
};
export const solveIssueModerator = tableToModerator(table);
export { table as solveIssueTable };
@@ -17,6 +17,7 @@ describe("buildAgentPrompt", () => {
const ctx: AgentContext = {
start: startTask("fix the bug"),
depth: 0,
bundleHash: "TESTHASH00001",
steps: [],
threadId: "01TEST000000000000000000TR",
currentRole: { name: START, systemPrompt: "You are an agent." },
@@ -33,6 +34,7 @@ describe("buildAgentPrompt", () => {
const ctx: AgentContext = {
start: startTask("user task"),
depth: 0,
bundleHash: "TESTHASH00001",
threadId: "01TEST000000000000000000TR",
currentRole: { name: "coder", systemPrompt: "Be helpful." },
steps: [
@@ -61,6 +63,7 @@ describe("buildAgentPrompt", () => {
const ctx: AgentContext = {
start: startTask("first message full: task content here"),
depth: 0,
bundleHash: "TESTHASH00001",
threadId: "01TEST000000000000000000TR",
currentRole: { name: "coder", systemPrompt: "System." },
steps: [
@@ -99,6 +102,7 @@ describe("buildAgentPrompt", () => {
const ctx: AgentContext = {
start: startTask("start"),
depth: 0,
bundleHash: "TESTHASH00001",
threadId: "01TEST000000000000000000TR",
currentRole: { name: "c", systemPrompt: "S" },
steps: [
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env bash
# deploy.sh — Build & deploy dashboard + gateway to Cloudflare
#
# Usage:
# ./scripts/deploy.sh # deploy both
# ./scripts/deploy.sh dashboard # dashboard only
# ./scripts/deploy.sh gateway # gateway only
#
# Env (via `cfg` or export):
# CLOUDFLARE_API_TOKEN — Cloudflare API token
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$REPO_ROOT"
CLOUDFLARE_API_TOKEN="${CLOUDFLARE_API_TOKEN:?CLOUDFLARE_API_TOKEN is required}"
export CLOUDFLARE_API_TOKEN
TARGET="${1:-all}"
deploy_dashboard() {
echo "🌐 Building dashboard..."
(cd packages/workflow-dashboard && npm run build)
echo "🚀 Deploying dashboard to Cloudflare Pages..."
(cd packages/workflow-gateway && npx wrangler pages deploy \
../workflow-dashboard/dist \
--project-name workflow-dashboard)
echo " ✅ Dashboard → workflow.shazhou.work"
}
deploy_gateway() {
echo "🚀 Deploying gateway Worker..."
(cd packages/workflow-gateway && npx wrangler deploy)
echo " ✅ Gateway → workflow-gateway.shazhou.workers.dev"
}
case "$TARGET" in
dashboard) deploy_dashboard ;;
gateway) deploy_gateway ;;
all) deploy_dashboard; deploy_gateway ;;
*) echo "Usage: deploy.sh [dashboard|gateway|all]"; exit 1 ;;
esac
echo "✅ Deploy complete"
+139
View File
@@ -0,0 +1,139 @@
#!/usr/bin/env bash
# publish.sh — Bump version & publish all @uncaged/workflow-* packages
#
# Usage:
# ./scripts/publish.sh 0.4.0 # explicit version
# ./scripts/publish.sh patch # 0.3.1 → 0.3.2
# ./scripts/publish.sh minor # 0.3.1 → 0.4.0
#
# Env (via `cfg` or export):
# GITEA_TOKEN — Gitea npm registry auth
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$REPO_ROOT"
GITEA_TOKEN="${GITEA_TOKEN:?GITEA_TOKEN is required}"
GITEA_NPM_REGISTRY="https://git.shazhou.work/api/packages/uncaged/npm/"
# ─── Version ─────────────────────────────────────────────────────────────────
current_version() {
node -e "console.log(require('./packages/workflow-protocol/package.json').version)"
}
bump_version() {
local cur="$1" kind="$2"
IFS='.' read -r major minor patch <<< "$cur"
case "$kind" in
patch) echo "${major}.${minor}.$((patch + 1))" ;;
minor) echo "${major}.$((minor + 1)).0" ;;
major) echo "$((major + 1)).0.0" ;;
*) echo "$kind" ;;
esac
}
CURRENT=$(current_version)
VERSION=$(bump_version "$CURRENT" "${1:?Usage: publish.sh <version|patch|minor|major>}")
echo "📦 Publish: $CURRENT$VERSION"
# ─── Topological publish order ───────────────────────────────────────────────
PUBLISH_ORDER=(
workflow-protocol
workflow-util
workflow-cas
workflow-runtime
workflow-reactor
workflow-register
workflow-execute
cli-workflow
workflow-util-agent
workflow-agent-cursor
workflow-agent-hermes
workflow-agent-llm
workflow-template-develop
workflow-template-solve-issue
)
# ─── Bump version ────────────────────────────────────────────────────────────
echo "🔢 Bumping versions..."
for dir in packages/*/; do
pkg="$dir/package.json"
[[ -f "$pkg" ]] || continue
is_private=$(node -e "console.log(require('./$pkg').private || false)")
[[ "$is_private" == "true" ]] && continue
node -e "
const fs = require('fs');
const p = JSON.parse(fs.readFileSync('$pkg','utf8'));
p.version = '$VERSION';
fs.writeFileSync('$pkg', JSON.stringify(p, null, 2) + '\n');
"
done
# ─── Replace workspace:* ─────────────────────────────────────────────────────
echo "🔗 Replacing workspace:* → $VERSION..."
for dir in packages/*/; do
pkg="$dir/package.json"
[[ -f "$pkg" ]] || continue
node -e "
const fs = require('fs');
const p = JSON.parse(fs.readFileSync('$pkg','utf8'));
let c = false;
for (const k of ['dependencies','peerDependencies','devDependencies']) {
if (!p[k]) continue;
for (const [n, v] of Object.entries(p[k])) {
if (n.startsWith('@uncaged/') && v === 'workspace:*') { p[k][n] = '$VERSION'; c = true; }
}
}
if (c) fs.writeFileSync('$pkg', JSON.stringify(p, null, 2) + '\n');
"
done
# ─── Build ───────────────────────────────────────────────────────────────────
echo "🔨 Building..."
npm run build
# ─── Publish ─────────────────────────────────────────────────────────────────
echo "🚀 Publishing..."
cat > "$REPO_ROOT/.npmrc" <<EOF
@uncaged:registry=${GITEA_NPM_REGISTRY}
//${GITEA_NPM_REGISTRY#https://}:_authToken=${GITEA_TOKEN}
EOF
FAIL=0
for pkg_dir in "${PUBLISH_ORDER[@]}"; do
if (cd "packages/$pkg_dir" && npm publish 2>&1); then
echo " ✅ @uncaged/$pkg_dir@$VERSION"
else
echo " ❌ @uncaged/$pkg_dir"
FAIL=1
fi
done
# ─── Restore workspace:* ─────────────────────────────────────────────────────
echo "🔄 Restoring workspace:*..."
for dir in packages/*/; do
pkg="$dir/package.json"
[[ -f "$pkg" ]] || continue
node -e "
const fs = require('fs');
const p = JSON.parse(fs.readFileSync('$pkg','utf8'));
let c = false;
for (const k of ['dependencies','peerDependencies','devDependencies']) {
if (!p[k]) continue;
for (const [n, v] of Object.entries(p[k])) {
if (n.startsWith('@uncaged/') && v === '$VERSION') { p[k][n] = 'workspace:*'; c = true; }
}
}
if (c) fs.writeFileSync('$pkg', JSON.stringify(p, null, 2) + '\n');
"
done
# ─── Commit ──────────────────────────────────────────────────────────────────
echo "📝 Committing..."
git add -A
git commit -m "chore: publish v${VERSION}
小橘 <xiaoju@shazhou.work>"
git push
[[ "$FAIL" -eq 0 ]] && echo "✅ v${VERSION} published" || echo "⚠️ v${VERSION} published with errors"