Compare commits
94 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cdf3c95622 | |||
| a7fea10383 | |||
| 3846dc12a9 | |||
| c5fd84432f | |||
| 4c4dabb7a3 | |||
| 1b62cec0a2 | |||
| ecc348f182 | |||
| 41209f1ef8 | |||
| 58a4aefcc4 | |||
| bbb79f821e | |||
| 05fbd4f5b5 | |||
| 7e7331eb2d | |||
| 0fbbf37548 | |||
| 2af39463de | |||
| 5f2458238f | |||
| aadec0b96c | |||
| 1c68ce6217 | |||
| 7265603b55 | |||
| 74cea09ac0 | |||
| b1e66fa7a4 | |||
| 81a7a8c7c1 | |||
| 9cb7d68abe | |||
| 98122b446d | |||
| 4a31cf9d63 | |||
| 2c26be6ec6 | |||
| f723daa014 | |||
| 1e9900bed3 | |||
| aebff8b906 | |||
| db45089922 | |||
| 9c1b018ffa | |||
| a98431a12a | |||
| 0fe17b0fb2 | |||
| e37dbc3f35 | |||
| 82d9abf260 | |||
| 50aec2d0cf | |||
| e979a55f8a | |||
| 30f1582046 | |||
| cf0540d7fa | |||
| c05fac746c | |||
| 34efd25e91 | |||
| cc0bc6c8aa | |||
| 626cb5d98e | |||
| f87cb38a67 | |||
| 0970139418 | |||
| 376dd87b6b | |||
| 4d8469a649 | |||
| a929fa4ccb | |||
| ff3e19fd22 | |||
| b509d1715e | |||
| b93f6e736f | |||
| ec13c19505 | |||
| 203b86e827 | |||
| 90de1c7025 | |||
| 2b587612d5 | |||
| 2342a6e3bd | |||
| 0021596ff0 | |||
| 56ec8cd401 | |||
| fe87efd79d | |||
| b783027406 | |||
| 904ee1eb83 | |||
| 1742ced6df | |||
| 93145cf08c | |||
| da6bcb10d6 | |||
| 6fc97fc8c8 | |||
| 93d9821f64 | |||
| 29367cbe31 | |||
| ec397aecd3 | |||
| 2e9d939f8e | |||
| 064a24f093 | |||
| fede623a82 | |||
| 2a52b930b9 | |||
| bf2f790e6e | |||
| 08a79b77db | |||
| 22a6200b69 | |||
| 7e7f6aa6d6 | |||
| d6fe3f844c | |||
| d0803019b5 | |||
| f16e7641fd | |||
| 3b41625001 | |||
| c602d2284b | |||
| d96e10b0fc | |||
| 8e36d3e1f5 | |||
| bbe4fe0ed1 | |||
| e105c5cac1 | |||
| 578776fccf | |||
| cb756a999a | |||
| e0577ceefe | |||
| 024dd8c1e8 | |||
| 9e98119145 | |||
| fd8943f131 | |||
| f7253d5948 | |||
| 1c5636c270 | |||
| ca0403c8ab | |||
| aa25f55f63 |
@@ -4,3 +4,4 @@ bun.lock
|
||||
*.tgz
|
||||
tsconfig.tsbuildinfo
|
||||
.npmrc
|
||||
|
||||
|
||||
@@ -245,6 +245,64 @@ bun run format # biome format --write
|
||||
bun test # run tests
|
||||
```
|
||||
|
||||
### Publishing to Gitea npm Registry
|
||||
|
||||
All public `@uncaged/*` packages are published to the Gitea npm registry at `git.shazhou.work`. Workflow workspaces consume packages from this registry via `bunfig.toml`.
|
||||
|
||||
```bash
|
||||
# Publish all packages (bun pm pack resolves workspace:* → actual versions)
|
||||
bun run publish:gitea
|
||||
|
||||
# Dry run — see what would be published
|
||||
bun run publish:gitea:dry
|
||||
```
|
||||
|
||||
Prerequisites: `.npmrc` in monorepo root with Gitea auth token (`//git.shazhou.work/api/packages/shazhou/npm/:_authToken=<token>`).
|
||||
|
||||
### Workflow Workspace Setup
|
||||
|
||||
External workflow repos (e.g. `xingyue-workflows`) use the Gitea registry for `@uncaged/*` packages. Add a `bunfig.toml`:
|
||||
|
||||
```toml
|
||||
[install.scopes]
|
||||
"@uncaged" = "https://git.shazhou.work/api/packages/shazhou/npm/"
|
||||
```
|
||||
|
||||
Then `bun install` resolves `@uncaged/*` from Gitea, all other packages from npmjs.
|
||||
|
||||
### Cross-repo Development (bun link)
|
||||
|
||||
Alternative for development against un-published local changes:
|
||||
|
||||
```bash
|
||||
bun run link # Register all packages (from monorepo root)
|
||||
bun run link:consume # Link into CWD's project (⚠️ don't bun install after)
|
||||
bun run link:unlink # Restore original deps
|
||||
```
|
||||
|
||||
### End-to-end: Monorepo → Registry → Workspace → Bundle
|
||||
|
||||
The recommended development flow for building workflows:
|
||||
|
||||
```
|
||||
workflow/ (monorepo) — engine, runtime, templates, agents
|
||||
│ bun run publish:gitea — auto topo-sort, bun pm pack → npm publish
|
||||
▼
|
||||
git.shazhou.work npm registry — @uncaged/* scoped packages
|
||||
│ bun install — via bunfig.toml scoped registry
|
||||
▼
|
||||
my-workflows/ (workspace) — bunfig.toml + normal package.json
|
||||
│ bun run build:develop — bun build → single .esm.js
|
||||
▼
|
||||
uncaged-workflow workflow add — register bundle locally
|
||||
uncaged-workflow run — execute workflow
|
||||
```
|
||||
|
||||
1. **Monorepo changes** → `bun run publish:gitea` (packages auto-discovered from `packages/*/`, topologically sorted, `workspace:*` resolved to real versions)
|
||||
2. **Workspace** → `bun install` fetches latest from Gitea, `bun install` is safe to run anytime
|
||||
3. **Build** → produces single-file ESM bundle with `@uncaged/*` as externals
|
||||
4. **Register & Run** → `uncaged-workflow workflow add <name> <bundle>` then `uncaged-workflow run <name>`
|
||||
|
||||
## Commit Convention
|
||||
|
||||
```
|
||||
|
||||
@@ -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**
|
||||
+6
-1
@@ -9,7 +9,12 @@
|
||||
"check": "bunx tsc --build && biome check .",
|
||||
"typecheck": "bunx tsc --build",
|
||||
"format": "biome format --write .",
|
||||
"test": "bun run --filter '*' test"
|
||||
"test": "bun run --filter '*' test",
|
||||
"link": "./scripts/link-all.sh",
|
||||
"link:consume": "./scripts/link-all.sh --consume",
|
||||
"link:unlink": "./scripts/link-all.sh --unlink",
|
||||
"publish:gitea": "./scripts/publish-all.sh",
|
||||
"publish:gitea:dry": "./scripts/publish-all.sh --dry-run"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.4.14",
|
||||
|
||||
@@ -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,
|
||||
maxRounds: 5,
|
||||
depth: 0,
|
||||
parentState: null,
|
||||
},
|
||||
promptHash,
|
||||
);
|
||||
@@ -100,8 +100,8 @@ describe("gc cli and garbageCollectCas", () => {
|
||||
{
|
||||
name: "demo",
|
||||
hash: bundleHash,
|
||||
maxRounds: 5,
|
||||
depth: 0,
|
||||
parentState: null,
|
||||
},
|
||||
promptHash,
|
||||
);
|
||||
@@ -135,8 +135,8 @@ describe("gc cli and garbageCollectCas", () => {
|
||||
{
|
||||
name: "demo",
|
||||
hash: bundleHash,
|
||||
maxRounds: 5,
|
||||
depth: 0,
|
||||
parentState: null,
|
||||
},
|
||||
promptHash,
|
||||
);
|
||||
|
||||
@@ -58,6 +58,11 @@ describe("--help flag on groups", () => {
|
||||
const code = await runCli(STORAGE_ROOT, ["init", "--help"]);
|
||||
expect(code).toBe(0);
|
||||
});
|
||||
|
||||
test("setup --help returns 0", async () => {
|
||||
const code = await runCli(STORAGE_ROOT, ["setup", "--help"]);
|
||||
expect(code).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSkillTopics", () => {
|
||||
@@ -90,6 +95,8 @@ describe("formatCliUsage", () => {
|
||||
expect(u).toContain("Thread execution:");
|
||||
expect(u).toContain("Content-addressable storage:");
|
||||
expect(u).toContain("Development:");
|
||||
expect(u).toContain("Configuration:");
|
||||
expect(u).toContain("setup [--provider <name>]");
|
||||
expect(u).toContain("Shortcuts:");
|
||||
expect(u).toContain("Reference:");
|
||||
expect(u).toContain("skill [topic]");
|
||||
@@ -128,6 +135,7 @@ describe("formatSkillTopic('cli')", () => {
|
||||
expect(doc).toContain("### thread");
|
||||
expect(doc).toContain("### cas");
|
||||
expect(doc).toContain("### init");
|
||||
expect(doc).toContain("### setup");
|
||||
expect(doc).toContain("### Top-level shortcuts");
|
||||
});
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -38,8 +38,16 @@ describe("init workspace", () => {
|
||||
|
||||
const rootPkg = JSON.parse(await readFile(join(root, "package.json"), "utf8")) as {
|
||||
workspaces: string[];
|
||||
scripts: { bundle: string };
|
||||
};
|
||||
expect(rootPkg.workspaces).toEqual(["templates/*", "workflows"]);
|
||||
expect(rootPkg.scripts.bundle).toBe("bun run scripts/bundle.ts");
|
||||
|
||||
expect(await pathExists(join(root, "scripts", "bundle.ts"))).toBe(true);
|
||||
const bundleSrc = await readFile(join(root, "scripts", "bundle.ts"), "utf8");
|
||||
expect(bundleSrc).toContain("Bun.build");
|
||||
expect(bundleSrc).toContain("-entry.ts");
|
||||
expect(bundleSrc).toContain("distDir");
|
||||
|
||||
const wfPkg = JSON.parse(await readFile(join(root, "workflows", "package.json"), "utf8")) as {
|
||||
type: string;
|
||||
@@ -82,7 +90,7 @@ describe("init workspace", () => {
|
||||
for (const term of [
|
||||
"RoleDefinition",
|
||||
"WorkflowDefinition",
|
||||
"Moderator",
|
||||
"ModeratorTable",
|
||||
"AgentFn",
|
||||
"ExtractFn",
|
||||
"RoleMeta",
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
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 { readWorkflowRegistry } from "@uncaged/workflow-register";
|
||||
|
||||
import { runCli } from "../src/cli-dispatch.js";
|
||||
import { cmdSetup } from "../src/commands/setup/index.js";
|
||||
|
||||
describe("setup command (CLI mode)", () => {
|
||||
let prevEnv: string | undefined;
|
||||
let storageRoot: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
prevEnv = process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
|
||||
storageRoot = await mkdtemp(join(tmpdir(), "uncaged-setup-"));
|
||||
process.env.UNCAGED_WORKFLOW_STORAGE_ROOT = storageRoot;
|
||||
await mkdir(storageRoot, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (prevEnv === undefined) {
|
||||
delete process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
|
||||
} else {
|
||||
process.env.UNCAGED_WORKFLOW_STORAGE_ROOT = prevEnv;
|
||||
}
|
||||
await rm(storageRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("writes workflow.yaml with provider, models.default, and depth defaults", async () => {
|
||||
const r = await cmdSetup(storageRoot, {
|
||||
provider: "dashscope",
|
||||
baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
apiKey: "sk-test123",
|
||||
defaultModel: "dashscope/qwen-plus",
|
||||
initWorkspaceName: null,
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
if (!r.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
const reg = await readWorkflowRegistry(storageRoot);
|
||||
expect(reg.ok).toBe(true);
|
||||
if (!reg.ok) {
|
||||
return;
|
||||
}
|
||||
expect(reg.value.config).not.toBeNull();
|
||||
if (reg.value.config === null) {
|
||||
return;
|
||||
}
|
||||
expect(reg.value.config.providers.dashscope).toEqual({
|
||||
baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
apiKey: "sk-test123",
|
||||
});
|
||||
expect(reg.value.config.models.default).toBe("dashscope/qwen-plus");
|
||||
expect(reg.value.config.maxDepth).toBe(3);
|
||||
expect(reg.value.config.supervisorInterval).toBe(3);
|
||||
|
||||
const raw = await readFile(join(storageRoot, "workflow.yaml"), "utf8");
|
||||
expect(raw).toContain("dashscope");
|
||||
expect(raw).toContain("qwen-plus");
|
||||
});
|
||||
|
||||
test("idempotent: second run updates apiKey and preserves workflows", async () => {
|
||||
const initialYaml = `config:
|
||||
maxDepth: 7
|
||||
supervisorInterval: 2
|
||||
providers:
|
||||
dashscope:
|
||||
baseUrl: https://dashscope.aliyuncs.com/compatible-mode/v1
|
||||
apiKey: sk-old
|
||||
models:
|
||||
default: dashscope/qwen-plus
|
||||
workflows:
|
||||
keep-me:
|
||||
hash: "0000000000000"
|
||||
timestamp: 1
|
||||
history: []
|
||||
`;
|
||||
await writeFile(join(storageRoot, "workflow.yaml"), initialYaml, "utf8");
|
||||
|
||||
const r2 = await cmdSetup(storageRoot, {
|
||||
provider: "dashscope",
|
||||
baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
apiKey: "sk-newkey",
|
||||
defaultModel: "dashscope/qwen-plus",
|
||||
initWorkspaceName: null,
|
||||
});
|
||||
expect(r2.ok).toBe(true);
|
||||
if (!r2.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
const reg = await readWorkflowRegistry(storageRoot);
|
||||
expect(reg.ok).toBe(true);
|
||||
if (!reg.ok || reg.value.config === null) {
|
||||
return;
|
||||
}
|
||||
expect(reg.value.config.providers.dashscope.apiKey).toBe("sk-newkey");
|
||||
expect(reg.value.config.maxDepth).toBe(7);
|
||||
expect(reg.value.config.supervisorInterval).toBe(2);
|
||||
expect(reg.value.workflows["keep-me"]).toBeDefined();
|
||||
if (reg.value.workflows["keep-me"] === undefined) {
|
||||
return;
|
||||
}
|
||||
expect(reg.value.workflows["keep-me"].hash).toBe("0000000000000");
|
||||
});
|
||||
|
||||
test("runCli setup dispatches with flags and exits 0", async () => {
|
||||
const code = await runCli(storageRoot, [
|
||||
"setup",
|
||||
"--provider",
|
||||
"openai",
|
||||
"--base-url",
|
||||
"https://api.openai.com/v1",
|
||||
"--api-key",
|
||||
"sk-test",
|
||||
"--default-model",
|
||||
"openai/gpt-4o",
|
||||
]);
|
||||
expect(code).toBe(0);
|
||||
const reg = await readWorkflowRegistry(storageRoot);
|
||||
expect(reg.ok).toBe(true);
|
||||
if (!reg.ok || reg.value.config === null) {
|
||||
return;
|
||||
}
|
||||
expect(reg.value.config.providers.openai.apiKey).toBe("sk-test");
|
||||
expect(reg.value.config.models.default).toBe("openai/gpt-4o");
|
||||
});
|
||||
});
|
||||
@@ -36,6 +36,7 @@ const threadFixtureDescriptor = `export const descriptor = {
|
||||
only: { description: "only", schema: {} },
|
||||
noop: { description: "noop", schema: {} },
|
||||
},
|
||||
graph: { edges: [] },
|
||||
};
|
||||
`;
|
||||
|
||||
@@ -186,6 +187,14 @@ describe("cli thread commands", () => {
|
||||
}
|
||||
expect(shown.value.includes('"threadId"')).toBe(true);
|
||||
|
||||
const parsed = JSON.parse(shown.value) as Record<string, unknown>;
|
||||
expect(parsed.parentState).toBeNull();
|
||||
const parsedSteps = parsed.steps as Array<Record<string, unknown>>;
|
||||
for (const step of parsedSteps) {
|
||||
expect(step).toHaveProperty("childThread");
|
||||
expect(step.childThread).toBeNull();
|
||||
}
|
||||
|
||||
const removed = await cmdThreadRemove(storageRoot, threadId);
|
||||
expect(removed.ok).toBe(true);
|
||||
|
||||
@@ -305,8 +314,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);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@uncaged/cli-workflow",
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.3",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"uncaged-workflow": "src/cli.ts"
|
||||
|
||||
Generated
+51
@@ -0,0 +1,51 @@
|
||||
lockfileVersion: '9.0'
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@uncaged/workflow-cas':
|
||||
specifier: workspace:*
|
||||
version: link:../workflow-cas
|
||||
'@uncaged/workflow-execute':
|
||||
specifier: workspace:*
|
||||
version: link:../workflow-execute
|
||||
'@uncaged/workflow-protocol':
|
||||
specifier: workspace:*
|
||||
version: link:../workflow-protocol
|
||||
'@uncaged/workflow-register':
|
||||
specifier: workspace:*
|
||||
version: link:../workflow-register
|
||||
'@uncaged/workflow-runtime':
|
||||
specifier: workspace:*
|
||||
version: link:../workflow-runtime
|
||||
'@uncaged/workflow-util':
|
||||
specifier: workspace:*
|
||||
version: link:../workflow-util
|
||||
hono:
|
||||
specifier: ^4.12.18
|
||||
version: 4.12.18
|
||||
yaml:
|
||||
specifier: ^2.8.4
|
||||
version: 2.8.4
|
||||
|
||||
packages:
|
||||
|
||||
hono@4.12.18:
|
||||
resolution: {integrity: sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==}
|
||||
engines: {node: '>=16.9.0'}
|
||||
|
||||
yaml@2.8.4:
|
||||
resolution: {integrity: sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==}
|
||||
engines: {node: '>= 14.6'}
|
||||
hasBin: true
|
||||
|
||||
snapshots:
|
||||
|
||||
hono@4.12.18: {}
|
||||
|
||||
yaml@2.8.4: {}
|
||||
@@ -5,6 +5,7 @@ import { formatCliUsage as formatCliUsageWithGroups } from "./cli-usage.js";
|
||||
import { createCasDispatcher } from "./commands/cas/index.js";
|
||||
import { createInitDispatcher } from "./commands/init/index.js";
|
||||
import { dispatchServe } from "./commands/serve/index.js";
|
||||
import { dispatchSetup } from "./commands/setup/index.js";
|
||||
import { createThreadDispatcher, dispatchLive, dispatchRun } from "./commands/thread/index.js";
|
||||
import { createWorkflowDispatcher } from "./commands/workflow/index.js";
|
||||
import { formatSkillIndex, formatSkillTopic, getSkillTopics } from "./skill.js";
|
||||
@@ -66,6 +67,7 @@ const COMMAND_TABLE: Record<string, DispatchFn> = {
|
||||
thread: dispatchThread,
|
||||
cas: dispatchCas,
|
||||
init: dispatchInit,
|
||||
setup: dispatchSetup,
|
||||
skill: dispatchSkill,
|
||||
run: dispatchRun,
|
||||
live: dispatchLive,
|
||||
|
||||
@@ -5,6 +5,15 @@ import { INIT_SUBCOMMAND_TABLE } from "./commands/init/index.js";
|
||||
import { THREAD_SUBCOMMAND_TABLE } from "./commands/thread/index.js";
|
||||
import { WORKFLOW_SUBCOMMAND_TABLE } from "./commands/workflow/index.js";
|
||||
|
||||
const SETUP_USAGE_COMMANDS = [
|
||||
{
|
||||
name: "",
|
||||
args: "[--provider <name>] [--base-url <url>] [--api-key <key>] [--default-model <provider/model>] [--init-workspace <name>]",
|
||||
description:
|
||||
"Configure workflow.yaml LLM providers and default model (interactive when no flags)",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export function getCommandRegistry(): ReadonlyArray<CommandGroup> {
|
||||
return [
|
||||
{
|
||||
@@ -39,6 +48,10 @@ export function getCommandRegistry(): ReadonlyArray<CommandGroup> {
|
||||
description: e.description,
|
||||
})),
|
||||
},
|
||||
{
|
||||
name: "setup",
|
||||
commands: [...SETUP_USAGE_COMMANDS],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ const USAGE_SECTION_BY_GROUP: Record<string, string> = {
|
||||
thread: "Thread execution:",
|
||||
cas: "Content-addressable storage:",
|
||||
init: "Development:",
|
||||
setup: "Configuration:",
|
||||
};
|
||||
|
||||
export function formatUsageCommandLines(
|
||||
@@ -38,9 +39,10 @@ export function formatCliUsage(
|
||||
}
|
||||
lines.push(sectionTitle);
|
||||
const rows = group.commands.map((cmd) => {
|
||||
const namePart = cmd.name === "" ? "" : ` ${cmd.name}`;
|
||||
const args = cmd.args ? ` ${cmd.args}` : "";
|
||||
return {
|
||||
prefix: `${group.name} ${cmd.name}${args}`,
|
||||
prefix: `${group.name}${namePart}${args}`,
|
||||
description: cmd.description,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@ export function templatePackageJson(templateName: string): string {
|
||||
private: true,
|
||||
type: "module",
|
||||
dependencies: {
|
||||
"@uncaged/workflow-runtime": "^0.1.0",
|
||||
"@uncaged/workflow-runtime": "^0.3.1",
|
||||
zod: "^4.0.0",
|
||||
},
|
||||
},
|
||||
@@ -50,7 +50,6 @@ const greeterMetaSchema = z.object({
|
||||
export const greeterRole: RoleDefinition<HelloTemplateMeta["greeter"]> = {
|
||||
description: "Says hello — replace with your first role.",
|
||||
systemPrompt: "You are a helpful assistant. Reply with one short friendly sentence.",
|
||||
extractPrompt: "Extract the assistant's greeting as message.",
|
||||
schema: greeterMetaSchema,
|
||||
extractRefs: null,
|
||||
};
|
||||
@@ -58,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 }],
|
||||
};
|
||||
`;
|
||||
}
|
||||
@@ -76,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,
|
||||
@@ -88,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,
|
||||
};
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,9 @@ function rootPackageJson(workspaceName: string): string {
|
||||
private: true,
|
||||
type: "module",
|
||||
workspaces: ["templates/*", "workflows"],
|
||||
scripts: {
|
||||
bundle: "bun run scripts/bundle.ts",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
@@ -28,7 +31,7 @@ function workflowsPackageJson(): string {
|
||||
private: true,
|
||||
type: "module",
|
||||
dependencies: {
|
||||
"@uncaged/workflow-runtime": "^0.1.0",
|
||||
"@uncaged/workflow-runtime": "^0.3.1",
|
||||
zod: "^4.0.0",
|
||||
},
|
||||
},
|
||||
@@ -42,7 +45,7 @@ function biomeJson(): string {
|
||||
{
|
||||
$schema: "https://biomejs.dev/schemas/2.4.14/schema.json",
|
||||
files: {
|
||||
includes: ["**", "!**/node_modules", "!**/dist"],
|
||||
includes: ["**", "!**/node_modules", "!**/dist", "!scripts/bundle.ts"],
|
||||
},
|
||||
formatter: {
|
||||
indentWidth: 2,
|
||||
@@ -85,7 +88,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/\` 下放绑定与打包入口。
|
||||
@@ -93,20 +96,20 @@ Init 生成的骨架:\`templates/\` 下放可复用定义,\`workflows/\` 下
|
||||
## 2. 核心概念
|
||||
|
||||
- **RoleMeta**:\`Record<string, Record<string, unknown>>\`,角色名 → 该角色结构化 meta 的形状约定。
|
||||
- **RoleDefinition<Meta>**:纯数据——\`description\`、\`systemPrompt\`、\`extractPrompt\`、\`schema\`(Zod v4)。不含执行逻辑。
|
||||
- **WorkflowDefinition<M extends RoleMeta>**:\`description\` + \`roles\`(各角色定义)+ **Moderator**。
|
||||
- **Moderator**:\`(ctx: ModeratorContext<M>) => (角色名) | END\`。同步、纯函数,只做路由。
|
||||
- **RoleDefinition<Meta>**:纯数据——\`description\`、\`systemPrompt\`、\`schema\`(Zod v4)。不含执行逻辑。
|
||||
- **WorkflowDefinition<M extends RoleMeta>**:\`description\` + \`roles\`(各角色定义)+ **ModeratorTable**(声明式路由表)。
|
||||
- **ModeratorTable**:从 \`START\` 与各角色名映射到有序 transition 列表(条件 + 下一角色或 \`END\`);可序列化,供描述符提取 **graph**。
|
||||
- **AgentFn**:\`(ctx: AgentContext) => Promise<string>\`,原始文本输出;从上下文读取当前角色的 \`systemPrompt\`。
|
||||
- **ExtractFn**:从上下文与 prompt 解析结构化数据(引擎与 Agent 都可使用)。
|
||||
- **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\` / \`extractPrompt\` / \`description\`。
|
||||
3. **编写 Moderator**:根据 \`ctx.steps\` 与业务状态返回下一个角色名或 \`END\`。
|
||||
4. **组装 WorkflowDefinition**:在模板 \`index\` 中导出 definition(以及必要的角色 / moderator 导出)。
|
||||
2. **编写 RoleDefinition**:为每个角色写 Zod \`schema\`,补齐 \`systemPrompt\` / \`description\`。
|
||||
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 +156,13 @@ uncaged-workflow add <name> <path/to/bundle.esm.js>
|
||||
|
||||
---
|
||||
|
||||
编写新 workflow 时,先对齐 **RoleMeta → RoleDefinition(Zod)→ Moderator → 绑定 → 单文件 bundle**,再对照本节规范自检。
|
||||
编写新 workflow 时,先对齐 **RoleMeta → RoleDefinition(Zod)→ ModeratorTable → 绑定 → 单文件 bundle**,再对照本节规范自检。
|
||||
`;
|
||||
}
|
||||
|
||||
function bunfigToml(): string {
|
||||
return `[install.scopes]
|
||||
"@uncaged" = "https://git.shazhou.work/api/packages/shazhou/npm/"
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -164,7 +173,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
|
||||
@@ -184,6 +193,104 @@ uncaged-workflow init workspace ${workspaceName}
|
||||
`;
|
||||
}
|
||||
|
||||
function bundleTs(): string {
|
||||
return [
|
||||
'import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";',
|
||||
'import { join } from "node:path";',
|
||||
"",
|
||||
'const rootDir = join(import.meta.dir, "..");',
|
||||
'const workflowsDir = join(rootDir, "workflows");',
|
||||
'const distDir = join(rootDir, "dist");',
|
||||
"",
|
||||
"type JsonDeps = {",
|
||||
" dependencies: Record<string, string> | null;",
|
||||
" devDependencies: Record<string, string> | null;",
|
||||
"};",
|
||||
"",
|
||||
"function isEntryFile(name: string): boolean {",
|
||||
' return name.endsWith("-entry.ts");',
|
||||
"}",
|
||||
"",
|
||||
"function entryStem(name: string): string {",
|
||||
' return name.slice(0, -".ts".length);',
|
||||
"}",
|
||||
"",
|
||||
"async function uncagedWorkflowExternals(): Promise<string[]> {",
|
||||
" const names = new Set<string>();",
|
||||
' const paths = [join(rootDir, "package.json"), join(workflowsDir, "package.json")];',
|
||||
" for (const pkgPath of paths) {",
|
||||
" let raw: string;",
|
||||
" try {",
|
||||
' raw = await readFile(pkgPath, "utf8");',
|
||||
" } catch {",
|
||||
" continue;",
|
||||
" }",
|
||||
" const parsed = JSON.parse(raw) as JsonDeps;",
|
||||
" const blocks = [parsed.dependencies, parsed.devDependencies];",
|
||||
" for (const block of blocks) {",
|
||||
" if (block == null) {",
|
||||
" continue;",
|
||||
" }",
|
||||
" for (const key of Object.keys(block)) {",
|
||||
' if (key.startsWith("@uncaged/workflow")) {',
|
||||
" names.add(key);",
|
||||
" }",
|
||||
" }",
|
||||
" }",
|
||||
" }",
|
||||
" if (names.size === 0) {",
|
||||
' names.add("@uncaged/workflow-runtime");',
|
||||
' names.add("@uncaged/workflow-protocol");',
|
||||
" }",
|
||||
" return [...names];",
|
||||
"}",
|
||||
"",
|
||||
"async function main(): Promise<void> {",
|
||||
" await mkdir(distDir, { recursive: true });",
|
||||
" let files: string[];",
|
||||
" try {",
|
||||
" files = await readdir(workflowsDir);",
|
||||
" } catch {",
|
||||
' console.error("bundle: missing workflows/ directory");',
|
||||
" process.exitCode = 1;",
|
||||
" return;",
|
||||
" }",
|
||||
" const entries = files.filter(isEntryFile);",
|
||||
" if (entries.length === 0) {",
|
||||
' console.warn("bundle: no *-entry.ts files under workflows/");',
|
||||
" return;",
|
||||
" }",
|
||||
" const external = await uncagedWorkflowExternals();",
|
||||
" for (const file of entries) {",
|
||||
" const stem = entryStem(file);",
|
||||
" const entryPath = join(workflowsDir, file);",
|
||||
" const result = await Bun.build({",
|
||||
" entrypoints: [entryPath],",
|
||||
" outdir: distDir,",
|
||||
' format: "esm",',
|
||||
' target: "node",',
|
||||
" splitting: false,",
|
||||
' naming: { entry: "[name].esm.js" },',
|
||||
" external,",
|
||||
" });",
|
||||
" if (!result.success) {",
|
||||
" for (const log of result.logs) {",
|
||||
" console.error(log);",
|
||||
" }",
|
||||
` throw new Error(\`bundle failed for \${file}\`);`,
|
||||
" }",
|
||||
" const dts =",
|
||||
` 'export { run, descriptor } from "../workflows/' + stem + '.js";\\n';`,
|
||||
` await writeFile(join(distDir, \`\${stem}.d.ts\`), dts, "utf8");`,
|
||||
` console.log(\`bundle: \${stem} -> dist/\${stem}.esm.js\`);`,
|
||||
" }",
|
||||
"}",
|
||||
"",
|
||||
"await main();",
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export async function cmdInitWorkspace(
|
||||
parentDir: string,
|
||||
workspaceName: string,
|
||||
@@ -201,6 +308,7 @@ export async function cmdInitWorkspace(
|
||||
await mkdir(rootPath, { recursive: false });
|
||||
await mkdir(join(rootPath, "templates"), { recursive: false });
|
||||
await mkdir(join(rootPath, "workflows"), { recursive: false });
|
||||
await mkdir(join(rootPath, "scripts"), { recursive: false });
|
||||
|
||||
await Promise.all([
|
||||
writeFile(join(rootPath, "package.json"), rootPackageJson(workspaceName), "utf8"),
|
||||
@@ -210,6 +318,8 @@ export async function cmdInitWorkspace(
|
||||
writeFile(join(rootPath, "README.md"), readmeMd(workspaceName), "utf8"),
|
||||
writeFile(join(rootPath, "templates", ".gitkeep"), "", "utf8"),
|
||||
writeFile(join(rootPath, "workflows", "package.json"), workflowsPackageJson(), "utf8"),
|
||||
writeFile(join(rootPath, "bunfig.toml"), bunfigToml(), "utf8"),
|
||||
writeFile(join(rootPath, "scripts", "bundle.ts"), bundleTs(), "utf8"),
|
||||
]);
|
||||
|
||||
return ok({ rootPath });
|
||||
|
||||
@@ -8,7 +8,7 @@ import { createWorkflowRoutes } from "./routes-workflow.js";
|
||||
|
||||
const MAX_BODY_SIZE = 1_048_576; // 1 MB
|
||||
|
||||
export function createApp(storageRoot: string): Hono {
|
||||
export function createApp(storageRoot: string, agentToken: string | null): Hono {
|
||||
const app = new Hono();
|
||||
|
||||
app.onError((_err, c) => {
|
||||
@@ -37,7 +37,19 @@ export function createApp(storageRoot: string): Hono {
|
||||
await next();
|
||||
});
|
||||
|
||||
// ── Agent token auth (skip healthz) ───────────────────────────────
|
||||
if (agentToken !== null) {
|
||||
app.use("/api/*", async (c, next) => {
|
||||
const token = c.req.header("X-Agent-Token");
|
||||
if (token !== agentToken) {
|
||||
return c.json({ error: "unauthorized" }, 401);
|
||||
}
|
||||
await next();
|
||||
});
|
||||
}
|
||||
|
||||
app.get("/healthz", (c) => c.json({ ok: true }));
|
||||
app.get("/api/healthz", (c) => c.json({ ok: true }));
|
||||
|
||||
app.route("/api/workflows", createWorkflowRoutes(storageRoot));
|
||||
app.route("/api/threads", createThreadRoutes(storageRoot));
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { statSync, watch } from "node:fs";
|
||||
import { existsSync, statSync, watch } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { createCasStore, getContentMerklePayload } from "@uncaged/workflow-cas";
|
||||
import {
|
||||
@@ -118,7 +118,12 @@ async function emitRecordsForHead(params: {
|
||||
params.eventId.n++;
|
||||
await params.stream.writeSSE({
|
||||
event: "record",
|
||||
data: JSON.stringify({ type: "workflow-result", ...wf }),
|
||||
data: JSON.stringify({
|
||||
type: "workflow-result",
|
||||
returnCode: wf.returnCode,
|
||||
content: wf.summary,
|
||||
timestamp: null,
|
||||
}),
|
||||
id: String(params.eventId.n),
|
||||
});
|
||||
return true;
|
||||
@@ -307,6 +312,18 @@ export function createLiveRoutes(storageRoot: string): Hono {
|
||||
return;
|
||||
}
|
||||
|
||||
// If thread is not actively running, emit all records and close — don't keep SSE open
|
||||
const runningPath = join(storageRoot, "logs", threadTarget.bundleHash, `${threadId}.running`);
|
||||
if (!existsSync(runningPath)) {
|
||||
eventId.n++;
|
||||
await stream.writeSSE({
|
||||
event: "done",
|
||||
data: JSON.stringify({ reason: "not-running" }),
|
||||
id: String(eventId.n),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
let completed = false;
|
||||
|
||||
|
||||
@@ -1,36 +1,60 @@
|
||||
import { join } from "node:path";
|
||||
import { createCasStore, getContentMerklePayload } from "@uncaged/workflow-cas";
|
||||
import { createCasStore, getContentMerklePayload, parseCasThreadNode } from "@uncaged/workflow-cas";
|
||||
import { FORK_BRANCH_ROLE, walkStateFramesNewestFirst } from "@uncaged/workflow-execute";
|
||||
import { END } from "@uncaged/workflow-runtime";
|
||||
import { getGlobalCasDir } from "@uncaged/workflow-util";
|
||||
import { Hono } from "hono";
|
||||
|
||||
import { pathExists } from "../../fs-utils.js";
|
||||
import type { ResolvedThreadRecord } from "../../thread-scan.js";
|
||||
import type { HistoricalThreadRow, ResolvedThreadRecord } from "../../thread-scan.js";
|
||||
import {
|
||||
listHistoricalThreads,
|
||||
listRunningThreads,
|
||||
resolveThreadListStatus,
|
||||
resolveThreadRecord,
|
||||
} from "../../thread-scan.js";
|
||||
import { cmdKill, cmdPause, cmdResume } from "../thread/control.js";
|
||||
import { cmdRun } from "../thread/run.js";
|
||||
|
||||
async function readStartInfo(
|
||||
cas: ReturnType<typeof createCasStore>,
|
||||
startHash: string,
|
||||
): Promise<{ name: string | null; prompt: string | null }> {
|
||||
const raw = await cas.get(startHash);
|
||||
if (raw === null) return { name: null, prompt: null };
|
||||
const parsed = parseCasThreadNode(raw);
|
||||
if (parsed === null || parsed.kind !== "start") return { name: null, prompt: null };
|
||||
const name = parsed.node.payload.name;
|
||||
const promptHash = parsed.node.refs[0] ?? null;
|
||||
let prompt: string | null = null;
|
||||
if (promptHash !== null) {
|
||||
prompt = await getContentMerklePayload(cas, promptHash);
|
||||
}
|
||||
return { name, prompt };
|
||||
}
|
||||
|
||||
async function buildThreadDetailRecords(
|
||||
storageRoot: string,
|
||||
resolved: ResolvedThreadRecord,
|
||||
runningMarkerPresent: boolean,
|
||||
statusRow: HistoricalThreadRow,
|
||||
): Promise<unknown[]> {
|
||||
const cas = createCasStore(getGlobalCasDir(storageRoot));
|
||||
const frames = await walkStateFramesNewestFirst(cas, resolved.head);
|
||||
const chronological = [...frames].reverse();
|
||||
|
||||
const { name: workflowName, prompt } = await readStartInfo(cas, resolved.start);
|
||||
|
||||
const status = await resolveThreadListStatus(storageRoot, statusRow, runningMarkerPresent);
|
||||
|
||||
const records: unknown[] = [
|
||||
{
|
||||
type: "thread-start",
|
||||
workflow: workflowName ?? "unknown",
|
||||
prompt: prompt ?? null,
|
||||
threadId: resolved.threadId,
|
||||
bundleHash: resolved.bundleHash,
|
||||
head: resolved.head,
|
||||
start: resolved.start,
|
||||
source: resolved.source,
|
||||
status,
|
||||
timestamp: null,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -42,7 +66,12 @@ async function buildThreadDetailRecords(
|
||||
const returnCode = fr.payload.meta.returnCode;
|
||||
const summary = fr.payload.meta.summary;
|
||||
if (typeof returnCode === "number" && typeof summary === "string") {
|
||||
records.push({ type: "workflow-result", returnCode, summary });
|
||||
records.push({
|
||||
type: "workflow-result",
|
||||
returnCode,
|
||||
content: summary,
|
||||
timestamp: fr.payload.timestamp,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -73,8 +102,8 @@ export function createThreadRoutes(storageRoot: string): Hono {
|
||||
const threads = await Promise.all(
|
||||
rows.map(async (r) => {
|
||||
const runningPath = join(storageRoot, "logs", r.hash, `${r.threadId}.running`);
|
||||
const isRunning = await pathExists(runningPath);
|
||||
const status = r.source === "history" ? "completed" : isRunning ? "running" : "active";
|
||||
const runningMarkerPresent = await pathExists(runningPath);
|
||||
const status = await resolveThreadListStatus(storageRoot, r, runningMarkerPresent);
|
||||
return {
|
||||
threadId: r.threadId,
|
||||
workflow: r.workflowName,
|
||||
@@ -98,7 +127,22 @@ export function createThreadRoutes(storageRoot: string): Hono {
|
||||
if (resolved === null) {
|
||||
return c.json({ error: `thread not found: ${threadId}` }, 404);
|
||||
}
|
||||
const records = await buildThreadDetailRecords(storageRoot, resolved);
|
||||
const runningPath = join(storageRoot, "logs", resolved.bundleHash, `${threadId}.running`);
|
||||
const runningMarkerPresent = await pathExists(runningPath);
|
||||
const statusRow = {
|
||||
threadId: resolved.threadId,
|
||||
hash: resolved.bundleHash,
|
||||
workflowName: null,
|
||||
source: resolved.source,
|
||||
activityTs: 0,
|
||||
head: resolved.head,
|
||||
};
|
||||
const records = await buildThreadDetailRecords(
|
||||
storageRoot,
|
||||
resolved,
|
||||
runningMarkerPresent,
|
||||
statusRow,
|
||||
);
|
||||
return c.json({ threadId, records });
|
||||
});
|
||||
|
||||
@@ -112,13 +156,12 @@ export function createThreadRoutes(storageRoot: string): Hono {
|
||||
|
||||
const name = body.workflow;
|
||||
const prompt = body.prompt;
|
||||
const maxRounds = typeof body.maxRounds === "number" ? body.maxRounds : 10;
|
||||
|
||||
if (typeof name !== "string" || typeof prompt !== "string") {
|
||||
return c.json({ error: "workflow (string) and prompt (string) are required" }, 400);
|
||||
}
|
||||
|
||||
const result = await cmdRun(storageRoot, name, prompt, maxRounds);
|
||||
const result = await cmdRun(storageRoot, name, prompt);
|
||||
if (!result.ok) {
|
||||
return c.json({ error: result.error }, 400);
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -1,12 +1,27 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { hostname as osHostname } from "node:os";
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
import { serve } from "bun";
|
||||
|
||||
import { printCliLine } from "../../cli-output.js";
|
||||
import { createApp } from "./app.js";
|
||||
import {
|
||||
registerWithGateway,
|
||||
startHeartbeat,
|
||||
startTunnel,
|
||||
unregisterFromGateway,
|
||||
} from "./tunnel.js";
|
||||
import type { ServeOptions } from "./types.js";
|
||||
|
||||
export function startServer(storageRoot: string, options: ServeOptions): void {
|
||||
const app = createApp(storageRoot);
|
||||
const DEFAULT_GATEWAY_URL = "https://workflow-gateway.shazhou.workers.dev";
|
||||
const HEARTBEAT_INTERVAL_MS = 60_000;
|
||||
|
||||
export function startServer(
|
||||
storageRoot: string,
|
||||
options: ServeOptions,
|
||||
agentToken: string | null,
|
||||
): void {
|
||||
const app = createApp(storageRoot, agentToken);
|
||||
|
||||
const server = serve({
|
||||
fetch: app.fetch,
|
||||
@@ -28,30 +43,51 @@ function parsePortValue(value: string | undefined): Result<number, string> {
|
||||
return ok(parsed);
|
||||
}
|
||||
|
||||
function requireNextArg(argv: string[], i: number, flag: string): Result<string, string> {
|
||||
const next = argv[i + 1];
|
||||
if (next === undefined) {
|
||||
return err(`${flag} requires a value`);
|
||||
}
|
||||
return ok(next);
|
||||
}
|
||||
|
||||
function parseServeArgv(argv: string[]): Result<ServeOptions, string> {
|
||||
let port = 7860;
|
||||
let hostname = "127.0.0.1";
|
||||
let name = osHostname().split(".")[0].toLowerCase();
|
||||
let noTunnel = false;
|
||||
let gatewayUrl = DEFAULT_GATEWAY_URL;
|
||||
const gatewaySecret = process.env.WORKFLOW_GATEWAY_SECRET ?? "";
|
||||
const stringFlags: Record<string, (v: string) => void> = {
|
||||
"--host": (v) => {
|
||||
hostname = v;
|
||||
},
|
||||
"--name": (v) => {
|
||||
name = v;
|
||||
},
|
||||
"--gateway": (v) => {
|
||||
gatewayUrl = v;
|
||||
},
|
||||
};
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
if (arg === "--port" || arg === "-p") {
|
||||
const portResult = parsePortValue(argv[i + 1]);
|
||||
if (!portResult.ok) {
|
||||
return portResult;
|
||||
}
|
||||
if (!portResult.ok) return portResult;
|
||||
port = portResult.value;
|
||||
i++;
|
||||
} else if (arg === "--host") {
|
||||
const next = argv[i + 1];
|
||||
if (next === undefined) {
|
||||
return err("--host requires a value");
|
||||
}
|
||||
hostname = next;
|
||||
} else if (arg === "--no-tunnel") {
|
||||
noTunnel = true;
|
||||
} else if (arg in stringFlags) {
|
||||
const r = requireNextArg(argv, i, arg);
|
||||
if (!r.ok) return r;
|
||||
stringFlags[arg](r.value);
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
return ok({ port, hostname });
|
||||
return ok({ port, hostname, name, noTunnel, gatewayUrl, gatewaySecret });
|
||||
}
|
||||
|
||||
export async function dispatchServe(storageRoot: string, argv: string[]): Promise<number> {
|
||||
@@ -61,7 +97,65 @@ export async function dispatchServe(storageRoot: string, argv: string[]): Promis
|
||||
return 1;
|
||||
}
|
||||
|
||||
startServer(storageRoot, parsed.value);
|
||||
const options = parsed.value;
|
||||
const agentToken = options.noTunnel ? null : randomUUID();
|
||||
startServer(storageRoot, options, agentToken);
|
||||
|
||||
if (options.noTunnel) {
|
||||
printCliLine("tunnel disabled (--no-tunnel)");
|
||||
await new Promise(() => {});
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Start cloudflared quick tunnel
|
||||
printCliLine("starting cloudflared quick tunnel...");
|
||||
const tunnel = await startTunnel(options.port);
|
||||
|
||||
if (!tunnel) {
|
||||
printCliLine("failed to create tunnel — continuing without gateway registration");
|
||||
await new Promise(() => {});
|
||||
return 0;
|
||||
}
|
||||
|
||||
printCliLine(`tunnel: ${tunnel.url}`);
|
||||
|
||||
// Register with gateway
|
||||
if (options.gatewaySecret) {
|
||||
const registered = await registerWithGateway(
|
||||
options.gatewayUrl,
|
||||
options.name,
|
||||
tunnel.url,
|
||||
options.gatewaySecret,
|
||||
agentToken!,
|
||||
);
|
||||
if (registered) {
|
||||
printCliLine(`registered with gateway as "${options.name}"`);
|
||||
}
|
||||
|
||||
// Start heartbeat
|
||||
const heartbeatTimer = startHeartbeat(
|
||||
options.gatewayUrl,
|
||||
options.name,
|
||||
tunnel.url,
|
||||
options.gatewaySecret,
|
||||
agentToken!,
|
||||
HEARTBEAT_INTERVAL_MS,
|
||||
);
|
||||
|
||||
// Cleanup on exit
|
||||
const cleanup = async () => {
|
||||
clearInterval(heartbeatTimer);
|
||||
printCliLine("unregistering from gateway...");
|
||||
await unregisterFromGateway(options.gatewayUrl, options.name, options.gatewaySecret);
|
||||
tunnel.process.kill();
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.on("SIGINT", cleanup);
|
||||
process.on("SIGTERM", cleanup);
|
||||
} else {
|
||||
printCliLine("WORKFLOW_GATEWAY_SECRET not set — skipping gateway registration");
|
||||
}
|
||||
|
||||
// Keep process alive
|
||||
await new Promise(() => {});
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { printCliLine } from "../../cli-output.js";
|
||||
|
||||
type TunnelHandle = {
|
||||
process: ReturnType<typeof Bun.spawn>;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export async function startTunnel(port: number): Promise<TunnelHandle | null> {
|
||||
const proc = Bun.spawn(["cloudflared", "tunnel", "--url", `http://localhost:${port}`], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
|
||||
// cloudflared prints the URL to stderr
|
||||
const reader = proc.stderr.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
const deadline = Date.now() + 30_000;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const match = buffer.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/);
|
||||
if (match) {
|
||||
// Release the reader so stderr keeps flowing without backpressure
|
||||
reader.releaseLock();
|
||||
return { process: proc, url: match[0] };
|
||||
}
|
||||
}
|
||||
|
||||
reader.releaseLock();
|
||||
proc.kill();
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function registerWithGateway(
|
||||
gatewayUrl: string,
|
||||
name: string,
|
||||
tunnelUrl: string,
|
||||
secret: string,
|
||||
agentToken: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const resp = await fetch(`${gatewayUrl}/api/gateway/register`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name, url: tunnelUrl, secret, agentToken }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await resp.text();
|
||||
printCliLine(`gateway registration failed: ${resp.status} ${body}`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
printCliLine(`gateway registration error: ${e}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function unregisterFromGateway(
|
||||
gatewayUrl: string,
|
||||
name: string,
|
||||
secret: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await fetch(`${gatewayUrl}/api/gateway/register/${name}`, {
|
||||
method: "DELETE",
|
||||
headers: { Authorization: `Bearer ${secret}` },
|
||||
});
|
||||
} catch {
|
||||
// Best effort — process is exiting
|
||||
}
|
||||
}
|
||||
|
||||
export function startHeartbeat(
|
||||
gatewayUrl: string,
|
||||
name: string,
|
||||
tunnelUrl: string,
|
||||
secret: string,
|
||||
agentToken: string,
|
||||
intervalMs: number,
|
||||
): ReturnType<typeof setInterval> {
|
||||
return setInterval(() => {
|
||||
registerWithGateway(gatewayUrl, name, tunnelUrl, secret, agentToken).catch(() => {});
|
||||
}, intervalMs);
|
||||
}
|
||||
@@ -1,4 +1,8 @@
|
||||
export type ServeOptions = {
|
||||
port: number;
|
||||
hostname: string;
|
||||
name: string;
|
||||
noTunnel: boolean;
|
||||
gatewayUrl: string;
|
||||
gatewaySecret: string;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
import { stdin as input, stdout as output } from "node:process";
|
||||
import { createInterface } from "node:readline/promises";
|
||||
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
|
||||
import { printCliError, printCliLine } from "../../cli-output.js";
|
||||
import { cmdSetup, printSetupSummary } from "./setup.js";
|
||||
import type { SetupCliArgs } from "./types.js";
|
||||
|
||||
function usageSetup(): string {
|
||||
return [
|
||||
"uncaged-workflow setup — configure workflow.yaml providers and default model",
|
||||
"",
|
||||
"Non-interactive (agent mode):",
|
||||
" uncaged-workflow setup \\",
|
||||
" --provider <name> \\",
|
||||
" --base-url <url> \\",
|
||||
" --api-key <key> \\",
|
||||
" --default-model <provider/model> \\",
|
||||
" [--init-workspace <name>]",
|
||||
"",
|
||||
"Interactive: run with no flags (prompts for each value).",
|
||||
"",
|
||||
"Storage: uses the same root as other commands (see UNCAGED_WORKFLOW_STORAGE_ROOT).",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function requireNext(argv: string[], i: number, flag: string): Result<string, string> {
|
||||
const next = argv[i + 1];
|
||||
if (next === undefined || next.startsWith("--")) {
|
||||
return err(`${flag} requires a value`);
|
||||
}
|
||||
return ok(next);
|
||||
}
|
||||
|
||||
type ParsedSetup = SetupCliArgs | "interactive" | "help";
|
||||
|
||||
type SetupFlagField = "provider" | "baseUrl" | "apiKey" | "defaultModel" | "initWorkspaceName";
|
||||
|
||||
const SETUP_FLAG_TO_FIELD: Record<string, SetupFlagField> = {
|
||||
"--provider": "provider",
|
||||
"--base-url": "baseUrl",
|
||||
"--api-key": "apiKey",
|
||||
"--default-model": "defaultModel",
|
||||
"--init-workspace": "initWorkspaceName",
|
||||
};
|
||||
|
||||
function emptyFlagState(): Record<SetupFlagField, string | null> {
|
||||
return {
|
||||
provider: null,
|
||||
baseUrl: null,
|
||||
apiKey: null,
|
||||
defaultModel: null,
|
||||
initWorkspaceName: null,
|
||||
};
|
||||
}
|
||||
|
||||
function finalizeParsedSetup(
|
||||
state: Record<SetupFlagField, string | null>,
|
||||
): Result<ParsedSetup, string> {
|
||||
const hasAnyFlag =
|
||||
state.provider !== null ||
|
||||
state.baseUrl !== null ||
|
||||
state.apiKey !== null ||
|
||||
state.defaultModel !== null ||
|
||||
state.initWorkspaceName !== null;
|
||||
|
||||
if (!hasAnyFlag) {
|
||||
return ok("interactive");
|
||||
}
|
||||
|
||||
if (state.provider === null) {
|
||||
return err(
|
||||
"non-interactive setup requires --provider (or omit all flags for interactive mode)",
|
||||
);
|
||||
}
|
||||
|
||||
const missing: string[] = [];
|
||||
if (state.baseUrl === null) {
|
||||
missing.push("--base-url");
|
||||
}
|
||||
if (state.apiKey === null) {
|
||||
missing.push("--api-key");
|
||||
}
|
||||
if (state.defaultModel === null) {
|
||||
missing.push("--default-model");
|
||||
}
|
||||
if (missing.length > 0) {
|
||||
return err(`missing required flag(s): ${missing.join(", ")}`);
|
||||
}
|
||||
|
||||
const b = state.baseUrl;
|
||||
const k = state.apiKey;
|
||||
const m = state.defaultModel;
|
||||
if (b === null || k === null || m === null) {
|
||||
return err("internal: missing required flags after validation");
|
||||
}
|
||||
|
||||
return ok({
|
||||
provider: state.provider,
|
||||
baseUrl: b,
|
||||
apiKey: k,
|
||||
defaultModel: m,
|
||||
initWorkspaceName: state.initWorkspaceName,
|
||||
});
|
||||
}
|
||||
|
||||
function parseSetupArgv(argv: string[]): Result<ParsedSetup, string> {
|
||||
const state = emptyFlagState();
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const tok = argv[i];
|
||||
if (tok === undefined) {
|
||||
break;
|
||||
}
|
||||
if (tok === "--help" || tok === "-h") {
|
||||
return ok("help");
|
||||
}
|
||||
const field = SETUP_FLAG_TO_FIELD[tok];
|
||||
if (field === undefined) {
|
||||
return err(`unknown argument: ${tok}`);
|
||||
}
|
||||
const v = requireNext(argv, i, tok);
|
||||
if (!v.ok) {
|
||||
return v;
|
||||
}
|
||||
state[field] = v.value;
|
||||
i++;
|
||||
}
|
||||
|
||||
return finalizeParsedSetup(state);
|
||||
}
|
||||
|
||||
async function promptLine(
|
||||
rl: { question: (q: string) => Promise<string> },
|
||||
label: string,
|
||||
): Promise<string> {
|
||||
const raw = await rl.question(label);
|
||||
return raw.trim();
|
||||
}
|
||||
|
||||
async function collectInteractiveSetup(): Promise<Result<SetupCliArgs, string>> {
|
||||
const rl = createInterface({ input, output });
|
||||
try {
|
||||
const provider = await promptLine(rl, "Provider name (e.g. openai, dashscope): ");
|
||||
if (provider === "") {
|
||||
return err("provider name must not be empty");
|
||||
}
|
||||
const baseUrl = await promptLine(rl, "Base URL: ");
|
||||
if (baseUrl === "") {
|
||||
return err("base URL must not be empty");
|
||||
}
|
||||
const apiKey = await promptLine(rl, "API key: ");
|
||||
if (apiKey === "") {
|
||||
return err("API key must not be empty");
|
||||
}
|
||||
const defaultModel = await promptLine(rl, "Default model (provider/model): ");
|
||||
if (defaultModel === "") {
|
||||
return err("default model must not be empty");
|
||||
}
|
||||
const yn = await promptLine(
|
||||
rl,
|
||||
"Initialize a workflow workspace under the current directory? (y/n): ",
|
||||
);
|
||||
const lower = yn.toLowerCase();
|
||||
let initWorkspaceName: string | null = null;
|
||||
if (lower === "y" || lower === "yes") {
|
||||
const name = await promptLine(rl, "Workspace directory name: ");
|
||||
if (name === "") {
|
||||
return err("workspace name must not be empty");
|
||||
}
|
||||
initWorkspaceName = name;
|
||||
} else if (lower !== "n" && lower !== "no" && lower !== "") {
|
||||
return err('expected "y" or "n" for workspace init prompt');
|
||||
}
|
||||
return ok({
|
||||
provider,
|
||||
baseUrl,
|
||||
apiKey,
|
||||
defaultModel,
|
||||
initWorkspaceName,
|
||||
});
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function dispatchSetup(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const parsed = parseSetupArgv(argv);
|
||||
if (!parsed.ok) {
|
||||
printCliError(`${parsed.error}\n\n${usageSetup()}`);
|
||||
return 1;
|
||||
}
|
||||
if (parsed.value === "help") {
|
||||
printCliLine(usageSetup());
|
||||
return 0;
|
||||
}
|
||||
|
||||
let args: SetupCliArgs;
|
||||
if (parsed.value === "interactive") {
|
||||
const collected = await collectInteractiveSetup();
|
||||
if (!collected.ok) {
|
||||
printCliError(collected.error);
|
||||
return 1;
|
||||
}
|
||||
args = collected.value;
|
||||
} else {
|
||||
args = parsed.value;
|
||||
}
|
||||
|
||||
const result = await cmdSetup(storageRoot, args);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
printSetupSummary(result.value);
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { dispatchSetup } from "./dispatch.js";
|
||||
export { type CmdSetupSuccess, cmdSetup, printSetupSummary } from "./setup.js";
|
||||
export type { SetupCliArgs } from "./types.js";
|
||||
@@ -0,0 +1,112 @@
|
||||
import { err, ok, type Result, type WorkflowConfig } from "@uncaged/workflow-protocol";
|
||||
import {
|
||||
readWorkflowRegistry,
|
||||
splitProviderModelRef,
|
||||
workflowRegistryPath,
|
||||
writeWorkflowRegistry,
|
||||
} from "@uncaged/workflow-register";
|
||||
import { createLogger } from "@uncaged/workflow-util";
|
||||
|
||||
import { printCliLine } from "../../cli-output.js";
|
||||
import { cmdInitWorkspace } from "../init/index.js";
|
||||
import type { SetupCliArgs } from "./types.js";
|
||||
|
||||
const setupLog = createLogger({ sink: { kind: "stderr" } });
|
||||
|
||||
export type CmdSetupSuccess = {
|
||||
registryPath: string;
|
||||
provider: string;
|
||||
defaultModel: string;
|
||||
maxDepth: number;
|
||||
supervisorInterval: number;
|
||||
initWorkspaceRootPath: string | null;
|
||||
};
|
||||
|
||||
function mergeWorkflowConfig(
|
||||
prev: WorkflowConfig | null,
|
||||
input: SetupCliArgs,
|
||||
): Result<WorkflowConfig, string> {
|
||||
const modelSplit = splitProviderModelRef(input.defaultModel);
|
||||
if (!modelSplit.ok) {
|
||||
return err(modelSplit.error);
|
||||
}
|
||||
if (modelSplit.value.providerName !== input.provider) {
|
||||
return err(
|
||||
`default model provider "${modelSplit.value.providerName}" must match --provider "${input.provider}"`,
|
||||
);
|
||||
}
|
||||
|
||||
const maxDepth = prev === null ? 3 : prev.maxDepth;
|
||||
const supervisorInterval = prev === null ? 3 : prev.supervisorInterval;
|
||||
const providers = {
|
||||
...(prev === null ? {} : prev.providers),
|
||||
[input.provider]: { baseUrl: input.baseUrl, apiKey: input.apiKey },
|
||||
};
|
||||
const models = { ...(prev === null ? {} : prev.models), default: input.defaultModel };
|
||||
|
||||
return ok({
|
||||
maxDepth,
|
||||
supervisorInterval,
|
||||
providers,
|
||||
models,
|
||||
});
|
||||
}
|
||||
|
||||
export async function cmdSetup(
|
||||
storageRoot: string,
|
||||
input: SetupCliArgs,
|
||||
): Promise<Result<CmdSetupSuccess, string>> {
|
||||
const readResult = await readWorkflowRegistry(storageRoot);
|
||||
if (!readResult.ok) {
|
||||
setupLog("W8JH4Q2K", `read workflow registry failed: ${readResult.error.message}`);
|
||||
return err(readResult.error.message);
|
||||
}
|
||||
|
||||
const current = readResult.value;
|
||||
const merged = mergeWorkflowConfig(current.config, input);
|
||||
if (!merged.ok) {
|
||||
return merged;
|
||||
}
|
||||
const nextConfig = merged.value;
|
||||
const nextRegistry = {
|
||||
config: nextConfig,
|
||||
workflows: current.workflows,
|
||||
};
|
||||
|
||||
const written = await writeWorkflowRegistry(storageRoot, nextRegistry);
|
||||
if (!written.ok) {
|
||||
setupLog("M2NB5VX9", `write workflow registry failed: ${written.error.message}`);
|
||||
return err(written.error.message);
|
||||
}
|
||||
|
||||
const registryPath = workflowRegistryPath(storageRoot);
|
||||
|
||||
let initWorkspaceRootPath: string | null = null;
|
||||
if (input.initWorkspaceName !== null) {
|
||||
const initResult = await cmdInitWorkspace(process.cwd(), input.initWorkspaceName);
|
||||
if (!initResult.ok) {
|
||||
setupLog("T7QC4HWP", `init workspace failed: ${initResult.error}`);
|
||||
return err(initResult.error);
|
||||
}
|
||||
initWorkspaceRootPath = initResult.value.rootPath;
|
||||
}
|
||||
|
||||
return ok({
|
||||
registryPath,
|
||||
provider: input.provider,
|
||||
defaultModel: input.defaultModel,
|
||||
maxDepth: nextConfig.maxDepth,
|
||||
supervisorInterval: nextConfig.supervisorInterval,
|
||||
initWorkspaceRootPath,
|
||||
});
|
||||
}
|
||||
|
||||
export function printSetupSummary(result: CmdSetupSuccess): void {
|
||||
printCliLine(`wrote registry: ${result.registryPath}`);
|
||||
printCliLine(`provider "${result.provider}" (baseUrl + apiKey updated)`);
|
||||
printCliLine(`config.models.default = "${result.defaultModel}"`);
|
||||
printCliLine(`maxDepth=${result.maxDepth}, supervisorInterval=${result.supervisorInterval}`);
|
||||
if (result.initWorkspaceRootPath !== null) {
|
||||
printCliLine(`initialized workflow workspace at ${result.initWorkspaceRootPath}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/** Parsed non-interactive `setup` CLI arguments (all fields required for agent mode). */
|
||||
export type SetupCliArgs = {
|
||||
provider: string;
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
defaultModel: string;
|
||||
initWorkspaceName: string | null;
|
||||
};
|
||||
@@ -26,12 +26,7 @@ export async function dispatchRun(storageRoot: string, argv: string[]): Promise<
|
||||
return 1;
|
||||
}
|
||||
|
||||
const result = await cmdRun(
|
||||
storageRoot,
|
||||
parsed.value.name,
|
||||
parsed.value.prompt,
|
||||
parsed.value.maxRounds,
|
||||
);
|
||||
const result = await cmdRun(storageRoot, parsed.value.name, parsed.value.prompt);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
@@ -166,7 +161,7 @@ export async function dispatchFork(storageRoot: string, argv: string[]): Promise
|
||||
export const THREAD_SUBCOMMAND_TABLE: Record<string, CommandEntry> = {
|
||||
run: {
|
||||
handler: dispatchRun,
|
||||
args: "<name> [--prompt <text>] [--max-rounds N]",
|
||||
args: "<name> [--prompt <text>]",
|
||||
description: "Start a new thread executing a workflow",
|
||||
},
|
||||
list: {
|
||||
|
||||
@@ -10,7 +10,6 @@ export async function cmdRun(
|
||||
storageRoot: string,
|
||||
name: string,
|
||||
prompt: string,
|
||||
maxRounds: number,
|
||||
): Promise<Result<{ threadId: string }, string>> {
|
||||
const nameOk = validateCliWorkflowName(name);
|
||||
if (!nameOk.ok) {
|
||||
@@ -41,7 +40,7 @@ export async function cmdRun(
|
||||
threadId,
|
||||
workflowName: name,
|
||||
prompt,
|
||||
options: { maxRounds, depth: 0 },
|
||||
options: { depth: 0 },
|
||||
},
|
||||
{ awaitResponseLine: false },
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createCasStore, getContentMerklePayload } from "@uncaged/workflow-cas";
|
||||
import { createCasStore, getContentMerklePayload, parseCasThreadNode } from "@uncaged/workflow-cas";
|
||||
import { FORK_BRANCH_ROLE, walkStateFramesNewestFirst } from "@uncaged/workflow-execute";
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
import { END } from "@uncaged/workflow-runtime";
|
||||
@@ -6,6 +6,21 @@ import { getGlobalCasDir } from "@uncaged/workflow-util";
|
||||
|
||||
import { resolveThreadRecord } from "../../thread-scan.js";
|
||||
|
||||
async function readParentStateFromStartNode(
|
||||
cas: { get(hash: string): Promise<string | null> },
|
||||
startHash: string,
|
||||
): Promise<string | null> {
|
||||
const yamlText = await cas.get(startHash);
|
||||
if (yamlText === null) {
|
||||
return null;
|
||||
}
|
||||
const parsed = parseCasThreadNode(yamlText);
|
||||
if (parsed === null || parsed.kind !== "start") {
|
||||
return null;
|
||||
}
|
||||
return parsed.node.payload.parentState;
|
||||
}
|
||||
|
||||
export async function cmdThreadShow(
|
||||
storageRoot: string,
|
||||
threadId: string,
|
||||
@@ -19,7 +34,15 @@ export async function cmdThreadShow(
|
||||
const frames = await walkStateFramesNewestFirst(cas, resolved.head);
|
||||
const chronological = [...frames].reverse();
|
||||
|
||||
const steps: Array<{ role: string; hash: string; timestamp: number; content: string }> = [];
|
||||
const parentState = await readParentStateFromStartNode(cas, resolved.start);
|
||||
|
||||
const steps: Array<{
|
||||
role: string;
|
||||
hash: string;
|
||||
timestamp: number;
|
||||
content: string;
|
||||
childThread: string | null;
|
||||
}> = [];
|
||||
for (const fr of chronological) {
|
||||
if (fr.payload.role === END || fr.payload.role === FORK_BRANCH_ROLE) {
|
||||
continue;
|
||||
@@ -33,6 +56,7 @@ export async function cmdThreadShow(
|
||||
payloadText !== null
|
||||
? payloadText
|
||||
: `(content not in CAS; contentHash=${fr.payload.content})`,
|
||||
childThread: fr.payload.childThread,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -41,6 +65,7 @@ export async function cmdThreadShow(
|
||||
bundleHash: resolved.bundleHash,
|
||||
head: resolved.head,
|
||||
start: resolved.start,
|
||||
parentState,
|
||||
source: resolved.source,
|
||||
steps,
|
||||
};
|
||||
|
||||
@@ -3,12 +3,12 @@ import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
export type ParsedRunArgv = {
|
||||
name: string;
|
||||
prompt: string;
|
||||
maxRounds: number;
|
||||
};
|
||||
|
||||
type FlagOk = { kind: "prompt"; value: string } | { kind: "max-rounds"; value: number };
|
||||
|
||||
function parseFlagAt(argv: string[], index: number): Result<FlagOk, string> | null {
|
||||
function parseFlagAt(
|
||||
argv: string[],
|
||||
index: number,
|
||||
): Result<{ kind: "prompt"; value: string }, string> | null {
|
||||
const flag = argv[index];
|
||||
if (flag === "--prompt") {
|
||||
const value = argv[index + 1];
|
||||
@@ -17,24 +17,12 @@ function parseFlagAt(argv: string[], index: number): Result<FlagOk, string> | nu
|
||||
}
|
||||
return ok({ kind: "prompt", value });
|
||||
}
|
||||
if (flag === "--max-rounds") {
|
||||
const value = argv[index + 1];
|
||||
if (value === undefined) {
|
||||
return err("missing value for --max-rounds");
|
||||
}
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0) {
|
||||
return err("--max-rounds must be a non-negative integer");
|
||||
}
|
||||
return ok({ kind: "max-rounds", value: n });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parseRunArgv(argv: string[]): Result<ParsedRunArgv, string> {
|
||||
let name: string | undefined;
|
||||
let prompt = "";
|
||||
let maxRounds = 5;
|
||||
|
||||
let i = 0;
|
||||
const first = argv[0];
|
||||
@@ -54,12 +42,7 @@ export function parseRunArgv(argv: string[]): Result<ParsedRunArgv, string> {
|
||||
}
|
||||
|
||||
const flag = parsed.value;
|
||||
if (flag.kind === "prompt") {
|
||||
prompt = flag.value;
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
maxRounds = flag.value;
|
||||
prompt = flag.value;
|
||||
i += 2;
|
||||
}
|
||||
|
||||
@@ -67,5 +50,5 @@ export function parseRunArgv(argv: string[]): Result<ParsedRunArgv, string> {
|
||||
return err("run requires <name>");
|
||||
}
|
||||
|
||||
return ok({ name, prompt, maxRounds });
|
||||
return ok({ name, prompt });
|
||||
}
|
||||
|
||||
@@ -54,8 +54,9 @@ function formatSkillCli(): string {
|
||||
const commandSections: string[] = [];
|
||||
for (const group of groups) {
|
||||
const rows = group.commands.map((cmd) => {
|
||||
const namePart = cmd.name === "" ? "" : ` ${cmd.name}`;
|
||||
const args = cmd.args ? `\`${cmd.args}\`` : "(none)";
|
||||
return `| \`${group.name} ${cmd.name}\` | ${args} | ${cmd.description} |`;
|
||||
return `| \`${group.name}${namePart}\` | ${args} | ${cmd.description} |`;
|
||||
});
|
||||
commandSections.push(
|
||||
`### ${group.name}\n\n| Command | Args | Description |\n|---------|------|-------------|\n${rows.join("\n")}`,
|
||||
@@ -85,6 +86,12 @@ ${commandSections.join("\n\n")}
|
||||
| \`run\` | \`thread run\` | Shortcut to start a thread |
|
||||
| \`live\` | \`thread live\` | Shortcut to attach to a thread |
|
||||
|
||||
### serve
|
||||
|
||||
| Command | Args | Description |
|
||||
|---------|------|-------------|
|
||||
| \`serve\` | \`[--port N] [--host ADDR] [--name NAME]\` | Start HTTP API server with auto-tunnel. \`--name\` registers with the gateway. |
|
||||
|
||||
## Typical Workflow
|
||||
|
||||
1. \`uncaged-workflow workflow add my-wf ./my-wf.esm.js\` — register a workflow
|
||||
@@ -92,6 +99,15 @@ ${commandSections.join("\n\n")}
|
||||
3. \`uncaged-workflow live --latest\` — attach and watch output
|
||||
4. \`uncaged-workflow thread show <thread-id>\` — inspect completed thread
|
||||
|
||||
## Thread Status
|
||||
|
||||
| Status | Meaning |
|
||||
|--------|---------|
|
||||
| \`running\` | Worker process is alive (\`.running\` marker + live PID) |
|
||||
| \`active\` | In \`threads.json\` but not currently running (paused or waiting) |
|
||||
| \`completed\` | Finished with \`returnCode === 0\` (has \`__end__\` frame in CAS) |
|
||||
| \`failed\` | Finished with non-zero return code, or worker crashed (dead PID / no ctl) |
|
||||
|
||||
## Exit Codes
|
||||
|
||||
| Code | Meaning |
|
||||
@@ -103,7 +119,9 @@ ${commandSections.join("\n\n")}
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| \`UNCAGED_WORKFLOW_STORAGE_ROOT\` | Override the default storage directory for all workflow data |
|
||||
| \`WORKFLOW_STORAGE_ROOT\` | Override the default storage directory for all workflow data |
|
||||
| \`UNCAGED_WORKFLOW_STORAGE_ROOT\` | Same as above (takes priority) |
|
||||
| \`WORKFLOW_LLM_API_KEY\` | API key for LLM calls during workflow execution |
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -172,25 +190,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
|
||||
|
||||
@@ -200,7 +221,6 @@ Each role has:
|
||||
|-------|------|---------|
|
||||
| \`description\` | string | What the role does |
|
||||
| \`systemPrompt\` | string | System prompt for the agent |
|
||||
| \`extractPrompt\` | string | Instruction for extracting structured meta |
|
||||
| \`schema\` | ZodSchema | Validates the extracted meta |
|
||||
| \`extractRefs\` | fn or null | Extracts CAS hashes from meta for DAG linking |
|
||||
|
||||
@@ -210,7 +230,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
|
||||
|
||||
@@ -5,10 +5,13 @@ import {
|
||||
readThreadsIndex,
|
||||
type ThreadHistoryEntry,
|
||||
type ThreadIndex,
|
||||
walkStateFramesNewestFirst,
|
||||
} from "@uncaged/workflow-execute";
|
||||
import { END } from "@uncaged/workflow-runtime";
|
||||
import { getGlobalCasDir } from "@uncaged/workflow-util";
|
||||
|
||||
import { pathExists, readTextFileIfExists } from "./fs-utils.js";
|
||||
import { readWorkerCtl } from "./worker-spawn.js";
|
||||
|
||||
async function readWorkflowNameFromStartHash(
|
||||
storageRoot: string,
|
||||
@@ -98,6 +101,8 @@ export type HistoricalThreadRow = {
|
||||
source: "active" | "history";
|
||||
/** `updatedAt` for active threads; `completedAt` for history (ms since epoch). */
|
||||
activityTs: number;
|
||||
/** Current CAS head (`threads.json` / history row). */
|
||||
head: string;
|
||||
};
|
||||
|
||||
export type ResolvedThreadRecord = {
|
||||
@@ -172,6 +177,95 @@ export async function resolveThreadRecord(
|
||||
return null;
|
||||
}
|
||||
|
||||
export type ThreadHeadTerminal =
|
||||
| { kind: "non-terminal" }
|
||||
| { kind: "terminal"; returnCode: number };
|
||||
|
||||
/** True when the newest frame at `headHash` is `__end__` (workflow finished in CAS). */
|
||||
export async function readThreadTerminalFromHead(
|
||||
storageRoot: string,
|
||||
headHash: string,
|
||||
): Promise<ThreadHeadTerminal> {
|
||||
const cas = createCasStore(getGlobalCasDir(storageRoot));
|
||||
const frames = await walkStateFramesNewestFirst(cas, headHash);
|
||||
const newest = frames[0];
|
||||
if (newest === undefined) {
|
||||
return { kind: "non-terminal" };
|
||||
}
|
||||
if (newest.payload.role !== END) {
|
||||
return { kind: "non-terminal" };
|
||||
}
|
||||
const rc = newest.payload.meta.returnCode;
|
||||
if (typeof rc !== "number") {
|
||||
return { kind: "terminal", returnCode: 1 };
|
||||
}
|
||||
return { kind: "terminal", returnCode: rc };
|
||||
}
|
||||
|
||||
export type ThreadListStatus = "running" | "active" | "completed" | "failed";
|
||||
|
||||
/** Combines `.running` marker with CAS head: stale markers do not imply `running`. */
|
||||
export async function resolveThreadListStatus(
|
||||
storageRoot: string,
|
||||
row: HistoricalThreadRow,
|
||||
runningMarkerPresent: boolean,
|
||||
): Promise<ThreadListStatus> {
|
||||
const terminal = await readThreadTerminalFromHead(storageRoot, row.head);
|
||||
if (terminal.kind === "terminal") {
|
||||
return terminal.returnCode !== 0 ? "failed" : "completed";
|
||||
}
|
||||
if (row.source === "history") {
|
||||
return "completed";
|
||||
}
|
||||
if (runningMarkerPresent) {
|
||||
const ctlResult = await readWorkerCtl(storageRoot, row.hash);
|
||||
if (ctlResult.ok) {
|
||||
try {
|
||||
process.kill(ctlResult.value.pid, 0);
|
||||
return "running";
|
||||
} catch {
|
||||
// Worker PID is dead but .running marker remains — crashed thread
|
||||
return "failed";
|
||||
}
|
||||
}
|
||||
return "running";
|
||||
}
|
||||
// No .running marker + no __end__ + source "active" → check if worker is dead (crashed)
|
||||
const ctlResult = await readWorkerCtl(storageRoot, row.hash);
|
||||
if (!ctlResult.ok) {
|
||||
// No ctl file means worker never registered or was already cleaned up — dead thread
|
||||
return "failed";
|
||||
}
|
||||
try {
|
||||
process.kill(ctlResult.value.pid, 0);
|
||||
} catch {
|
||||
// Worker PID is dead, thread never finished — crashed
|
||||
return "failed";
|
||||
}
|
||||
return "active";
|
||||
}
|
||||
|
||||
async function appendRunningThreadRowIfLive(
|
||||
storageRoot: string,
|
||||
hash: string,
|
||||
threadId: string,
|
||||
out: RunningThreadRow[],
|
||||
): Promise<void> {
|
||||
const resolved = await resolveThreadRecord(storageRoot, threadId);
|
||||
if (resolved !== null && resolved.bundleHash !== hash) {
|
||||
return;
|
||||
}
|
||||
if (resolved !== null) {
|
||||
const terminal = await readThreadTerminalFromHead(storageRoot, resolved.head);
|
||||
if (terminal.kind === "terminal") {
|
||||
return;
|
||||
}
|
||||
}
|
||||
const workflowName =
|
||||
resolved !== null ? await readWorkflowNameFromStartHash(storageRoot, resolved.start) : null;
|
||||
out.push({ threadId, hash, workflowName });
|
||||
}
|
||||
|
||||
/** Threads currently executing — identified via `<threadId>.running` markers. */
|
||||
export async function listRunningThreads(storageRoot: string): Promise<RunningThreadRow[]> {
|
||||
const logsRoot = join(storageRoot, "logs");
|
||||
@@ -196,10 +290,7 @@ export async function listRunningThreads(storageRoot: string): Promise<RunningTh
|
||||
continue;
|
||||
}
|
||||
const threadId = fileName.slice(0, -".running".length);
|
||||
const resolved = await resolveThreadRecord(storageRoot, threadId);
|
||||
const workflowName =
|
||||
resolved !== null ? await readWorkflowNameFromStartHash(storageRoot, resolved.start) : null;
|
||||
out.push({ threadId, hash, workflowName });
|
||||
await appendRunningThreadRowIfLive(storageRoot, hash, threadId, out);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,6 +344,7 @@ export async function listHistoricalThreads(
|
||||
workflowName,
|
||||
source: "active",
|
||||
activityTs: entry.updatedAt,
|
||||
head: entry.head,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -287,6 +379,7 @@ export async function listHistoricalThreads(
|
||||
workflowName,
|
||||
source: "history",
|
||||
activityTs: e.completedAt,
|
||||
head: e.head,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { getWorkerHostScriptPath } from "@uncaged/workflow-execute";
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
|
||||
import { pathExists, readTextFileIfExists } from "./fs-utils.js";
|
||||
import { readThreadTerminalFromHead, resolveThreadRecord } from "./thread-scan.js";
|
||||
|
||||
export type WorkerCtl = {
|
||||
pid: number;
|
||||
@@ -269,7 +270,25 @@ export async function resolveRunningHashForThread(
|
||||
if (!(await pathExists(logsRoot))) {
|
||||
return err(`thread not running (no logs dir): ${threadId}`);
|
||||
}
|
||||
const hashes = await readdir(logsRoot);
|
||||
const resolved = await resolveThreadRecord(storageRoot, threadId);
|
||||
if (resolved !== null) {
|
||||
const runningPath = join(logsRoot, resolved.bundleHash, `${threadId}.running`);
|
||||
if (!(await pathExists(runningPath))) {
|
||||
return err(`thread not running: ${threadId}`);
|
||||
}
|
||||
const terminal = await readThreadTerminalFromHead(storageRoot, resolved.head);
|
||||
if (terminal.kind === "terminal") {
|
||||
return err(`thread not running: ${threadId}`);
|
||||
}
|
||||
return ok(resolved.bundleHash);
|
||||
}
|
||||
|
||||
let hashes: string[];
|
||||
try {
|
||||
hashes = await readdir(logsRoot);
|
||||
} catch {
|
||||
return err(`thread not running: ${threadId}`);
|
||||
}
|
||||
for (const hash of hashes) {
|
||||
const runningPath = join(logsRoot, hash, `${threadId}.running`);
|
||||
if (await pathExists(runningPath)) {
|
||||
|
||||
@@ -1,67 +1,125 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type { ExtractContext, ExtractFn } from "@uncaged/workflow-runtime";
|
||||
import type * as z from "zod/v4";
|
||||
import { createCursorAgent, validateCursorAgentConfig } from "../src/index.js";
|
||||
|
||||
const testExtract: ExtractFn = async <T extends Record<string, unknown>>(
|
||||
_schema: z.ZodType<T>,
|
||||
_prompt: string,
|
||||
_ctx: ExtractContext,
|
||||
): Promise<{ meta: T; contentPayload: string; refs: string[] }> => ({
|
||||
meta: { workspace: "/tmp" } as unknown as T,
|
||||
contentPayload: "",
|
||||
refs: [],
|
||||
});
|
||||
|
||||
describe("validateCursorAgentConfig", () => {
|
||||
test("accepts valid config", () => {
|
||||
test("accepts valid config with explicit workspace", () => {
|
||||
const r = validateCursorAgentConfig({
|
||||
command: "/usr/local/bin/cursor-agent",
|
||||
model: null,
|
||||
timeout: 0,
|
||||
extract: testExtract,
|
||||
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({
|
||||
command: "/usr/local/bin/cursor-agent",
|
||||
model: null,
|
||||
timeout: 0,
|
||||
extract: null as unknown as ExtractFn,
|
||||
workspace: null,
|
||||
llmProvider: { baseUrl: "http://localhost", apiKey: "test", model: "test" },
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
});
|
||||
|
||||
test("rejects non-absolute command", () => {
|
||||
const r = validateCursorAgentConfig({
|
||||
command: "cursor-agent",
|
||||
model: null,
|
||||
timeout: 0,
|
||||
workspace: "/tmp/test-project",
|
||||
llmProvider: null,
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) {
|
||||
expect(r.error).toContain("extract");
|
||||
expect(r.error).toContain("absolute path");
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects empty workspace string", () => {
|
||||
const r = validateCursorAgentConfig({
|
||||
command: "/usr/local/bin/cursor-agent",
|
||||
model: null,
|
||||
timeout: 0,
|
||||
workspace: "",
|
||||
llmProvider: null,
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) {
|
||||
expect(r.error).toContain("workspace");
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects null workspace without llmProvider", () => {
|
||||
const r = validateCursorAgentConfig({
|
||||
command: "/usr/local/bin/cursor-agent",
|
||||
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({
|
||||
command: "/usr/local/bin/cursor-agent",
|
||||
model: null,
|
||||
timeout: -1,
|
||||
extract: testExtract,
|
||||
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({
|
||||
command: "/usr/local/bin/cursor-agent",
|
||||
model: null,
|
||||
timeout: 0,
|
||||
extract: testExtract,
|
||||
workspace: "/tmp/test-project",
|
||||
llmProvider: null,
|
||||
});
|
||||
expect(typeof agent).toBe("function");
|
||||
});
|
||||
|
||||
test("throws on invalid config at construction", () => {
|
||||
expect(() =>
|
||||
createCursorAgent({
|
||||
model: null,
|
||||
timeout: -1,
|
||||
extract: testExtract,
|
||||
}),
|
||||
).toThrow();
|
||||
test("returns an AgentFn with null workspace and llmProvider", () => {
|
||||
const agent = createCursorAgent({
|
||||
command: "/usr/local/bin/cursor-agent",
|
||||
model: null,
|
||||
timeout: 0,
|
||||
workspace: null,
|
||||
llmProvider: { baseUrl: "http://localhost", apiKey: "test", model: "test" },
|
||||
});
|
||||
expect(typeof agent).toBe("function");
|
||||
});
|
||||
|
||||
test("defers validation to call time (invalid config does not throw at construction)", () => {
|
||||
const agent = createCursorAgent({
|
||||
command: "/usr/local/bin/cursor-agent",
|
||||
model: null,
|
||||
timeout: -1,
|
||||
workspace: "/tmp/test-project",
|
||||
llmProvider: null,
|
||||
});
|
||||
expect(typeof agent).toBe("function");
|
||||
});
|
||||
|
||||
test("defers validation — null workspace without llmProvider does not throw at construction", () => {
|
||||
const agent = createCursorAgent({
|
||||
command: "/usr/local/bin/cursor-agent",
|
||||
model: null,
|
||||
timeout: 0,
|
||||
workspace: null,
|
||||
llmProvider: null,
|
||||
});
|
||||
expect(typeof agent).toBe("function");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-agent-cursor",
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.3",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
lockfileVersion: '9.0'
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@uncaged/workflow-runtime':
|
||||
specifier: workspace:*
|
||||
version: link:../workflow-runtime
|
||||
'@uncaged/workflow-util-agent':
|
||||
specifier: workspace:*
|
||||
version: link:../workflow-util-agent
|
||||
zod:
|
||||
specifier: ^4.0.0
|
||||
version: 4.4.3
|
||||
|
||||
packages:
|
||||
|
||||
zod@4.4.3:
|
||||
resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==}
|
||||
|
||||
snapshots:
|
||||
|
||||
zod@4.4.3: {}
|
||||
@@ -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,19 +1,14 @@
|
||||
import type { AgentFn, ExtractContext } from "@uncaged/workflow-runtime";
|
||||
import type { AgentFn } from "@uncaged/workflow-runtime";
|
||||
import { createLogger } from "@uncaged/workflow-util";
|
||||
import { buildAgentPrompt, type SpawnCliError, spawnCli } from "@uncaged/workflow-util-agent";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
import { extractWorkspacePath } from "./extract-workspace.js";
|
||||
import type { CursorAgentConfig } from "./types.js";
|
||||
import { validateCursorAgentConfig } from "./validate-config.js";
|
||||
|
||||
export type { CursorAgentConfig } from "./types.js";
|
||||
export { validateCursorAgentConfig } from "./validate-config.js";
|
||||
|
||||
const cursorWorkspaceSchema = z.object({
|
||||
workspace: z
|
||||
.string()
|
||||
.describe("Absolute path to the project/repository directory the agent should work in"),
|
||||
});
|
||||
|
||||
function throwCursorSpawnError(error: SpawnCliError): never {
|
||||
if (error.kind === "non_zero_exit") {
|
||||
throw new Error(
|
||||
@@ -33,27 +28,36 @@ 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) {
|
||||
throw new Error(validated.error);
|
||||
}
|
||||
|
||||
const modelFlag = resolveCursorModel(config.model);
|
||||
const timeoutMs = config.timeout > 0 ? config.timeout : null;
|
||||
const logger = createLogger({ sink: { kind: "stderr" } });
|
||||
|
||||
return async (ctx) => {
|
||||
const extractCtx: ExtractContext = {
|
||||
...ctx,
|
||||
agentContent: "",
|
||||
};
|
||||
const extracted = await config.extract(
|
||||
cursorWorkspaceSchema,
|
||||
"From the thread context, determine the absolute filesystem path where the project/repository is located.",
|
||||
extractCtx,
|
||||
);
|
||||
const { workspace } = extracted.meta;
|
||||
const validated = validateCursorAgentConfig(config);
|
||||
if (!validated.ok) {
|
||||
throw new Error(validated.error);
|
||||
}
|
||||
|
||||
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",
|
||||
@@ -67,7 +71,7 @@ export function createCursorAgent(config: CursorAgentConfig): AgentFn {
|
||||
"--trust",
|
||||
"--force",
|
||||
];
|
||||
const run = await spawnCli("cursor-agent", args, {
|
||||
const run = await spawnCli(config.command, args, {
|
||||
cwd: workspace,
|
||||
timeoutMs,
|
||||
});
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import type { ExtractFn } from "@uncaged/workflow-runtime";
|
||||
import type { LlmProvider } from "@uncaged/workflow-protocol";
|
||||
|
||||
export type CursorAgentConfig = {
|
||||
/** Absolute path to the cursor-agent CLI binary. */
|
||||
command: string;
|
||||
model: string | null;
|
||||
timeout: number;
|
||||
extract: ExtractFn;
|
||||
/** 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,18 @@
|
||||
import { err, ok, type Result } from "@uncaged/workflow-runtime";
|
||||
import { isAbsolute } from "node:path";
|
||||
|
||||
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.extract !== "function") {
|
||||
return err("extract must be a function");
|
||||
if (!isAbsolute(config.command)) {
|
||||
return err("command must be an absolute path to the cursor-agent CLI binary");
|
||||
}
|
||||
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");
|
||||
|
||||
@@ -4,14 +4,28 @@ import { createHermesAgent, validateHermesAgentConfig } from "../src/index.js";
|
||||
describe("validateHermesAgentConfig", () => {
|
||||
test("accepts valid config", () => {
|
||||
const r = validateHermesAgentConfig({
|
||||
command: "/usr/local/bin/hermes",
|
||||
model: null,
|
||||
timeout: null,
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
});
|
||||
|
||||
test("rejects non-absolute command", () => {
|
||||
const r = validateHermesAgentConfig({
|
||||
command: "hermes",
|
||||
model: null,
|
||||
timeout: null,
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) {
|
||||
expect(r.error).toContain("absolute path");
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects negative timeout", () => {
|
||||
const r = validateHermesAgentConfig({
|
||||
command: "/usr/local/bin/hermes",
|
||||
model: null,
|
||||
timeout: -5,
|
||||
});
|
||||
@@ -23,10 +37,11 @@ describe("validateHermesAgentConfig", () => {
|
||||
});
|
||||
|
||||
describe("createHermesAgent", () => {
|
||||
test("returns an AgentFn", () => {
|
||||
test("returns an AgentFn even with invalid config (validation deferred to call)", () => {
|
||||
const agent = createHermesAgent({
|
||||
command: "/usr/local/bin/hermes",
|
||||
model: null,
|
||||
timeout: null,
|
||||
timeout: -5,
|
||||
});
|
||||
expect(typeof agent).toBe("function");
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-agent-hermes",
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.3",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
lockfileVersion: '9.0'
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@uncaged/workflow-runtime':
|
||||
specifier: workspace:*
|
||||
version: link:../workflow-runtime
|
||||
'@uncaged/workflow-util-agent':
|
||||
specifier: workspace:*
|
||||
version: link:../workflow-util-agent
|
||||
@@ -26,14 +26,14 @@ function throwHermesSpawnError(error: SpawnCliError): never {
|
||||
|
||||
/** Runs `hermes chat` non-interactively with the Nerve-style argv contract (`-q`, `--yolo`, `--quiet`). */
|
||||
export function createHermesAgent(config: HermesAgentConfig): AgentFn {
|
||||
const validated = validateHermesAgentConfig(config);
|
||||
if (!validated.ok) {
|
||||
throw new Error(validated.error);
|
||||
}
|
||||
|
||||
const timeoutMs = config.timeout;
|
||||
|
||||
return async (ctx) => {
|
||||
const validated = validateHermesAgentConfig(config);
|
||||
if (!validated.ok) {
|
||||
throw new Error(validated.error);
|
||||
}
|
||||
|
||||
const fullPrompt = await buildAgentPrompt(ctx);
|
||||
const args = [
|
||||
"chat",
|
||||
@@ -47,7 +47,7 @@ export function createHermesAgent(config: HermesAgentConfig): AgentFn {
|
||||
if (config.model !== null) {
|
||||
args.push("--model", config.model);
|
||||
}
|
||||
const run = await spawnCli("hermes", args, {
|
||||
const run = await spawnCli(config.command, args, {
|
||||
cwd: null,
|
||||
timeoutMs,
|
||||
});
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export type HermesAgentConfig = {
|
||||
/** Absolute path to the hermes CLI binary. */
|
||||
command: string;
|
||||
model: string | null;
|
||||
timeout: number | null;
|
||||
};
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { isAbsolute } from "node:path";
|
||||
|
||||
import { err, ok, type Result } from "@uncaged/workflow-runtime";
|
||||
|
||||
import type { HermesAgentConfig } from "./types.js";
|
||||
|
||||
export function validateHermesAgentConfig(config: HermesAgentConfig): Result<void, string> {
|
||||
if (!isAbsolute(config.command)) {
|
||||
return err("command must be an absolute path to the hermes CLI binary");
|
||||
}
|
||||
if (config.timeout !== null && config.timeout < 0) {
|
||||
return err("timeout must be null or a non-negative number (milliseconds)");
|
||||
}
|
||||
|
||||
@@ -8,10 +8,12 @@ function makeCtx(userContent: string): AgentContext {
|
||||
start: {
|
||||
role: START,
|
||||
content: userContent,
|
||||
meta: { maxRounds: 10 },
|
||||
meta: {},
|
||||
timestamp: 1,
|
||||
parentState: null,
|
||||
},
|
||||
depth: 0,
|
||||
bundleHash: "TESTHASH00001",
|
||||
steps: [],
|
||||
threadId: "01TEST000000000000000000TR",
|
||||
currentRole: { name: "planner", systemPrompt: "system instructions" },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-agent-llm",
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.3",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
lockfileVersion: '9.0'
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@uncaged/workflow-runtime':
|
||||
specifier: workspace:*
|
||||
version: link:../workflow-runtime
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-cas",
|
||||
"version": "0.1.0",
|
||||
"version": "0.3.3",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "bun test"
|
||||
|
||||
Generated
+75
@@ -0,0 +1,75 @@
|
||||
lockfileVersion: '9.0'
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@uncaged/workflow-protocol':
|
||||
specifier: workspace:*
|
||||
version: link:../workflow-protocol
|
||||
'@uncaged/workflow-util':
|
||||
specifier: workspace:*
|
||||
version: link:../workflow-util
|
||||
xxhashjs:
|
||||
specifier: ^0.2.2
|
||||
version: 0.2.2
|
||||
yaml:
|
||||
specifier: ^2.7.1
|
||||
version: 2.8.4
|
||||
devDependencies:
|
||||
'@types/bun':
|
||||
specifier: latest
|
||||
version: 1.3.13
|
||||
|
||||
packages:
|
||||
|
||||
'@types/bun@1.3.13':
|
||||
resolution: {integrity: sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw==}
|
||||
|
||||
'@types/node@25.6.2':
|
||||
resolution: {integrity: sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw==}
|
||||
|
||||
bun-types@1.3.13:
|
||||
resolution: {integrity: sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA==}
|
||||
|
||||
cuint@0.2.2:
|
||||
resolution: {integrity: sha512-d4ZVpCW31eWwCMe1YT3ur7mUDnTXbgwyzaL320DrcRT45rfjYxkt5QWLrmOJ+/UEAI2+fQgKe/fCjR8l4TpRgw==}
|
||||
|
||||
undici-types@7.19.2:
|
||||
resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==}
|
||||
|
||||
xxhashjs@0.2.2:
|
||||
resolution: {integrity: sha512-AkTuIuVTET12tpsVIQo+ZU6f/qDmKuRUcjaqR+OIvm+aCBsZ95i7UVY5WJ9TMsSaZ0DA2WxoZ4acu0sPH+OKAw==}
|
||||
|
||||
yaml@2.8.4:
|
||||
resolution: {integrity: sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==}
|
||||
engines: {node: '>= 14.6'}
|
||||
hasBin: true
|
||||
|
||||
snapshots:
|
||||
|
||||
'@types/bun@1.3.13':
|
||||
dependencies:
|
||||
bun-types: 1.3.13
|
||||
|
||||
'@types/node@25.6.2':
|
||||
dependencies:
|
||||
undici-types: 7.19.2
|
||||
|
||||
bun-types@1.3.13:
|
||||
dependencies:
|
||||
'@types/node': 25.6.2
|
||||
|
||||
cuint@0.2.2: {}
|
||||
|
||||
undici-types@7.19.2: {}
|
||||
|
||||
xxhashjs@0.2.2:
|
||||
dependencies:
|
||||
cuint: 0.2.2
|
||||
|
||||
yaml@2.8.4: {}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,41 @@
|
||||
import { parse, stringify } from "yaml";
|
||||
|
||||
import type { CasStore, MerkleNode, StepMerklePayload, ThreadMerklePayload } from "./types.js";
|
||||
import type {
|
||||
CasStore,
|
||||
MerkleNode,
|
||||
MerkleNodeType,
|
||||
StepMerklePayload,
|
||||
ThreadMerklePayload,
|
||||
} from "./types.js";
|
||||
|
||||
function requireStringHashArray(value: unknown, notArrayMessage: string): string[] {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new Error(notArrayMessage);
|
||||
}
|
||||
const out: string[] = [];
|
||||
for (const c of value) {
|
||||
if (typeof c !== "string") {
|
||||
throw new Error("merkle: hash entry must be a string");
|
||||
}
|
||||
out.push(c);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function edgeListRaw(rec: Record<string, unknown>, type: MerkleNodeType): unknown {
|
||||
if (type === "content") {
|
||||
return rec.refs !== undefined ? rec.refs : rec.children;
|
||||
}
|
||||
return rec.children;
|
||||
}
|
||||
|
||||
export function serializeMerkleNode(node: MerkleNode): string {
|
||||
if (node.type === "content") {
|
||||
return stringify(
|
||||
{ type: node.type, payload: node.payload, refs: node.children },
|
||||
{ indent: 2 },
|
||||
);
|
||||
}
|
||||
return stringify(
|
||||
{ type: node.type, payload: node.payload, children: node.children },
|
||||
{ indent: 2 },
|
||||
@@ -17,23 +50,18 @@ export function parseMerkleNode(yamlText: string): MerkleNode {
|
||||
const rec = raw as Record<string, unknown>;
|
||||
const type = rec.type;
|
||||
const payload = rec.payload;
|
||||
const children = rec.children;
|
||||
if (type !== "content" && type !== "step" && type !== "thread") {
|
||||
throw new Error("merkle: invalid or missing type");
|
||||
}
|
||||
if (typeof payload !== "string" && (payload === null || typeof payload !== "object")) {
|
||||
throw new Error("merkle: payload must be a string or object");
|
||||
}
|
||||
if (!Array.isArray(children)) {
|
||||
throw new Error("merkle: children must be an array");
|
||||
}
|
||||
const childHashes: string[] = [];
|
||||
for (const c of children) {
|
||||
if (typeof c !== "string") {
|
||||
throw new Error("merkle: child hash must be a string");
|
||||
}
|
||||
childHashes.push(c);
|
||||
}
|
||||
|
||||
const notArrayMsg =
|
||||
type === "content"
|
||||
? "merkle: content node requires refs or children array"
|
||||
: "merkle: children must be an array";
|
||||
const childHashes = requireStringHashArray(edgeListRaw(rec, type), notArrayMsg);
|
||||
return {
|
||||
type,
|
||||
payload: typeof payload === "string" ? payload : (payload as Record<string, unknown>),
|
||||
@@ -85,8 +113,8 @@ export async function putContentMerkleNode(store: CasStore, content: string): Pr
|
||||
/**
|
||||
* Loads a CAS blob and returns the payload string for a `content` node.
|
||||
*
|
||||
* Accepts both the legacy `{type:content, payload, children}` Merkle layout
|
||||
* and the RFC v3 `{type:content, payload, refs}` content node layout.
|
||||
* Accepts both the legacy `{ type:content, payload, children }` Merkle layout
|
||||
* and the RFC-aligned `{ type:content, payload, refs }` content node layout.
|
||||
*/
|
||||
export async function getContentMerklePayload(
|
||||
store: CasStore,
|
||||
|
||||
@@ -18,14 +18,27 @@ 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" &&
|
||||
typeof value.maxRounds === "number" &&
|
||||
typeof value.depth === "number"
|
||||
);
|
||||
}
|
||||
|
||||
/** 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;
|
||||
@@ -42,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" &&
|
||||
@@ -50,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;
|
||||
@@ -87,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 };
|
||||
}
|
||||
|
||||
@@ -144,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));
|
||||
}
|
||||
|
||||
@@ -9,7 +9,10 @@ function refsFromBlob(content: string): string[] {
|
||||
return [];
|
||||
}
|
||||
const rec = raw as Record<string, unknown>;
|
||||
const refs = rec.refs;
|
||||
let refs = rec.refs;
|
||||
if (!Array.isArray(refs) && Array.isArray(rec.children)) {
|
||||
refs = rec.children;
|
||||
}
|
||||
if (!Array.isArray(refs)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
VITE_GATEWAY_URL=https://workflow-gateway.shazhou.workers.dev
|
||||
@@ -9,8 +9,12 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dagrejs/dagre": "^3.0.0",
|
||||
"@xyflow/react": "^12.10.2",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6"
|
||||
"react-dom": "^19.2.6",
|
||||
"react-markdown": "^10.1.0",
|
||||
"shiki": "^4.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.2.4",
|
||||
|
||||
+1668
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,43 @@
|
||||
const BASE = "/api";
|
||||
const GATEWAY_URL = import.meta.env.VITE_GATEWAY_URL || "";
|
||||
|
||||
async function postJson<T>(path: string, body: unknown): Promise<T> {
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
export function getApiKey(): string | null {
|
||||
try {
|
||||
return localStorage.getItem("workflow-api-key");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function setApiKey(key: string): void {
|
||||
localStorage.setItem("workflow-api-key", key);
|
||||
}
|
||||
|
||||
export function clearApiKey(): void {
|
||||
localStorage.removeItem("workflow-api-key");
|
||||
}
|
||||
|
||||
export function hasApiKey(): boolean {
|
||||
return getApiKey() !== null && getApiKey() !== "";
|
||||
}
|
||||
|
||||
function authHeaders(): Record<string, string> {
|
||||
const key = getApiKey();
|
||||
if (key) return { Authorization: `Bearer ${key}` };
|
||||
return {};
|
||||
}
|
||||
|
||||
function agentBase(agent: string): string {
|
||||
if (GATEWAY_URL) {
|
||||
return `${GATEWAY_URL}/api/agents/${agent}`;
|
||||
}
|
||||
// Local dev: proxy via vite, no agent prefix
|
||||
return "/api";
|
||||
}
|
||||
|
||||
async function postJson<T>(base: string, path: string, body: unknown): Promise<T> {
|
||||
const res = await fetch(`${base}${path}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
headers: { "Content-Type": "application/json", ...authHeaders() },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
@@ -13,18 +47,32 @@ async function postJson<T>(path: string, body: unknown): Promise<T> {
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
async function fetchJson<T>(path: string): Promise<T> {
|
||||
const res = await fetch(`${BASE}${path}`);
|
||||
async function fetchJson<T>(base: string, path: string): Promise<T> {
|
||||
const res = await fetch(`${base}${path}`, { headers: authHeaders() });
|
||||
if (!res.ok) {
|
||||
throw new Error(`API ${res.status}: ${path}`);
|
||||
}
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
// ── Endpoint types ──────────────────────────────────────────────────
|
||||
|
||||
export type AgentEndpoint = {
|
||||
name: string;
|
||||
url: string;
|
||||
status: string;
|
||||
lastHeartbeat: number;
|
||||
};
|
||||
|
||||
export type WorkflowSummary = {
|
||||
name: string;
|
||||
currentHash: string;
|
||||
versions: number;
|
||||
hash: string | null;
|
||||
timestamp: number | null;
|
||||
};
|
||||
|
||||
export type WorkflowHistoryEntry = {
|
||||
hash: string;
|
||||
timestamp: number;
|
||||
};
|
||||
|
||||
export type ThreadSummary = {
|
||||
@@ -35,50 +83,119 @@ export type ThreadSummary = {
|
||||
status: string | null;
|
||||
};
|
||||
|
||||
export type ThreadRecord = {
|
||||
type: string;
|
||||
role: string | null;
|
||||
content: string | null;
|
||||
timestamp: number | null;
|
||||
[key: string]: unknown;
|
||||
export type ThreadStartRecord = {
|
||||
type: "thread-start";
|
||||
workflow: string;
|
||||
prompt: string | null;
|
||||
threadId: string;
|
||||
status: string;
|
||||
timestamp: null;
|
||||
};
|
||||
|
||||
export function listWorkflows(): Promise<{ workflows: WorkflowSummary[] }> {
|
||||
return fetchJson("/workflows");
|
||||
export type RoleRecord = {
|
||||
type: "role";
|
||||
role: string;
|
||||
content: string;
|
||||
timestamp: number | null;
|
||||
meta: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type WorkflowResultRecord = {
|
||||
type: "workflow-result";
|
||||
returnCode: number;
|
||||
content: string;
|
||||
timestamp: number | null;
|
||||
};
|
||||
|
||||
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: readonly WorkflowHistoryEntry[];
|
||||
descriptor: WorkflowDescriptor | null;
|
||||
};
|
||||
|
||||
// ── Gateway endpoints ───────────────────────────────────────────────
|
||||
|
||||
export function listAgents(): Promise<AgentEndpoint[]> {
|
||||
const url = GATEWAY_URL || "";
|
||||
return fetchJson(url, "/api/gateway/endpoints");
|
||||
}
|
||||
|
||||
export function listThreads(): Promise<{ threads: ThreadSummary[] }> {
|
||||
return fetchJson("/threads");
|
||||
// ── Agent-scoped endpoints ──────────────────────────────────────────
|
||||
|
||||
export function listWorkflows(agent: string): Promise<{ workflows: WorkflowSummary[] }> {
|
||||
return fetchJson(agentBase(agent), "/workflows");
|
||||
}
|
||||
|
||||
export function listRunningThreads(): Promise<{ threads: ThreadSummary[] }> {
|
||||
return fetchJson("/threads/running");
|
||||
export async function getWorkflowDetail(agent: string, name: string): Promise<WorkflowDetail> {
|
||||
return fetchJson<WorkflowDetail>(agentBase(agent), `/workflows/${encodeURIComponent(name)}`);
|
||||
}
|
||||
|
||||
export function getThread(id: string): Promise<{ records: ThreadRecord[] }> {
|
||||
return fetchJson(`/threads/${id}`);
|
||||
export async function getWorkflowDescriptor(
|
||||
agent: string,
|
||||
name: string,
|
||||
): Promise<WorkflowDescriptor | null> {
|
||||
const res = await getWorkflowDetail(agent, name);
|
||||
return res.descriptor;
|
||||
}
|
||||
|
||||
export function listThreads(agent: string): Promise<{ threads: ThreadSummary[] }> {
|
||||
return fetchJson(agentBase(agent), "/threads");
|
||||
}
|
||||
|
||||
export function listRunningThreads(agent: string): Promise<{ threads: ThreadSummary[] }> {
|
||||
return fetchJson(agentBase(agent), "/threads/running");
|
||||
}
|
||||
|
||||
export function getThread(agent: string, id: string): Promise<{ records: ThreadRecord[] }> {
|
||||
return fetchJson(agentBase(agent), `/threads/${id}`);
|
||||
}
|
||||
|
||||
export function runThread(
|
||||
agent: string,
|
||||
workflow: string,
|
||||
prompt: string,
|
||||
maxRounds: number = 10,
|
||||
): Promise<{ threadId: string }> {
|
||||
return postJson("/threads", { workflow, prompt, maxRounds });
|
||||
return postJson(agentBase(agent), "/threads", { workflow, prompt });
|
||||
}
|
||||
|
||||
export function killThread(threadId: string): Promise<{ ok: boolean }> {
|
||||
return postJson(`/threads/${threadId}/kill`, {});
|
||||
export function killThread(agent: string, threadId: string): Promise<{ ok: boolean }> {
|
||||
return postJson(agentBase(agent), `/threads/${threadId}/kill`, {});
|
||||
}
|
||||
|
||||
export function pauseThread(threadId: string): Promise<{ ok: boolean }> {
|
||||
return postJson(`/threads/${threadId}/pause`, {});
|
||||
export function pauseThread(agent: string, threadId: string): Promise<{ ok: boolean }> {
|
||||
return postJson(agentBase(agent), `/threads/${threadId}/pause`, {});
|
||||
}
|
||||
|
||||
export function resumeThread(threadId: string): Promise<{ ok: boolean }> {
|
||||
return postJson(`/threads/${threadId}/resume`, {});
|
||||
export function resumeThread(agent: string, threadId: string): Promise<{ ok: boolean }> {
|
||||
return postJson(agentBase(agent), `/threads/${threadId}/resume`, {});
|
||||
}
|
||||
|
||||
export function getHealth(): Promise<{ ok: boolean }> {
|
||||
return fetchJson("/healthz");
|
||||
export function getAgentHealth(agent: string): Promise<{ ok: boolean }> {
|
||||
return fetchJson(agentBase(agent), "/healthz");
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { clearApiKey, hasApiKey } from "./api.ts";
|
||||
import { LoginPage } from "./components/login.tsx";
|
||||
import { RunDialog } from "./components/run-dialog.tsx";
|
||||
import { Sidebar } from "./components/sidebar.tsx";
|
||||
import { StatusBar } from "./components/status-bar.tsx";
|
||||
@@ -8,24 +10,48 @@ import { WorkflowList } from "./components/workflow-list.tsx";
|
||||
import { useHashRoute } from "./use-hash-route.ts";
|
||||
|
||||
export function App() {
|
||||
const { view, threadId, setView, setThreadId } = useHashRoute();
|
||||
const [authed, setAuthed] = useState(hasApiKey());
|
||||
const { view, agent, threadId, setView, setAgent, setThreadId } = useHashRoute();
|
||||
const [showRun, setShowRun] = useState(false);
|
||||
|
||||
if (!authed) {
|
||||
return <LoginPage onLogin={() => setAuthed(true)} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen">
|
||||
<Sidebar view={view} onViewChange={setView} />
|
||||
<Sidebar
|
||||
view={view}
|
||||
agent={agent}
|
||||
onViewChange={setView}
|
||||
onAgentChange={setAgent}
|
||||
onLogout={() => {
|
||||
clearApiKey();
|
||||
setAuthed(false);
|
||||
}}
|
||||
/>
|
||||
<main className="flex-1 overflow-hidden flex flex-col">
|
||||
<StatusBar onRun={() => setShowRun(true)} />
|
||||
<StatusBar agent={agent} onRun={() => setShowRun(true)} />
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
{view === "threads" && threadId === null && <ThreadList onSelect={setThreadId} />}
|
||||
{view === "threads" && threadId !== null && (
|
||||
<ThreadDetail threadId={threadId} onBack={() => setThreadId(null)} />
|
||||
{!agent && (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<p style={{ color: "var(--color-text-muted)" }}>
|
||||
Select an agent from the sidebar to get started.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{view === "workflows" && <WorkflowList />}
|
||||
{agent && view === "threads" && threadId === null && (
|
||||
<ThreadList agent={agent} onSelect={setThreadId} />
|
||||
)}
|
||||
{agent && view === "threads" && threadId !== null && (
|
||||
<ThreadDetail agent={agent} threadId={threadId} onBack={() => setThreadId(null)} />
|
||||
)}
|
||||
{agent && view === "workflows" && <WorkflowList agent={agent} />}
|
||||
</div>
|
||||
</main>
|
||||
{showRun && (
|
||||
{showRun && agent && (
|
||||
<RunDialog
|
||||
agent={agent}
|
||||
onClose={() => setShowRun(false)}
|
||||
onCreated={(id) => {
|
||||
setShowRun(false);
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useState } from "react";
|
||||
import { setApiKey } from "../api.ts";
|
||||
|
||||
type Props = {
|
||||
onLogin: () => void;
|
||||
};
|
||||
|
||||
export function LoginPage({ onLogin }: Props) {
|
||||
const [key, setKey] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!key.trim()) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Test the key by hitting the endpoints list
|
||||
const gatewayUrl = import.meta.env.VITE_GATEWAY_URL || "";
|
||||
try {
|
||||
const res = await fetch(`${gatewayUrl}/api/gateway/endpoints`, {
|
||||
headers: { Authorization: `Bearer ${key.trim()}` },
|
||||
});
|
||||
if (res.status === 401) {
|
||||
setError("Invalid API key");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (!res.ok) {
|
||||
setError(`Server error: ${res.status}`);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
setError(`Connection failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setApiKey(key.trim());
|
||||
onLogin();
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="min-h-screen flex items-center justify-center"
|
||||
style={{ background: "var(--color-bg)" }}
|
||||
>
|
||||
<div
|
||||
className="p-8 rounded-lg border w-full max-w-sm"
|
||||
style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }}
|
||||
>
|
||||
<h1 className="text-xl font-bold mb-1" style={{ color: "var(--color-accent)" }}>
|
||||
⚙ Workflow Dashboard
|
||||
</h1>
|
||||
<p className="text-sm mb-6" style={{ color: "var(--color-text-muted)" }}>
|
||||
Enter your API key to continue
|
||||
</p>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<input
|
||||
type="password"
|
||||
value={key}
|
||||
onChange={(e) => setKey(e.target.value)}
|
||||
placeholder="API Key"
|
||||
className="w-full px-3 py-2 rounded border text-sm mb-3 outline-none"
|
||||
style={{
|
||||
background: "var(--color-bg)",
|
||||
borderColor: "var(--color-border)",
|
||||
color: "var(--color-text)",
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
{error && (
|
||||
<p className="text-xs mb-3" style={{ color: "var(--color-error)" }}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !key.trim()}
|
||||
className="w-full px-3 py-2 rounded text-sm font-medium"
|
||||
style={{
|
||||
background: "var(--color-accent)",
|
||||
color: "var(--color-bg)",
|
||||
opacity: loading || !key.trim() ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
{loading ? "Verifying..." : "Login"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import {
|
||||
type BundledLanguage,
|
||||
type BundledTheme,
|
||||
createHighlighter,
|
||||
type HighlighterGeneric,
|
||||
} from "shiki";
|
||||
|
||||
let highlighterPromise: Promise<HighlighterGeneric<BundledLanguage, BundledTheme>> | null = null;
|
||||
|
||||
const LANGS: BundledLanguage[] = [
|
||||
"typescript",
|
||||
"javascript",
|
||||
"json",
|
||||
"yaml",
|
||||
"bash",
|
||||
"python",
|
||||
"markdown",
|
||||
];
|
||||
|
||||
function getHighlighter(): Promise<HighlighterGeneric<BundledLanguage, BundledTheme>> {
|
||||
if (highlighterPromise === null) {
|
||||
highlighterPromise = createHighlighter({
|
||||
themes: ["github-dark"],
|
||||
langs: LANGS,
|
||||
});
|
||||
}
|
||||
return highlighterPromise;
|
||||
}
|
||||
|
||||
function CodeBlock({ className, children }: { className?: string; children?: React.ReactNode }) {
|
||||
const [html, setHtml] = useState<string | null>(null);
|
||||
const code = String(children).replace(/\n$/, "");
|
||||
const lang = className?.replace("language-", "") ?? "text";
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
getHighlighter().then((hl) => {
|
||||
if (cancelled) return;
|
||||
try {
|
||||
const result = hl.codeToHtml(code, { lang, theme: "github-dark" });
|
||||
setHtml(result);
|
||||
} catch {
|
||||
setHtml(null);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [code, lang]);
|
||||
|
||||
if (html !== null) {
|
||||
return (
|
||||
<div
|
||||
className="rounded overflow-x-auto text-xs my-2"
|
||||
// biome-ignore lint/security/noDangerouslySetInnerHtml: shiki output is safe
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<pre
|
||||
className="rounded overflow-x-auto text-xs my-2 p-3"
|
||||
style={{ background: "var(--color-bg)" }}
|
||||
>
|
||||
<code>{code}</code>
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
export function Markdown({ content }: { content: string }) {
|
||||
return (
|
||||
<div className="prose prose-invert prose-sm max-w-none">
|
||||
<ReactMarkdown
|
||||
components={{
|
||||
code({ className, children, ...props }) {
|
||||
const isInline = !className;
|
||||
if (isInline) {
|
||||
return (
|
||||
<code
|
||||
className="text-xs px-1 py-0.5 rounded"
|
||||
style={{ background: "var(--color-border)", color: "var(--color-accent)" }}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
return <CodeBlock className={className}>{children}</CodeBlock>;
|
||||
},
|
||||
p({ children }) {
|
||||
return <p className="my-1.5 leading-relaxed">{children}</p>;
|
||||
},
|
||||
ul({ children }) {
|
||||
return <ul className="list-disc pl-4 my-1.5">{children}</ul>;
|
||||
},
|
||||
ol({ children }) {
|
||||
return <ol className="list-decimal pl-4 my-1.5">{children}</ol>;
|
||||
},
|
||||
h1({ children }) {
|
||||
return <h1 className="text-lg font-bold mt-3 mb-1">{children}</h1>;
|
||||
},
|
||||
h2({ children }) {
|
||||
return <h2 className="text-base font-bold mt-2 mb-1">{children}</h2>;
|
||||
},
|
||||
h3({ children }) {
|
||||
return <h3 className="text-sm font-bold mt-2 mb-1">{children}</h3>;
|
||||
},
|
||||
blockquote({ children }) {
|
||||
return (
|
||||
<blockquote
|
||||
className="border-l-2 pl-3 my-2 text-sm"
|
||||
style={{ borderColor: "var(--color-accent)", color: "var(--color-text-muted)" }}
|
||||
>
|
||||
{children}
|
||||
</blockquote>
|
||||
);
|
||||
},
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import type { RoleRecord, ThreadRecord, ThreadStartRecord, WorkflowResultRecord } from "../api.ts";
|
||||
import { Markdown } from "./markdown.tsx";
|
||||
|
||||
const ROLE_COLORS: Record<string, string> = {
|
||||
preparer: "#8b5cf6",
|
||||
agent: "#3b82f6",
|
||||
extractor: "#f59e0b",
|
||||
};
|
||||
|
||||
function roleColor(role: string): string {
|
||||
return ROLE_COLORS[role] ?? "var(--color-accent)";
|
||||
}
|
||||
|
||||
function formatTime(ts: number | null): string | null {
|
||||
if (ts === null) return null;
|
||||
return new Date(ts).toLocaleTimeString();
|
||||
}
|
||||
|
||||
function StartCard({ record }: { record: ThreadStartRecord }) {
|
||||
return (
|
||||
<div
|
||||
className="p-4 rounded-lg border"
|
||||
style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-lg">🚀</span>
|
||||
<span className="font-semibold" style={{ color: "var(--color-accent)" }}>
|
||||
{record.workflow}
|
||||
</span>
|
||||
<span
|
||||
className="text-xs px-2 py-0.5 rounded"
|
||||
style={{
|
||||
background: record.status === "active" ? "var(--color-success)" : "var(--color-border)",
|
||||
color: record.status === "active" ? "var(--color-bg)" : "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
{record.status}
|
||||
</span>
|
||||
</div>
|
||||
{record.prompt !== null && (
|
||||
<div
|
||||
className="mt-2 p-3 rounded text-sm border-l-2"
|
||||
style={{
|
||||
background: "var(--color-bg)",
|
||||
borderColor: "var(--color-accent)",
|
||||
color: "var(--color-text)",
|
||||
}}
|
||||
>
|
||||
<div className="text-xs mb-1" style={{ color: "var(--color-text-muted)" }}>
|
||||
Prompt
|
||||
</div>
|
||||
<Markdown content={record.prompt} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RoleMessage({ record, highlighted }: { record: RoleRecord; highlighted: boolean }) {
|
||||
const color = roleColor(record.role);
|
||||
return (
|
||||
<div
|
||||
className={`p-3 rounded-lg border text-sm ${highlighted ? "wf-record-card-highlight" : ""}`}
|
||||
style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span
|
||||
className="text-xs px-2 py-0.5 rounded font-mono font-medium"
|
||||
style={{ background: color, color: "#fff" }}
|
||||
>
|
||||
{record.role}
|
||||
</span>
|
||||
{formatTime(record.timestamp) !== null && (
|
||||
<span className="text-xs ml-auto" style={{ color: "var(--color-text-muted)" }}>
|
||||
{formatTime(record.timestamp)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Markdown content={record.content} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ResultCard({ record }: { record: WorkflowResultRecord }) {
|
||||
const success = record.returnCode === 0;
|
||||
return (
|
||||
<div
|
||||
className="p-4 rounded-lg border"
|
||||
style={{
|
||||
background: "var(--color-surface)",
|
||||
borderColor: success ? "var(--color-success)" : "var(--color-error)",
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-lg">{success ? "✅" : "❌"}</span>
|
||||
<span className="font-semibold text-sm">{success ? "Completed" : "Failed"}</span>
|
||||
<span
|
||||
className="text-xs px-2 py-0.5 rounded font-mono"
|
||||
style={{
|
||||
background: success ? "var(--color-success)" : "var(--color-error)",
|
||||
color: "#fff",
|
||||
}}
|
||||
>
|
||||
exit {record.returnCode}
|
||||
</span>
|
||||
{formatTime(record.timestamp) !== null && (
|
||||
<span className="text-xs ml-auto" style={{ color: "var(--color-text-muted)" }}>
|
||||
{formatTime(record.timestamp)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Markdown content={record.content} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type RecordCardProps = {
|
||||
record: ThreadRecord;
|
||||
highlighted: boolean;
|
||||
};
|
||||
|
||||
export function RecordCard({ record, highlighted }: RecordCardProps) {
|
||||
switch (record.type) {
|
||||
case "thread-start":
|
||||
return <StartCard record={record} />;
|
||||
case "role":
|
||||
return <RoleMessage record={record} highlighted={highlighted} />;
|
||||
case "workflow-result":
|
||||
return <ResultCard record={record} />;
|
||||
}
|
||||
}
|
||||
@@ -3,15 +3,15 @@ import { listWorkflows, runThread } from "../api.ts";
|
||||
import { useFetch } from "../hooks.ts";
|
||||
|
||||
type Props = {
|
||||
agent: string;
|
||||
onClose: () => void;
|
||||
onCreated: (threadId: string) => void;
|
||||
};
|
||||
|
||||
export function RunDialog({ onClose, onCreated }: Props) {
|
||||
const workflows = useFetch(() => listWorkflows(), []);
|
||||
export function RunDialog({ agent, onClose, onCreated }: Props) {
|
||||
const workflows = useFetch(() => listWorkflows(agent), [agent]);
|
||||
const [workflow, setWorkflow] = useState("");
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [maxRounds, setMaxRounds] = useState(10);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -21,7 +21,7 @@ export function RunDialog({ onClose, onCreated }: Props) {
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await runThread(workflow, prompt, maxRounds);
|
||||
const result = await runThread(agent, workflow, prompt);
|
||||
onCreated(result.threadId);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
@@ -38,7 +38,7 @@ export function RunDialog({ onClose, onCreated }: Props) {
|
||||
className="w-full max-w-lg p-6 rounded-lg border"
|
||||
style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }}
|
||||
>
|
||||
<h3 className="text-lg font-semibold mb-4">Run Thread</h3>
|
||||
<h3 className="text-lg font-semibold mb-4">Run Thread on {agent}</h3>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
@@ -90,29 +90,6 @@ export function RunDialog({ onClose, onCreated }: Props) {
|
||||
placeholder="Enter the task prompt..."
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="run-max-rounds"
|
||||
className="text-sm block mb-1"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
Max Rounds
|
||||
</label>
|
||||
<input
|
||||
id="run-max-rounds"
|
||||
type="number"
|
||||
value={maxRounds}
|
||||
onChange={(e) => setMaxRounds(Number(e.target.value))}
|
||||
min={1}
|
||||
max={100}
|
||||
className="w-24 px-3 py-2 rounded border text-sm"
|
||||
style={{
|
||||
background: "var(--color-bg)",
|
||||
borderColor: "var(--color-border)",
|
||||
color: "var(--color-text)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="text-sm" style={{ color: "var(--color-error)" }}>
|
||||
{error}
|
||||
|
||||
@@ -1,10 +1,29 @@
|
||||
import { useEffect } from "react";
|
||||
import type { AgentEndpoint } from "../api.ts";
|
||||
import { listAgents } from "../api.ts";
|
||||
import { useFetch } from "../hooks.ts";
|
||||
|
||||
type Props = {
|
||||
view: "threads" | "workflows";
|
||||
agent: string | null;
|
||||
onViewChange: (v: "threads" | "workflows") => void;
|
||||
onAgentChange: (a: string | null) => void;
|
||||
onLogout: () => void;
|
||||
};
|
||||
|
||||
export function Sidebar({ view, onViewChange }: Props) {
|
||||
const items = [
|
||||
export function Sidebar({ view, agent, onViewChange, onAgentChange, onLogout }: Props) {
|
||||
const { status, data } = useFetch(() => listAgents(), []);
|
||||
|
||||
const agents: AgentEndpoint[] = status === "ok" ? data : [];
|
||||
|
||||
// Auto-select first agent when none is selected
|
||||
useEffect(() => {
|
||||
if (agent === null && agents.length > 0) {
|
||||
onAgentChange(agents[0].name);
|
||||
}
|
||||
}, [agent, agents, onAgentChange]);
|
||||
|
||||
const viewItems = [
|
||||
{ key: "threads" as const, label: "Threads", icon: "⚡" },
|
||||
{ key: "workflows" as const, label: "Workflows", icon: "📦" },
|
||||
];
|
||||
@@ -22,8 +41,45 @@ export function Sidebar({ view, onViewChange }: Props) {
|
||||
Dashboard
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Agent selector */}
|
||||
<div className="px-4 py-3 border-b" style={{ borderColor: "var(--color-border)" }}>
|
||||
<label
|
||||
className="block text-xs font-medium mb-1"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
htmlFor="agent-select"
|
||||
>
|
||||
Agent
|
||||
</label>
|
||||
<select
|
||||
id="agent-select"
|
||||
className="w-full rounded px-2 py-1.5 text-xs"
|
||||
style={{
|
||||
background: "var(--color-bg)",
|
||||
color: "var(--color-text)",
|
||||
border: "1px solid var(--color-border)",
|
||||
}}
|
||||
value={agent ?? ""}
|
||||
onChange={(e) => onAgentChange(e.target.value || null)}
|
||||
disabled={status === "loading"}
|
||||
>
|
||||
{status === "loading" ? (
|
||||
<option value="">Loading…</option>
|
||||
) : agents.length === 0 ? (
|
||||
<option value="">No agents online</option>
|
||||
) : (
|
||||
agents.map((a) => (
|
||||
<option key={a.name} value={a.name}>
|
||||
{a.status === "online" ? "🟢" : "🔴"} {a.name}
|
||||
</option>
|
||||
))
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* View navigation */}
|
||||
<nav className="flex-1 p-2 space-y-1">
|
||||
{items.map((item) => (
|
||||
{viewItems.map((item) => (
|
||||
<button
|
||||
type="button"
|
||||
key={item.key}
|
||||
@@ -38,6 +94,17 @@ export function Sidebar({ view, onViewChange }: Props) {
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="p-2 border-t" style={{ borderColor: "var(--color-border)" }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onLogout}
|
||||
className="w-full text-left px-3 py-2 rounded text-xs transition-colors"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
🚪 Logout
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { getHealth } from "../api.ts";
|
||||
import { getAgentHealth } from "../api.ts";
|
||||
|
||||
type HealthStatus = "connected" | "disconnected" | "reconnecting";
|
||||
|
||||
type Props = {
|
||||
agent: string | null;
|
||||
onRun: () => void;
|
||||
};
|
||||
|
||||
@@ -17,13 +18,17 @@ function statusLabel(status: HealthStatus): { text: string; color: string } {
|
||||
return { text: "● Offline", color: "var(--color-error)" };
|
||||
}
|
||||
|
||||
export function StatusBar({ onRun }: Props) {
|
||||
export function StatusBar({ agent, onRun }: Props) {
|
||||
const [status, setStatus] = useState<HealthStatus>("disconnected");
|
||||
const wasConnectedRef = useRef(false);
|
||||
|
||||
const checkHealth = useCallback(async () => {
|
||||
if (!agent) {
|
||||
setStatus("disconnected");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await getHealth();
|
||||
await getAgentHealth(agent);
|
||||
wasConnectedRef.current = true;
|
||||
setStatus("connected");
|
||||
} catch {
|
||||
@@ -33,9 +38,11 @@ export function StatusBar({ onRun }: Props) {
|
||||
setStatus("disconnected");
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
}, [agent]);
|
||||
|
||||
useEffect(() => {
|
||||
wasConnectedRef.current = false;
|
||||
setStatus("disconnected");
|
||||
checkHealth();
|
||||
const interval = setInterval(checkHealth, 10_000);
|
||||
return () => clearInterval(interval);
|
||||
@@ -49,12 +56,19 @@ export function StatusBar({ onRun }: Props) {
|
||||
style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<span style={{ color: "var(--color-text-muted)" }}>Local API: 127.0.0.1:7860</span>
|
||||
<span style={{ color: "var(--color-text-muted)" }}>
|
||||
{agent ? `Agent: ${agent}` : "No agent selected"}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRun}
|
||||
disabled={!agent}
|
||||
className="px-3 py-1 rounded text-xs font-medium"
|
||||
style={{ background: "var(--color-accent)", color: "#fff" }}
|
||||
style={{
|
||||
background: agent ? "var(--color-accent)" : "var(--color-border)",
|
||||
color: "#fff",
|
||||
opacity: agent ? 1 : 0.5,
|
||||
}}
|
||||
>
|
||||
▶ Run Thread
|
||||
</button>
|
||||
|
||||
@@ -1,18 +1,112 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { getThread, killThread, pauseThread, resumeThread } from "../api.ts";
|
||||
import { useCallback, 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;
|
||||
threadId: string;
|
||||
onBack: () => void;
|
||||
};
|
||||
|
||||
export function ThreadDetail({ threadId, onBack }: Props) {
|
||||
const sse = useSSE(threadId);
|
||||
const { status, data, error } = useFetch(() => getThread(threadId), [threadId]);
|
||||
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>;
|
||||
onNodeClick: ((roleName: string) => void) | null;
|
||||
};
|
||||
|
||||
function GraphPanel({ descriptor, workflowName, nodeStates, onNodeClick }: 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}
|
||||
onNodeClick={onNodeClick}
|
||||
/>
|
||||
</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]);
|
||||
const [actionStatus, setActionStatus] = useState<string | null>(null);
|
||||
const recordsEndRef = useRef<HTMLDivElement>(null);
|
||||
const firstCardByRoleRef = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||
const highlightTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [highlightedRole, setHighlightedRole] = useState<string | null>(null);
|
||||
|
||||
const liveActive = sse.connected && !sse.completed;
|
||||
const records = liveActive
|
||||
@@ -21,6 +115,46 @@ export function ThreadDetail({ 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]);
|
||||
|
||||
const firstIndexByRole = useMemo(() => {
|
||||
const m = new Map<string, number>();
|
||||
for (let i = 0; i < records.length; i++) {
|
||||
const r = records[i];
|
||||
if (r.type === "role" && !m.has(r.role)) {
|
||||
m.set(r.role, i);
|
||||
}
|
||||
}
|
||||
return m;
|
||||
}, [records]);
|
||||
|
||||
const handleGraphNodeClick = useCallback((roleName: string) => {
|
||||
const el = firstCardByRoleRef.current.get(roleName);
|
||||
if (el == null) return;
|
||||
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
if (highlightTimerRef.current !== null) clearTimeout(highlightTimerRef.current);
|
||||
setHighlightedRole(roleName);
|
||||
highlightTimerRef.current = setTimeout(() => {
|
||||
setHighlightedRole(null);
|
||||
highlightTimerRef.current = null;
|
||||
}, 1500);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (highlightTimerRef.current !== null) clearTimeout(highlightTimerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: scroll when the rendered record list grows
|
||||
useEffect(() => {
|
||||
recordsEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
@@ -30,7 +164,7 @@ export function ThreadDetail({ threadId, onBack }: Props) {
|
||||
setActionStatus(`${action}ing...`);
|
||||
try {
|
||||
const fn = action === "kill" ? killThread : action === "pause" ? pauseThread : resumeThread;
|
||||
await fn(threadId);
|
||||
await fn(agent, threadId);
|
||||
setActionStatus(`${action} sent ✓`);
|
||||
} catch (e) {
|
||||
setActionStatus(`${action} failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||
@@ -78,7 +212,7 @@ export function ThreadDetail({ threadId, onBack }: Props) {
|
||||
|
||||
<h2 className="text-xl font-semibold mb-2 font-mono flex items-center gap-2 flex-wrap">
|
||||
<span>{threadId}</span>
|
||||
{sse.connected && (
|
||||
{sse.connected && !sse.completed && (
|
||||
<span
|
||||
className="text-xs font-medium px-2 py-0.5 rounded"
|
||||
style={{ background: "var(--color-success)", color: "var(--color-bg)" }}
|
||||
@@ -93,6 +227,15 @@ export function ThreadDetail({ threadId, onBack }: Props) {
|
||||
</p>
|
||||
)}
|
||||
|
||||
{descriptor !== null && descriptor.graph.edges.length > 0 && (
|
||||
<GraphPanel
|
||||
descriptor={descriptor}
|
||||
workflowName={workflowName}
|
||||
nodeStates={nodeStates}
|
||||
onNodeClick={handleGraphNodeClick}
|
||||
/>
|
||||
)}
|
||||
|
||||
{status === "loading" && !liveActive && records.length === 0 && (
|
||||
<p style={{ color: "var(--color-text-muted)" }}>Loading...</p>
|
||||
)}
|
||||
@@ -101,40 +244,26 @@ export function ThreadDetail({ threadId, onBack }: Props) {
|
||||
)}
|
||||
{(status === "ok" || liveActive || records.length > 0) && (
|
||||
<div className="space-y-3">
|
||||
{records.map((r) => (
|
||||
<div
|
||||
key={`${threadId}-${r.type}-${String(r.timestamp)}-${r.role ?? ""}-${r.content ?? ""}`}
|
||||
className="p-3 rounded border text-sm"
|
||||
style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span
|
||||
className="text-xs px-1.5 py-0.5 rounded font-mono"
|
||||
style={{ background: "var(--color-border)", color: "var(--color-accent)" }}
|
||||
{records.map((r, i) => {
|
||||
const key = `${threadId}-${i}`;
|
||||
if (r.type === "role") {
|
||||
const isFirstForRole = firstIndexByRole.get(r.role) === i;
|
||||
const flash = highlightedRole === r.role;
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
ref={(el) => {
|
||||
if (!isFirstForRole) return;
|
||||
if (el !== null) firstCardByRoleRef.current.set(r.role, el);
|
||||
else firstCardByRoleRef.current.delete(r.role);
|
||||
}}
|
||||
>
|
||||
{r.type}
|
||||
</span>
|
||||
{r.role && (
|
||||
<span className="text-xs" style={{ color: "var(--color-text-muted)" }}>
|
||||
{r.role}
|
||||
</span>
|
||||
)}
|
||||
{r.timestamp !== null && (
|
||||
<span className="text-xs ml-auto" style={{ color: "var(--color-text-muted)" }}>
|
||||
{new Date(r.timestamp).toLocaleTimeString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{r.content && (
|
||||
<pre
|
||||
className="whitespace-pre-wrap text-xs mt-1"
|
||||
style={{ color: "var(--color-text)" }}
|
||||
>
|
||||
{typeof r.content === "string" ? r.content : JSON.stringify(r.content, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<RecordCard record={r} highlighted={flash} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <RecordCard key={key} record={r} highlighted={false} />;
|
||||
})}
|
||||
<div ref={recordsEndRef} aria-hidden />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -2,17 +2,23 @@ import { listThreads } from "../api.ts";
|
||||
import { useFetch } from "../hooks.ts";
|
||||
|
||||
type Props = {
|
||||
agent: string;
|
||||
onSelect: (id: string) => void;
|
||||
};
|
||||
|
||||
export function ThreadList({ onSelect }: Props) {
|
||||
const { status, data, error } = useFetch(() => listThreads(), []);
|
||||
export function ThreadList({ agent, onSelect }: Props) {
|
||||
const { status, data, error } = useFetch(() => listThreads(agent), [agent]);
|
||||
|
||||
if (status === "loading")
|
||||
return <p style={{ color: "var(--color-text-muted)" }}>Loading threads...</p>;
|
||||
if (status === "error") return <p style={{ color: "var(--color-error)" }}>Error: {error}</p>;
|
||||
|
||||
const threads = data.threads;
|
||||
const threads = [...data.threads].sort((a, b) => {
|
||||
if (!a.startedAt && !b.startedAt) return 0;
|
||||
if (!a.startedAt) return 1;
|
||||
if (!b.startedAt) return -1;
|
||||
return b.startedAt.localeCompare(a.startedAt);
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -38,11 +44,11 @@ export function ThreadList({ onSelect }: Props) {
|
||||
className="text-xs px-2 py-0.5 rounded"
|
||||
style={{
|
||||
background:
|
||||
t.status === "running"
|
||||
t.status === "completed"
|
||||
? "var(--color-success)"
|
||||
: t.status === "failed"
|
||||
? "var(--color-error)"
|
||||
: "var(--color-text-muted)",
|
||||
: "var(--color-accent)",
|
||||
color: "#000",
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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 cursor-pointer ${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,79 @@
|
||||
import {
|
||||
Background,
|
||||
type EdgeTypes,
|
||||
MarkerType,
|
||||
type Node,
|
||||
type NodeTypes,
|
||||
type OnNodeClick,
|
||||
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>;
|
||||
onNodeClick: ((roleName: string) => void) | null;
|
||||
};
|
||||
|
||||
const nodeTypes: NodeTypes = {
|
||||
role: RoleNode,
|
||||
terminal: TerminalNode,
|
||||
};
|
||||
|
||||
const edgeTypes: EdgeTypes = {
|
||||
condition: ConditionEdge,
|
||||
};
|
||||
|
||||
function handleRoleNodeClick(onRoleClick: (roleName: string) => void, node: Node): void {
|
||||
if (node.type !== "role") return;
|
||||
onRoleClick(node.id);
|
||||
}
|
||||
|
||||
export function WorkflowGraph({ graph, roles, nodeStates, onNodeClick }: Props) {
|
||||
const layout = useLayout({ edges: graph.edges, roles, nodeStates });
|
||||
|
||||
const onNodeClickHandler: OnNodeClick | undefined =
|
||||
onNodeClick !== null ? (_e, node) => handleRoleNodeClick(onNodeClick, node) : undefined;
|
||||
|
||||
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}
|
||||
onNodeClick={onNodeClickHandler}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,168 @@
|
||||
import { listWorkflows } from "../api.ts";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import type { WorkflowDetail } from "../api.ts";
|
||||
import { getWorkflowDetail, listWorkflows } from "../api.ts";
|
||||
import { useFetch } from "../hooks.ts";
|
||||
import { type NodeState, WorkflowGraph } from "./workflow-graph/index.ts";
|
||||
|
||||
export function WorkflowList() {
|
||||
const { status, data, error } = useFetch(() => listWorkflows(), []);
|
||||
type Props = {
|
||||
agent: string;
|
||||
};
|
||||
|
||||
type DetailCacheEntry =
|
||||
| { status: "loading" }
|
||||
| { status: "error"; message: string }
|
||||
| { status: "ok"; detail: WorkflowDetail };
|
||||
|
||||
function versionCount(detail: WorkflowDetail): number {
|
||||
return detail.history.length + 1;
|
||||
}
|
||||
|
||||
function ExpandedWorkflowBody({
|
||||
cacheEntry,
|
||||
staticNodeStates,
|
||||
}: {
|
||||
cacheEntry: DetailCacheEntry | undefined;
|
||||
staticNodeStates: Map<string, NodeState>;
|
||||
}) {
|
||||
if (cacheEntry === undefined || cacheEntry.status === "loading") {
|
||||
return (
|
||||
<p className="text-sm py-2" style={{ color: "var(--color-text-muted)" }}>
|
||||
Loading workflow details...
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (cacheEntry.status === "error") {
|
||||
return (
|
||||
<p className="text-sm py-2" style={{ color: "var(--color-error)" }}>
|
||||
{cacheEntry.message}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const { detail } = cacheEntry;
|
||||
const descriptor = detail.descriptor;
|
||||
const edgeCount = descriptor !== null ? descriptor.graph.edges.length : 0;
|
||||
const vc = versionCount(detail);
|
||||
|
||||
return (
|
||||
<div className="pt-3 space-y-3 border-t" style={{ borderColor: "var(--color-border)" }}>
|
||||
<div>
|
||||
<p className="text-sm font-medium" style={{ color: "var(--color-text)" }}>
|
||||
{detail.name}
|
||||
</p>
|
||||
<p className="text-xs mt-1 mb-1" style={{ color: "var(--color-text-muted)" }}>
|
||||
Hash
|
||||
</p>
|
||||
<code className="text-xs font-mono block" style={{ color: "var(--color-accent)" }}>
|
||||
{detail.hash}
|
||||
</code>
|
||||
</div>
|
||||
<p className="text-xs" style={{ color: "var(--color-text-muted)" }}>
|
||||
{vc} version{vc !== 1 ? "s" : ""}
|
||||
</p>
|
||||
<div>
|
||||
<p className="text-xs mb-1 font-medium" style={{ color: "var(--color-text-muted)" }}>
|
||||
Description
|
||||
</p>
|
||||
<p className="text-sm whitespace-pre-wrap" style={{ color: "var(--color-text)" }}>
|
||||
{descriptor !== null && descriptor.description !== ""
|
||||
? descriptor.description
|
||||
: descriptor !== null
|
||||
? "—"
|
||||
: "No descriptor available for this workflow version."}
|
||||
</p>
|
||||
</div>
|
||||
{descriptor !== null && edgeCount > 0 ? (
|
||||
<div
|
||||
className="rounded-lg border overflow-hidden"
|
||||
style={{ borderColor: "var(--color-border)", background: "var(--color-bg)" }}
|
||||
>
|
||||
<div
|
||||
className="px-3 py-2 text-xs flex justify-between items-center"
|
||||
style={{ color: "var(--color-text-muted)", background: "var(--color-surface)" }}
|
||||
>
|
||||
<span className="font-mono">Workflow graph</span>
|
||||
<span>
|
||||
{edgeCount} edge{edgeCount === 1 ? "" : "s"}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ height: 300, width: "100%" }}>
|
||||
<WorkflowGraph
|
||||
graph={descriptor.graph}
|
||||
roles={descriptor.roles}
|
||||
nodeStates={staticNodeStates}
|
||||
onNodeClick={null}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkflowList({ agent }: Props) {
|
||||
const { status, data, error } = useFetch(() => listWorkflows(agent), [agent]);
|
||||
const [expanded, setExpanded] = useState<Set<string>>(() => new Set());
|
||||
const [detailsByName, setDetailsByName] = useState<Map<string, DetailCacheEntry>>(
|
||||
() => new Map(),
|
||||
);
|
||||
|
||||
const staticNodeStates = useMemo(() => new Map<string, NodeState>(), []);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: reset expansion when switching agents
|
||||
useEffect(() => {
|
||||
setExpanded(new Set());
|
||||
setDetailsByName(new Map());
|
||||
}, [agent]);
|
||||
|
||||
const ensureDetailLoaded = useCallback(
|
||||
(name: string) => {
|
||||
setDetailsByName((prev) => {
|
||||
const cur = prev.get(name);
|
||||
if (cur !== undefined && (cur.status === "ok" || cur.status === "loading")) {
|
||||
return prev;
|
||||
}
|
||||
return new Map(prev).set(name, { status: "loading" });
|
||||
});
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const detail = await getWorkflowDetail(agent, name);
|
||||
setDetailsByName((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(name, { status: "ok", detail });
|
||||
return next;
|
||||
});
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
setDetailsByName((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(name, { status: "error", message });
|
||||
return next;
|
||||
});
|
||||
}
|
||||
})();
|
||||
},
|
||||
[agent],
|
||||
);
|
||||
|
||||
function toggleExpanded(name: string) {
|
||||
let shouldLoad = false;
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(name)) {
|
||||
next.delete(name);
|
||||
return next;
|
||||
}
|
||||
next.add(name);
|
||||
shouldLoad = true;
|
||||
return next;
|
||||
});
|
||||
if (shouldLoad) {
|
||||
ensureDetailLoaded(name);
|
||||
}
|
||||
}
|
||||
|
||||
if (status === "loading")
|
||||
return <p style={{ color: "var(--color-text-muted)" }}>Loading workflows...</p>;
|
||||
@@ -17,26 +177,58 @@ export function WorkflowList() {
|
||||
<p style={{ color: "var(--color-text-muted)" }}>No workflows registered.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{workflows.map((w) => (
|
||||
<div
|
||||
key={w.name}
|
||||
className="p-4 rounded-lg border"
|
||||
style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">{w.name}</span>
|
||||
<span className="text-xs" style={{ color: "var(--color-text-muted)" }}>
|
||||
{w.versions} version{w.versions !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
<code
|
||||
className="text-xs mt-1 block font-mono"
|
||||
style={{ color: "var(--color-accent)" }}
|
||||
{workflows.map((w) => {
|
||||
const isOpen = expanded.has(w.name);
|
||||
return (
|
||||
<div
|
||||
key={w.name}
|
||||
className="rounded-lg border overflow-hidden"
|
||||
style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }}
|
||||
>
|
||||
{w.currentHash}
|
||||
</code>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleExpanded(w.name)}
|
||||
className="w-full text-left p-4 flex items-start justify-between gap-3 hover:opacity-90"
|
||||
style={{ color: "var(--color-text)" }}
|
||||
aria-expanded={isOpen}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="text-xs font-mono"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
{isOpen ? "▼" : "▶"}
|
||||
</span>
|
||||
<span className="font-medium">{w.name}</span>
|
||||
</div>
|
||||
<code
|
||||
className="text-xs mt-1 block font-mono truncate"
|
||||
style={{ color: "var(--color-accent)" }}
|
||||
>
|
||||
{w.hash !== null ? w.hash : "—"}
|
||||
</code>
|
||||
{w.timestamp !== null ? (
|
||||
<span
|
||||
className="text-xs mt-1 block"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
Updated {new Date(w.timestamp).toLocaleString()}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</button>
|
||||
{isOpen ? (
|
||||
<div className="px-4 pb-4">
|
||||
<ExpandedWorkflowBody
|
||||
cacheEntry={detailsByName.get(w.name)}
|
||||
staticNodeStates={staticNodeStates}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -19,3 +19,33 @@ 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;
|
||||
}
|
||||
|
||||
@keyframes wf-record-card-highlight {
|
||||
0% {
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
35% {
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
100% {
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
}
|
||||
|
||||
.wf-record-card-highlight {
|
||||
animation: wf-record-card-highlight 1.5s ease-out forwards;
|
||||
}
|
||||
|
||||
@@ -4,37 +4,50 @@ type View = "threads" | "workflows";
|
||||
|
||||
type HashRoute = {
|
||||
view: View;
|
||||
agent: string | null;
|
||||
threadId: string | null;
|
||||
};
|
||||
|
||||
function parseHash(hash: string): HashRoute {
|
||||
const raw = hash.replace(/^#\/?/, "");
|
||||
if (raw.startsWith("threads/")) {
|
||||
const id = raw.slice("threads/".length);
|
||||
if (id.length > 0) {
|
||||
return { view: "threads", threadId: id };
|
||||
}
|
||||
// Format: #agent/threads/id or #agent/workflows or #threads or #workflows
|
||||
const parts = raw.split("/");
|
||||
|
||||
// Check if first part is a known view
|
||||
if (parts[0] === "threads" || parts[0] === "workflows") {
|
||||
return {
|
||||
view: parts[0] as View,
|
||||
agent: null,
|
||||
threadId: parts[0] === "threads" && parts.length > 1 ? parts.slice(1).join("/") : null,
|
||||
};
|
||||
}
|
||||
if (raw === "workflows") {
|
||||
return { view: "workflows", threadId: null };
|
||||
}
|
||||
return { view: "threads", threadId: null };
|
||||
|
||||
// First part is agent name
|
||||
const agent = parts[0] || null;
|
||||
const viewPart = parts[1] ?? "threads";
|
||||
const view: View = viewPart === "workflows" ? "workflows" : "threads";
|
||||
const threadId = view === "threads" && parts.length > 2 ? parts.slice(2).join("/") : null;
|
||||
|
||||
return { view, agent, threadId };
|
||||
}
|
||||
|
||||
function buildHash(route: HashRoute): string {
|
||||
const prefix = route.agent ? `${route.agent}/` : "";
|
||||
if (route.view === "workflows") {
|
||||
return "#workflows";
|
||||
return `#${prefix}workflows`;
|
||||
}
|
||||
if (route.threadId !== null) {
|
||||
return `#threads/${route.threadId}`;
|
||||
return `#${prefix}threads/${route.threadId}`;
|
||||
}
|
||||
return "#threads";
|
||||
return `#${prefix}threads`;
|
||||
}
|
||||
|
||||
export function useHashRoute(): {
|
||||
view: View;
|
||||
agent: string | null;
|
||||
threadId: string | null;
|
||||
setView: (v: View) => void;
|
||||
setAgent: (a: string | null) => void;
|
||||
setThreadId: (id: string | null) => void;
|
||||
} {
|
||||
const [route, setRoute] = useState<HashRoute>(() => parseHash(window.location.hash));
|
||||
@@ -53,12 +66,27 @@ export function useHashRoute(): {
|
||||
setRoute(next);
|
||||
}, []);
|
||||
|
||||
const setView = useCallback((v: View) => navigate({ view: v, threadId: null }), [navigate]);
|
||||
|
||||
const setThreadId = useCallback(
|
||||
(id: string | null) => navigate({ view: "threads", threadId: id }),
|
||||
[navigate],
|
||||
const setView = useCallback(
|
||||
(v: View) => navigate({ view: v, agent: route.agent, threadId: null }),
|
||||
[navigate, route.agent],
|
||||
);
|
||||
|
||||
return { view: route.view, threadId: route.threadId, setView, setThreadId };
|
||||
const setAgent = useCallback(
|
||||
(a: string | null) => navigate({ view: route.view, agent: a, threadId: null }),
|
||||
[navigate, route.view],
|
||||
);
|
||||
|
||||
const setThreadId = useCallback(
|
||||
(id: string | null) => navigate({ view: "threads", agent: route.agent, threadId: id }),
|
||||
[navigate, route.agent],
|
||||
);
|
||||
|
||||
return {
|
||||
view: route.view,
|
||||
agent: route.agent,
|
||||
threadId: route.threadId,
|
||||
setView,
|
||||
setAgent,
|
||||
setThreadId,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from "react";
|
||||
|
||||
import type { ThreadRecord } from "./api.ts";
|
||||
import { getApiKey } from "./api.ts";
|
||||
|
||||
export type UseSSEReturn = {
|
||||
records: ThreadRecord[];
|
||||
@@ -56,7 +57,17 @@ function handleRecordEvent(ev: Event, ctx: RecordEventContext): void {
|
||||
ctx.cleanupEs();
|
||||
}
|
||||
|
||||
export function useSSE(threadId: string | null): UseSSEReturn {
|
||||
function sseUrl(agent: string, threadId: string): string {
|
||||
const gatewayUrl = import.meta.env.VITE_GATEWAY_URL || "";
|
||||
const key = getApiKey();
|
||||
const keyParam = key ? `?key=${encodeURIComponent(key)}` : "";
|
||||
if (gatewayUrl) {
|
||||
return `${gatewayUrl}/api/${agent}/threads/${encodeURIComponent(threadId)}/live${keyParam}`;
|
||||
}
|
||||
return `/api/threads/${encodeURIComponent(threadId)}/live`;
|
||||
}
|
||||
|
||||
export function useSSE(agent: string | null, threadId: string | null): UseSSEReturn {
|
||||
const [records, setRecords] = useState<ThreadRecord[]>([]);
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [completed, setCompleted] = useState(false);
|
||||
@@ -65,7 +76,7 @@ export function useSSE(threadId: string | null): UseSSEReturn {
|
||||
const reconnectAttemptsRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (threadId === null) {
|
||||
if (threadId === null || agent === null) {
|
||||
completedRef.current = false;
|
||||
reconnectAttemptsRef.current = 0;
|
||||
setRecords([]);
|
||||
@@ -75,6 +86,7 @@ export function useSSE(threadId: string | null): UseSSEReturn {
|
||||
}
|
||||
|
||||
const tid = threadId;
|
||||
const agentName = agent;
|
||||
|
||||
completedRef.current = false;
|
||||
reconnectAttemptsRef.current = 0;
|
||||
@@ -113,7 +125,7 @@ export function useSSE(threadId: string | null): UseSSEReturn {
|
||||
}
|
||||
|
||||
cleanupEs();
|
||||
const url = `/api/threads/${encodeURIComponent(tid)}/live`;
|
||||
const url = sseUrl(agentName, tid);
|
||||
es = new EventSource(url);
|
||||
|
||||
es.onopen = () => {
|
||||
@@ -136,6 +148,16 @@ export function useSSE(threadId: string | null): UseSSEReturn {
|
||||
}),
|
||||
);
|
||||
|
||||
es.addEventListener("done", () => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
completedRef.current = true;
|
||||
setCompleted(true);
|
||||
setConnected(false);
|
||||
cleanupEs();
|
||||
});
|
||||
|
||||
es.onerror = () => {
|
||||
if (cancelled || completedRef.current) {
|
||||
return;
|
||||
@@ -155,7 +177,7 @@ export function useSSE(threadId: string | null): UseSSEReturn {
|
||||
}
|
||||
cleanupEs();
|
||||
};
|
||||
}, [threadId]);
|
||||
}, [agent, threadId]);
|
||||
|
||||
return { records, connected, completed };
|
||||
}
|
||||
|
||||
@@ -34,8 +34,8 @@ function noLogger(): (tag: string, content: string) => void {
|
||||
|
||||
function makeOptions(overrides: Partial<ExecuteThreadOptions>): ExecuteThreadOptions {
|
||||
return {
|
||||
maxRounds: 5,
|
||||
depth: 0,
|
||||
parentStateHash: null,
|
||||
signal: new AbortController().signal,
|
||||
awaitAfterEachYield: async () => {},
|
||||
forkSourceThreadId: null,
|
||||
@@ -107,7 +107,7 @@ describe("executeThread (Phase 2 — CAS thread storage)", () => {
|
||||
wf,
|
||||
"demo",
|
||||
{ prompt: "hello", steps: [] },
|
||||
makeOptions({ storageRoot, maxRounds: 5 }),
|
||||
makeOptions({ storageRoot }),
|
||||
io,
|
||||
noLogger(),
|
||||
);
|
||||
@@ -127,7 +127,6 @@ describe("executeThread (Phase 2 — CAS thread storage)", () => {
|
||||
expect(startNode.type).toBe("start");
|
||||
expect((startNode.payload as Record<string, unknown>).name).toBe("demo");
|
||||
expect((startNode.payload as Record<string, unknown>).hash).toBe(bundleHash);
|
||||
expect((startNode.payload as Record<string, unknown>).maxRounds).toBe(5);
|
||||
|
||||
const refs = startNode.refs as string[];
|
||||
expect(refs.length).toBe(1);
|
||||
@@ -146,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" };
|
||||
};
|
||||
|
||||
@@ -164,7 +163,6 @@ describe("executeThread (Phase 2 — CAS thread storage)", () => {
|
||||
|
||||
const opts = makeOptions({
|
||||
storageRoot,
|
||||
maxRounds: 5,
|
||||
awaitAfterEachYield: async () => {
|
||||
const text = await readFile(join(bundleDir, "threads.json"), "utf8");
|
||||
const parsed = JSON.parse(text) as Record<string, { head: string }>;
|
||||
@@ -213,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" };
|
||||
};
|
||||
|
||||
@@ -228,7 +226,7 @@ describe("executeThread (Phase 2 — CAS thread storage)", () => {
|
||||
wf,
|
||||
"demo",
|
||||
{ prompt: "p", steps: [] },
|
||||
makeOptions({ storageRoot, maxRounds: 5 }),
|
||||
makeOptions({ storageRoot }),
|
||||
io,
|
||||
noLogger(),
|
||||
);
|
||||
@@ -264,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" };
|
||||
};
|
||||
|
||||
@@ -279,7 +277,7 @@ describe("executeThread (Phase 2 — CAS thread storage)", () => {
|
||||
wf,
|
||||
"demo",
|
||||
{ prompt: "p", steps: [] },
|
||||
makeOptions({ storageRoot, maxRounds: 5 }),
|
||||
makeOptions({ storageRoot }),
|
||||
io,
|
||||
noLogger(),
|
||||
);
|
||||
|
||||
@@ -2,8 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { createCasStore } from "@uncaged/workflow-cas";
|
||||
import { type ExtractContext, START } from "@uncaged/workflow-runtime";
|
||||
import { createCasStore, putContentNodeWithRefs } from "@uncaged/workflow-cas";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
import { createExtract } from "../src/extract/extract-fn.js";
|
||||
@@ -45,21 +44,9 @@ describe("createExtract — ExtractResult shape", () => {
|
||||
);
|
||||
|
||||
const schema = z.object({ confidence: z.number() });
|
||||
const ctx: ExtractContext = {
|
||||
threadId: "01THREADTESTAAAAAAAAAAAAAA",
|
||||
depth: 0,
|
||||
start: {
|
||||
role: START,
|
||||
content: "task text",
|
||||
meta: { maxRounds: 10 },
|
||||
timestamp: 100,
|
||||
},
|
||||
steps: [],
|
||||
currentRole: { name: "analyst", systemPrompt: "be precise" },
|
||||
agentContent: "model says hello",
|
||||
};
|
||||
const contentHash = await putContentNodeWithRefs(cas, "model says hello", []);
|
||||
|
||||
const out = await extract(schema, "extract fields", ctx);
|
||||
const out = await extract(schema, contentHash);
|
||||
|
||||
expect(out.meta).toEqual({ confidence: 0.9 });
|
||||
expect(out.contentPayload).toBe("model says hello");
|
||||
|
||||
@@ -45,8 +45,8 @@ describe("garbageCollectCas (mark-and-sweep)", () => {
|
||||
{
|
||||
name: "demo",
|
||||
hash: bundleHash,
|
||||
maxRounds: 5,
|
||||
depth: 0,
|
||||
parentState: null,
|
||||
},
|
||||
promptHash,
|
||||
);
|
||||
@@ -60,6 +60,7 @@ describe("garbageCollectCas (mark-and-sweep)", () => {
|
||||
ancestors: [],
|
||||
compact: null,
|
||||
timestamp: 1,
|
||||
childThread: null,
|
||||
} satisfies StateNodePayload);
|
||||
|
||||
const c2 = await putContentNodeWithRefs(cas, "c1", []);
|
||||
@@ -71,6 +72,7 @@ describe("garbageCollectCas (mark-and-sweep)", () => {
|
||||
ancestors: [h1],
|
||||
compact: null,
|
||||
timestamp: 2,
|
||||
childThread: null,
|
||||
} satisfies StateNodePayload);
|
||||
|
||||
const ec = await putContentNodeWithRefs(cas, "", []);
|
||||
@@ -82,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");
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-execute",
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.3",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
|
||||
Generated
+51
@@ -0,0 +1,51 @@
|
||||
lockfileVersion: '9.0'
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@uncaged/workflow-cas':
|
||||
specifier: workspace:*
|
||||
version: link:../workflow-cas
|
||||
'@uncaged/workflow-protocol':
|
||||
specifier: workspace:*
|
||||
version: link:../workflow-protocol
|
||||
'@uncaged/workflow-reactor':
|
||||
specifier: workspace:*
|
||||
version: link:../workflow-reactor
|
||||
'@uncaged/workflow-register':
|
||||
specifier: workspace:*
|
||||
version: link:../workflow-register
|
||||
'@uncaged/workflow-runtime':
|
||||
specifier: workspace:*
|
||||
version: link:../workflow-runtime
|
||||
'@uncaged/workflow-util':
|
||||
specifier: workspace:*
|
||||
version: link:../workflow-util
|
||||
yaml:
|
||||
specifier: ^2.7.1
|
||||
version: 2.8.4
|
||||
devDependencies:
|
||||
zod:
|
||||
specifier: ^4.0.0
|
||||
version: 4.4.3
|
||||
|
||||
packages:
|
||||
|
||||
yaml@2.8.4:
|
||||
resolution: {integrity: sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==}
|
||||
engines: {node: '>= 14.6'}
|
||||
hasBin: true
|
||||
|
||||
zod@4.4.3:
|
||||
resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==}
|
||||
|
||||
snapshots:
|
||||
|
||||
yaml@2.8.4: {}
|
||||
|
||||
zod@4.4.3: {}
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { CasStore } from "@uncaged/workflow-cas";
|
||||
import type { ThreadReactorFn } from "@uncaged/workflow-reactor";
|
||||
import { createLlmFn, createThreadReactor } from "@uncaged/workflow-reactor";
|
||||
import type { LlmProvider } from "@uncaged/workflow-runtime";
|
||||
|
||||
import { extractFunctionToolFromZodSchema } from "./extract/index.js";
|
||||
|
||||
export type CasReactorThread = {
|
||||
cas: CasStore;
|
||||
};
|
||||
|
||||
const CAS_GET_TOOL_DEFINITION = {
|
||||
type: "function" as const,
|
||||
function: {
|
||||
name: "cas_get",
|
||||
description:
|
||||
"Read a Merkle DAG node from content-addressed storage by its hash. Returns YAML-formatted node with type, payload, and refs or children fields (content nodes use refs).",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
hash: { type: "string", description: "The CAS hash to retrieve" },
|
||||
},
|
||||
required: ["hash"],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export type CasReactorOpts = {
|
||||
maxRounds: number;
|
||||
systemPromptForStructuredTool: (structuredToolName: string) => string;
|
||||
};
|
||||
|
||||
export function createCasReactor(
|
||||
provider: LlmProvider,
|
||||
cas: CasStore,
|
||||
opts: CasReactorOpts,
|
||||
): ThreadReactorFn<CasReactorThread> {
|
||||
return createThreadReactor<CasReactorThread>({
|
||||
llm: createLlmFn(provider),
|
||||
maxRounds: opts.maxRounds,
|
||||
staticTools: [CAS_GET_TOOL_DEFINITION],
|
||||
structuredToolFromSchema: (schema) => {
|
||||
const t = extractFunctionToolFromZodSchema(schema);
|
||||
return {
|
||||
name: t.name,
|
||||
tool: {
|
||||
type: "function" as const,
|
||||
function: {
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
parameters: t.parameters,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
systemPromptForStructuredTool: opts.systemPromptForStructuredTool,
|
||||
toolHandler: async (call, _thread) => {
|
||||
if (call.function.name !== "cas_get") {
|
||||
return `Unknown tool: ${call.function.name}`;
|
||||
}
|
||||
let hash: string;
|
||||
try {
|
||||
const ta = JSON.parse(call.function.arguments) as unknown;
|
||||
if (!isRecord(ta) || typeof ta.hash !== "string") {
|
||||
return 'cas_get requires {"hash": "<cas-hash>"}.';
|
||||
}
|
||||
hash = ta.hash;
|
||||
} catch {
|
||||
return "cas_get arguments were not valid JSON.";
|
||||
}
|
||||
const blob = await cas.get(hash);
|
||||
return blob === null ? "null" : blob;
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import { END, START } from "@uncaged/workflow-runtime";
|
||||
import { err, type LogFn, ok, type Result } from "@uncaged/workflow-util";
|
||||
|
||||
import { createExtract } from "../extract/index.js";
|
||||
import { createSummarizer, type SummarizeFn } from "./summarizer.js";
|
||||
import { runSupervisor } from "./supervisor.js";
|
||||
import {
|
||||
appendThreadHistoryEntry,
|
||||
@@ -53,6 +54,7 @@ async function resolveEngineRegistryRuntime(
|
||||
Result<
|
||||
{
|
||||
extract: ReturnType<typeof createExtract>;
|
||||
summarize: SummarizeFn;
|
||||
workflowConfig: WorkflowConfig;
|
||||
},
|
||||
string
|
||||
@@ -76,7 +78,11 @@ async function resolveEngineRegistryRuntime(
|
||||
apiKey: ex.apiKey,
|
||||
model: ex.model,
|
||||
};
|
||||
return ok({ extract: createExtract(llmProvider, { cas }), workflowConfig: cfg });
|
||||
return ok({
|
||||
extract: createExtract(llmProvider, { cas }),
|
||||
summarize: createSummarizer(llmProvider, cas),
|
||||
workflowConfig: cfg,
|
||||
});
|
||||
}
|
||||
|
||||
async function appendStateForStep(params: {
|
||||
@@ -88,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) {
|
||||
@@ -106,6 +113,7 @@ async function appendStateForStep(params: {
|
||||
ancestors,
|
||||
compact: null,
|
||||
timestamp: params.timestamp,
|
||||
childThread: params.childThread,
|
||||
};
|
||||
const stateHash = await putStateNode(params.cas, payload);
|
||||
return {
|
||||
@@ -131,6 +139,7 @@ async function appendEndState(params: {
|
||||
ancestors,
|
||||
compact: null,
|
||||
timestamp: params.timestamp,
|
||||
childThread: null,
|
||||
};
|
||||
return putStateNode(params.cas, payload);
|
||||
}
|
||||
@@ -211,17 +220,17 @@ async function maybeSupervisorHaltsThread(params: {
|
||||
params.logger("K6PW9NYT", `supervisor skipped: ${sup.error}`);
|
||||
return null;
|
||||
}
|
||||
if (sup.value !== "stop") {
|
||||
if (sup.value !== "kill") {
|
||||
return null;
|
||||
}
|
||||
params.logger("M4QX8VHN", `thread ${params.threadId} stopped by supervisor`);
|
||||
params.logger("M4QX8VHN", `thread ${params.threadId} killed by supervisor`);
|
||||
return finalizeThread({
|
||||
cas: params.cas,
|
||||
bundleDir: params.bundleDir,
|
||||
threadId: params.threadId,
|
||||
startHash: params.startHash,
|
||||
chain: params.chain,
|
||||
completion: { returnCode: 0, summary: "completed: supervisor stopped thread" },
|
||||
completion: { returnCode: 1, summary: "killed: supervisor detected pathological behavior" },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -250,6 +259,7 @@ async function driveWorkflowGenerator(params: {
|
||||
bundleDir: string;
|
||||
startHash: string;
|
||||
chain: ChainState;
|
||||
summarize: SummarizeFn;
|
||||
}): Promise<WorkflowResult> {
|
||||
const {
|
||||
fn,
|
||||
@@ -262,6 +272,7 @@ async function driveWorkflowGenerator(params: {
|
||||
cas,
|
||||
bundleDir,
|
||||
startHash,
|
||||
summarize,
|
||||
} = params;
|
||||
let chain: ChainState = params.chain;
|
||||
const gen = fn(thread, runtime);
|
||||
@@ -270,6 +281,10 @@ async function driveWorkflowGenerator(params: {
|
||||
role: s.role,
|
||||
summary: JSON.stringify(s.meta),
|
||||
}));
|
||||
const summarizerSteps: { role: string; contentHash: string }[] = thread.steps.map((s) => ({
|
||||
role: s.role,
|
||||
contentHash: s.contentHash,
|
||||
}));
|
||||
|
||||
while (true) {
|
||||
if (executeOptions.signal.aborted) {
|
||||
@@ -284,32 +299,24 @@ async function driveWorkflowGenerator(params: {
|
||||
});
|
||||
}
|
||||
|
||||
if (written >= executeOptions.maxRounds) {
|
||||
logger("R3CW7YBQ", `thread ${threadId} stopped at maxRounds=${executeOptions.maxRounds}`);
|
||||
return await finalizeThread({
|
||||
cas,
|
||||
bundleDir,
|
||||
threadId,
|
||||
startHash,
|
||||
chain,
|
||||
completion: {
|
||||
returnCode: 0,
|
||||
summary: `completed: reached maxRounds (${executeOptions.maxRounds})`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const iterResult = await gen.next();
|
||||
|
||||
if (iterResult.done) {
|
||||
logger("F3HN8QKP", `thread ${threadId} generator finished`);
|
||||
const rawCompletion = iterResult.value;
|
||||
const llmSummary = await summarize({
|
||||
prompt: thread.start.content,
|
||||
recentSteps: summarizerSteps,
|
||||
fallback: rawCompletion.summary,
|
||||
logger,
|
||||
});
|
||||
return await finalizeThread({
|
||||
cas,
|
||||
bundleDir,
|
||||
threadId,
|
||||
startHash,
|
||||
chain,
|
||||
completion: iterResult.value,
|
||||
completion: { ...rawCompletion, summary: llmSummary },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -325,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 });
|
||||
@@ -335,6 +343,7 @@ async function driveWorkflowGenerator(params: {
|
||||
role: step.role,
|
||||
summary: JSON.stringify(step.meta),
|
||||
});
|
||||
summarizerSteps.push({ role: step.role, contentHash: step.contentHash });
|
||||
|
||||
await Promise.race([
|
||||
executeOptions.awaitAfterEachYield(),
|
||||
@@ -383,7 +392,7 @@ async function driveWorkflowGenerator(params: {
|
||||
* Persistence layout (RFC v3 — CAS-based thread storage):
|
||||
* - Thread chain is written as immutable CAS blobs: a single {@link StartNode}
|
||||
* plus one {@link StateNode} per role step (including a final `__end__`
|
||||
* state on completion / abort / `maxRounds`).
|
||||
* state on completion / abort).
|
||||
* - The active thread head is published in `<bundleDir>/threads.json`; on
|
||||
* completion it is removed and a record is appended to
|
||||
* `<bundleDir>/history/{YYYY-MM-DD}.jsonl`.
|
||||
@@ -433,8 +442,8 @@ export async function executeThread(
|
||||
{
|
||||
name: workflowName,
|
||||
hash: io.hash,
|
||||
maxRounds: options.maxRounds,
|
||||
depth: options.depth,
|
||||
parentState: options.parentStateHash,
|
||||
},
|
||||
promptHash,
|
||||
);
|
||||
@@ -462,6 +471,7 @@ export async function executeThread(
|
||||
meta: row.meta,
|
||||
refs: row.refs,
|
||||
timestamp: row.timestamp,
|
||||
childThread: null,
|
||||
});
|
||||
chain = written.chain;
|
||||
await publishHead({
|
||||
@@ -475,21 +485,6 @@ export async function executeThread(
|
||||
|
||||
const nowMs = Date.now();
|
||||
|
||||
if (options.maxRounds <= 0) {
|
||||
logger("R3CW7YBQ", `thread ${io.threadId} stopped at maxRounds=${options.maxRounds}`);
|
||||
return await finalizeThread({
|
||||
cas: io.cas,
|
||||
bundleDir,
|
||||
threadId: io.threadId,
|
||||
startHash,
|
||||
chain,
|
||||
completion: {
|
||||
returnCode: 0,
|
||||
summary: `completed: reached maxRounds (${options.maxRounds})`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const registryRuntime = await resolveEngineRegistryRuntime(options.storageRoot, io.cas);
|
||||
if (!registryRuntime.ok) {
|
||||
throw new Error(registryRuntime.error);
|
||||
@@ -498,11 +493,13 @@ export async function executeThread(
|
||||
const thread: ThreadContext = {
|
||||
threadId: io.threadId,
|
||||
depth: options.depth,
|
||||
bundleHash: io.hash,
|
||||
start: {
|
||||
role: START,
|
||||
content: input.prompt,
|
||||
meta: { maxRounds: options.maxRounds },
|
||||
meta: {},
|
||||
timestamp: nowMs,
|
||||
parentState: options.parentStateHash,
|
||||
},
|
||||
steps: input.steps.map((out, i) => ({
|
||||
role: out.role,
|
||||
@@ -530,5 +527,6 @@ export async function executeThread(
|
||||
bundleDir,
|
||||
startHash,
|
||||
chain,
|
||||
summarize: registryRuntime.value.summarize,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -104,9 +104,7 @@ async function readPromptText(cas: CasStore, promptHash: string): Promise<Result
|
||||
async function readStartWorkflowIdentity(params: {
|
||||
cas: CasStore;
|
||||
startHash: string;
|
||||
}): Promise<
|
||||
Result<{ workflowName: string; maxRounds: number; depth: number; prompt: string }, string>
|
||||
> {
|
||||
}): Promise<Result<{ workflowName: string; depth: number; prompt: string }, string>> {
|
||||
const yamlText = await params.cas.get(params.startHash);
|
||||
if (yamlText === null) {
|
||||
return err(`start node missing in CAS: ${params.startHash}`);
|
||||
@@ -127,7 +125,6 @@ async function readStartWorkflowIdentity(params: {
|
||||
const p = parsed.node.payload;
|
||||
return ok({
|
||||
workflowName: p.name,
|
||||
maxRounds: p.maxRounds,
|
||||
depth: p.depth,
|
||||
prompt: prompt.value,
|
||||
});
|
||||
@@ -147,6 +144,7 @@ async function payloadToRoleOutput(cas: CasStore, payload: StateNodePayload): Pr
|
||||
contentHash: payload.content,
|
||||
meta: payload.meta,
|
||||
refs,
|
||||
childThread: payload.childThread,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -243,6 +241,7 @@ async function buildForkContinuation(params: {
|
||||
ancestors: ancestorsMarker,
|
||||
compact: null,
|
||||
timestamp: Date.now(),
|
||||
childThread: null,
|
||||
};
|
||||
const markerHash = await putStateNode(cas, markerPayload);
|
||||
|
||||
@@ -317,7 +316,7 @@ export async function prepareCasFork(params: {
|
||||
hash: params.bundleHash,
|
||||
sourceThreadId: params.sourceThreadId,
|
||||
prompt: id.value.prompt,
|
||||
runOptions: { maxRounds: id.value.maxRounds, depth: id.value.depth },
|
||||
runOptions: { depth: id.value.depth },
|
||||
steps,
|
||||
stepTimestamps,
|
||||
forkContinuation: cont.value,
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { CasStore } from "@uncaged/workflow-cas";
|
||||
import type { LlmProvider } from "@uncaged/workflow-runtime";
|
||||
import type { LogFn } from "@uncaged/workflow-util";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
import { createCasReactor } from "../cas-reactor.js";
|
||||
|
||||
/** Max ReAct rounds: 3 cas_get reads + 1 structured output = 4 rounds is sufficient. */
|
||||
const SUMMARIZER_MAX_REACT_ROUNDS = 4;
|
||||
/** Only pass the last N steps; each step is just a role+contentHash reference (~60 chars), not full content. */
|
||||
const SUMMARIZER_RECENT_STEP_LIMIT = 20;
|
||||
|
||||
const summarySchema = z.object({ summary: z.string() }).meta({
|
||||
title: "workflow_summary",
|
||||
description: "A concise summary of the completed workflow's results and outcome.",
|
||||
});
|
||||
|
||||
function buildSummarizerInput(args: {
|
||||
prompt: string;
|
||||
recentSteps: readonly { role: string; contentHash: string }[];
|
||||
}): string {
|
||||
const recent = args.recentSteps.slice(-SUMMARIZER_RECENT_STEP_LIMIT);
|
||||
const stepsBlock = recent
|
||||
.map((s, i) => `${i + 1}. [${s.role}] contentHash: ${s.contentHash}`)
|
||||
.join("\n");
|
||||
return `Original task:\n${args.prompt}\n\nCompleted steps (oldest first):\n${stepsBlock === "" ? "(none)" : stepsBlock}\n\nUse cas_get to read step content if needed. Summarize the workflow outcome concisely.`;
|
||||
}
|
||||
|
||||
export type SummarizeFn = (args: {
|
||||
prompt: string;
|
||||
recentSteps: readonly { role: string; contentHash: string }[];
|
||||
fallback: string;
|
||||
logger: LogFn;
|
||||
}) => Promise<string>;
|
||||
|
||||
export function createSummarizer(provider: LlmProvider, cas: CasStore): SummarizeFn {
|
||||
const reactor = createCasReactor(provider, cas, {
|
||||
maxRounds: SUMMARIZER_MAX_REACT_ROUNDS,
|
||||
systemPromptForStructuredTool: (structuredToolName) =>
|
||||
`You summarize completed workflow threads. You have access to cas_get to read step content by hash. After reviewing the steps, call the ${structuredToolName} tool with a concise summary of the workflow outcome and results. Or reply with only a JSON object such as {"summary":"..."}.`,
|
||||
});
|
||||
|
||||
return async (args) => {
|
||||
const result = await reactor({
|
||||
thread: { cas },
|
||||
input: buildSummarizerInput(args),
|
||||
schema: summarySchema,
|
||||
});
|
||||
if (!result.ok) {
|
||||
args.logger("P2WX7KNR", `summarizer failed: ${result.error}`);
|
||||
return args.fallback;
|
||||
}
|
||||
args.logger("Q5MT3VBF", "summarizer produced workflow summary");
|
||||
return result.value.summary;
|
||||
};
|
||||
}
|
||||
@@ -12,12 +12,12 @@ const SUPERVISOR_MAX_REACT_ROUNDS = 4;
|
||||
|
||||
const supervisorDecisionSchema = z
|
||||
.object({
|
||||
decision: z.enum(["continue", "stop"]),
|
||||
decision: z.enum(["continue", "kill"]),
|
||||
})
|
||||
.meta({
|
||||
title: "supervisor_decision",
|
||||
description:
|
||||
'Workflow supervisor decision. "continue" when the thread is making progress; "stop" when done, looping, or stuck.',
|
||||
'Workflow supervisor decision. "continue" when the thread is making progress or following its normal role sequence; "kill" only when the thread is stuck in an infinite loop, producing no meaningful progress, or has gone off the rails. Normal workflow completion is handled by the moderator — the supervisor should NOT kill a thread just because it looks done.',
|
||||
});
|
||||
|
||||
type SupervisorThreadContext = Record<string, never>;
|
||||
@@ -63,7 +63,7 @@ export async function runSupervisor(
|
||||
};
|
||||
},
|
||||
systemPromptForStructuredTool: (structuredToolName) =>
|
||||
`You supervise a multi-step workflow. Decide whether the thread should keep running or halt. Reply with "continue" when the thread is making progress toward the task, or "stop" when it is finished, looping, or no longer making progress. Call the ${structuredToolName} tool with JSON arguments matching the schema, or reply with only a JSON object such as {"decision":"stop"}.`,
|
||||
`You supervise a multi-step workflow. Your job is to detect pathological situations — NOT to decide when the workflow is "done" (that is the moderator's job). Reply with "continue" when the thread is making progress or following its normal role sequence. Reply with "kill" ONLY when the thread is stuck in an infinite loop, producing repetitive/meaningless output, or has clearly gone off the rails. Call the ${structuredToolName} tool with JSON arguments matching the schema, or reply with only a JSON object such as {"decision":"continue"}.`,
|
||||
toolHandler: async (call) => `Unknown tool: ${call.function.name}`,
|
||||
});
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { CasStore } from "@uncaged/workflow-cas";
|
||||
import type { RoleOutput } from "@uncaged/workflow-runtime";
|
||||
import type { Result } from "@uncaged/workflow-util";
|
||||
|
||||
export type SupervisorDecision = "continue" | "stop";
|
||||
export type SupervisorDecision = "continue" | "kill";
|
||||
|
||||
export type ExecuteThreadIo = {
|
||||
threadId: string;
|
||||
@@ -39,9 +39,10 @@ export type PrefilledDiskStep = {
|
||||
};
|
||||
|
||||
export type ExecuteThreadOptions = {
|
||||
maxRounds: number;
|
||||
/** 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>;
|
||||
@@ -68,7 +69,7 @@ export type CasForkPlan = {
|
||||
hash: string;
|
||||
sourceThreadId: string;
|
||||
prompt: string;
|
||||
runOptions: { maxRounds: number; depth: number };
|
||||
runOptions: { depth: number };
|
||||
steps: RoleOutput[];
|
||||
stepTimestamps: number[];
|
||||
forkContinuation: ForkContinuationOptions;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { unlinkSync } from "node:fs";
|
||||
import { mkdir, unlink, writeFile } from "node:fs/promises";
|
||||
import { createServer, type Socket } from "node:net";
|
||||
import { dirname, join } from "node:path";
|
||||
@@ -31,7 +32,7 @@ type RunCommand = {
|
||||
threadId: string;
|
||||
workflowName: string;
|
||||
prompt: string;
|
||||
options: { maxRounds: number; depth: number };
|
||||
options: { depth: number };
|
||||
steps: RoleOutput[];
|
||||
/** Timestamps aligned with `steps` for replay / fork restore; length must match `steps` when steps are non-empty. */
|
||||
stepTimestamps: number[] | null;
|
||||
@@ -71,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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -184,10 +187,6 @@ function parseRunControlPayload(rec: Record<string, unknown>): RunCommand | null
|
||||
return null;
|
||||
}
|
||||
const optRec = options as Record<string, unknown>;
|
||||
const maxRounds = optRec.maxRounds;
|
||||
if (typeof maxRounds !== "number") {
|
||||
return null;
|
||||
}
|
||||
const depthRaw = optRec.depth;
|
||||
const depth =
|
||||
typeof depthRaw === "number" && Number.isFinite(depthRaw) ? Math.trunc(depthRaw) : 0;
|
||||
@@ -209,7 +208,7 @@ function parseRunControlPayload(rec: Record<string, unknown>): RunCommand | null
|
||||
threadId,
|
||||
workflowName,
|
||||
prompt,
|
||||
options: { maxRounds, depth },
|
||||
options: { depth },
|
||||
steps: parsedSteps.steps,
|
||||
stepTimestamps: parsedSteps.stepTimestamps,
|
||||
forkSourceThreadId,
|
||||
@@ -382,6 +381,23 @@ async function main(): Promise<void> {
|
||||
let activeThreads = 0;
|
||||
let shutdownTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function cleanupAllRunningMarkersSync(): void {
|
||||
for (const threadId of threads.keys()) {
|
||||
try {
|
||||
unlinkSync(join(storageRoot, "logs", hash, `${threadId}.running`));
|
||||
} catch {
|
||||
// ignore missing file or other fs errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const sig of ["SIGINT", "SIGTERM"] as const) {
|
||||
process.on(sig, () => {
|
||||
cleanupAllRunningMarkersSync();
|
||||
process.exit(sig === "SIGINT" ? 130 : 143);
|
||||
});
|
||||
}
|
||||
|
||||
const cas = createCasStore(getGlobalCasDir(storageRoot));
|
||||
|
||||
const workerCtlPath = join(storageRoot, "workers", `${hash}.json`);
|
||||
@@ -483,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,
|
||||
@@ -498,8 +515,8 @@ async function main(): Promise<void> {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
bootLog("Q3MN8YKW", `thread ${threadId} failed: ${message}`);
|
||||
} finally {
|
||||
threads.delete(threadId);
|
||||
await unlink(runningPath).catch(() => {});
|
||||
threads.delete(threadId);
|
||||
bumpDone();
|
||||
socket?.end();
|
||||
}
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
import { type CasStore, getContentMerklePayload } from "@uncaged/workflow-cas";
|
||||
import { createLlmFn, createThreadReactor } from "@uncaged/workflow-reactor";
|
||||
import type {
|
||||
ExtractContext,
|
||||
ExtractFn,
|
||||
ExtractResult,
|
||||
LlmProvider,
|
||||
} from "@uncaged/workflow-runtime";
|
||||
import type { ExtractFn, ExtractResult, LlmProvider } from "@uncaged/workflow-runtime";
|
||||
import type * as z from "zod/v4";
|
||||
import { extractFunctionToolFromZodSchema } from "./llm-extract.js";
|
||||
|
||||
import { createCasReactor } from "../cas-reactor.js";
|
||||
|
||||
export type ExtractDeps = {
|
||||
cas: CasStore;
|
||||
@@ -15,65 +10,6 @@ export type ExtractDeps = {
|
||||
|
||||
const MAX_REACT_ROUNDS = 10;
|
||||
|
||||
const CAS_GET_TOOL_DEFINITION = {
|
||||
type: "function" as const,
|
||||
function: {
|
||||
name: "cas_get",
|
||||
description:
|
||||
"Read a Merkle DAG node from content-addressed storage by its hash. Returns YAML-formatted node with type, payload, and children fields.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
hash: { type: "string", description: "The CAS hash to retrieve" },
|
||||
},
|
||||
required: ["hash"],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export type ExtractThreadContext = {
|
||||
cas: CasStore;
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/** Builds the user-side extraction prompt (thread + agent output + instruction). */
|
||||
export async function buildExtractUserContent(
|
||||
ctx: ExtractContext,
|
||||
prompt: string,
|
||||
deps: ExtractDeps,
|
||||
): Promise<string> {
|
||||
const lines: string[] = [];
|
||||
lines.push(`## Role: ${ctx.currentRole.name}`);
|
||||
lines.push(ctx.currentRole.systemPrompt);
|
||||
lines.push("");
|
||||
lines.push("## Task");
|
||||
lines.push(ctx.start.content);
|
||||
lines.push("");
|
||||
if (ctx.steps.length > 0) {
|
||||
lines.push("## Thread History");
|
||||
for (const step of ctx.steps) {
|
||||
const body = await getContentMerklePayload(deps.cas, step.contentHash);
|
||||
if (body === null) {
|
||||
throw new Error(`extract: missing CAS blob for step ${step.role}: ${step.contentHash}`);
|
||||
}
|
||||
lines.push(`### ${step.role}`);
|
||||
lines.push(body);
|
||||
lines.push(`Meta: ${JSON.stringify(step.meta)}`);
|
||||
lines.push("");
|
||||
}
|
||||
}
|
||||
lines.push("## Agent Output");
|
||||
lines.push(ctx.agentContent);
|
||||
lines.push("");
|
||||
lines.push("## Extraction Instruction");
|
||||
lines.push(prompt);
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an ExtractFn backed by an LLM provider.
|
||||
*
|
||||
@@ -82,52 +18,21 @@ export async function buildExtractUserContent(
|
||||
* assistant reply as a short-circuit, which covers the legacy "single" extraction path.
|
||||
*/
|
||||
export function createExtract(provider: LlmProvider, deps: ExtractDeps): ExtractFn {
|
||||
const llm = createLlmFn(provider);
|
||||
const reactor = createThreadReactor<ExtractThreadContext>({
|
||||
llm,
|
||||
const reactor = createCasReactor(provider, deps.cas, {
|
||||
maxRounds: MAX_REACT_ROUNDS,
|
||||
staticTools: [CAS_GET_TOOL_DEFINITION],
|
||||
structuredToolFromSchema: (schema) => {
|
||||
const t = extractFunctionToolFromZodSchema(schema);
|
||||
return {
|
||||
name: t.name,
|
||||
tool: {
|
||||
type: "function" as const,
|
||||
function: {
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
parameters: t.parameters,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
systemPromptForStructuredTool: (structuredToolName) =>
|
||||
`You extract structured metadata from the agent output below. Use cas_get to read Merkle DAG nodes from CAS (YAML: type, payload, children) when the agent output references hashes you must traverse. When you have the complete structured object, call the ${structuredToolName} tool with JSON arguments matching the schema. You may instead reply with only a JSON object (no prose) when no tools are needed.`,
|
||||
toolHandler: async (call, thread) => {
|
||||
if (call.function.name !== "cas_get") {
|
||||
return `Unexpected tool routed to handler: ${call.function.name}`;
|
||||
}
|
||||
let hash: string;
|
||||
try {
|
||||
const ta = JSON.parse(call.function.arguments) as unknown;
|
||||
if (!isRecord(ta) || typeof ta.hash !== "string") {
|
||||
return 'cas_get requires a JSON object with a string "hash" field.';
|
||||
}
|
||||
hash = ta.hash;
|
||||
} catch {
|
||||
return 'cas_get arguments were not valid JSON. Provide {"hash": "<cas-hash>"}.';
|
||||
}
|
||||
const blob = await thread.cas.get(hash);
|
||||
return blob === null ? "null" : blob;
|
||||
},
|
||||
`You extract structured metadata from content. The content is from a CAS node. Use cas_get to read referenced nodes if needed. When ready, call the ${structuredToolName} tool with JSON matching the schema. You may instead reply with only a JSON object (no prose) when no tools are needed.`,
|
||||
});
|
||||
|
||||
return async <T extends Record<string, unknown>>(
|
||||
schema: z.ZodType<T>,
|
||||
prompt: string,
|
||||
ctx: ExtractContext,
|
||||
contentHash: string,
|
||||
): Promise<ExtractResult<T>> => {
|
||||
const text = await buildExtractUserContent(ctx, prompt, deps);
|
||||
const payload = await getContentMerklePayload(deps.cas, contentHash);
|
||||
if (payload === null) {
|
||||
throw new Error(`extract: missing CAS content node for hash ${contentHash}`);
|
||||
}
|
||||
const text = `${payload}\n\nExtract structured metadata according to the schema.`;
|
||||
const result = await reactor({
|
||||
thread: { cas: deps.cas },
|
||||
input: text,
|
||||
@@ -138,7 +43,7 @@ export function createExtract(provider: LlmProvider, deps: ExtractDeps): Extract
|
||||
}
|
||||
return {
|
||||
meta: result.value,
|
||||
contentPayload: ctx.agentContent,
|
||||
contentPayload: payload,
|
||||
refs: [],
|
||||
};
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user