Compare commits

...

11 Commits

Author SHA1 Message Date
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
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
22 changed files with 852 additions and 31 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` 要不要递归展示整个调用栈,还是只显示直接链接?
@@ -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,
);
@@ -305,8 +305,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);
@@ -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");
@@ -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;
}
+39 -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,14 @@ 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 +175,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));
}
@@ -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", {
@@ -112,6 +112,7 @@ async function appendStateForStep(params: {
ancestors,
compact: null,
timestamp: params.timestamp,
childThread: null,
};
const stateHash = await putStateNode(params.cas, payload);
return {
@@ -137,6 +138,7 @@ async function appendEndState(params: {
ancestors,
compact: null,
timestamp: params.timestamp,
childThread: null,
};
return putStateNode(params.cas, payload);
}
@@ -439,6 +441,7 @@ export async function executeThread(
name: workflowName,
hash: io.hash,
depth: options.depth,
parentState: null,
},
promptHash,
);
@@ -240,6 +240,7 @@ async function buildForkContinuation(params: {
ancestors: ancestorsMarker,
compact: null,
timestamp: Date.now(),
childThread: null,
};
const markerHash = await putStateNode(cas, markerPayload);
@@ -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 = {
@@ -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);
@@ -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;
@@ -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;
+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"