Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a98431a12a | |||
| e37dbc3f35 | |||
| 82d9abf260 | |||
| 50aec2d0cf | |||
| e979a55f8a | |||
| 30f1582046 | |||
| cf0540d7fa | |||
| c05fac746c | |||
| 34efd25e91 | |||
| cc0bc6c8aa | |||
| 626cb5d98e |
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
Executable
+44
@@ -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"
|
||||
Executable
+139
@@ -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"
|
||||
Reference in New Issue
Block a user