Compare commits
63 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| db45089922 | |||
| 0fe17b0fb2 | |||
| 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
|
||||
|
||||
|
||||
@@ -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,239 @@
|
||||
# Dashboard Workflow Graph Visualization
|
||||
|
||||
**Issue**: #198
|
||||
**Status**: Draft
|
||||
**Author**: xingyue
|
||||
|
||||
## Overview
|
||||
|
||||
在 Dashboard 的 ThreadDetail 页面中嵌入一个交互式流程图,将 workflow 的 `ModeratorTable` 可视化为有向图。用户可以一眼看到角色流转结构和当前执行进度。
|
||||
|
||||
## 数据流
|
||||
|
||||
### 问题:ModeratorTable 在 bundle 端,Dashboard 在前端
|
||||
|
||||
`ModeratorTable` 是运行时数据结构(包含 JS 函数引用如 `check`),无法直接序列化给前端。需要一个**静态图描述格式**作为中间层。
|
||||
|
||||
### 方案:扩展 WorkflowDescriptor
|
||||
|
||||
当前 `WorkflowDescriptor` 只有 roles + description,不包含转换图信息:
|
||||
|
||||
```ts
|
||||
type WorkflowDescriptor = {
|
||||
description: string;
|
||||
roles: Record<string, WorkflowRoleDescriptor>;
|
||||
};
|
||||
```
|
||||
|
||||
**扩展为**:
|
||||
|
||||
```ts
|
||||
type TransitionEdge = {
|
||||
condition: string; // condition.name,或 "FALLBACK"
|
||||
description: string | null; // condition.description,FALLBACK 为 null
|
||||
target: string; // role name 或 "__end__"
|
||||
};
|
||||
|
||||
type WorkflowGraph = Record<string, TransitionEdge[]>;
|
||||
// key = source role name 或 "__start__"
|
||||
|
||||
type WorkflowDescriptor = {
|
||||
description: string;
|
||||
roles: Record<string, WorkflowRoleDescriptor>;
|
||||
graph: WorkflowGraph | null; // null = legacy bundles without graph
|
||||
};
|
||||
```
|
||||
|
||||
在 `buildDescriptor`(`workflow-register`)中,从 `ModeratorTable` 提取静态图结构。`condition.check` 函数不序列化,只保留 `name` 和 `description`。
|
||||
|
||||
### 数据暴露路径
|
||||
|
||||
```
|
||||
ModeratorTable (runtime)
|
||||
→ buildDescriptor() 提取 graph
|
||||
→ descriptor.yaml 持久化
|
||||
→ CLI serve /workflows API 返回
|
||||
→ Dashboard 前端拿到 graph
|
||||
```
|
||||
|
||||
同时需要新增或扩展一个 API,让 Dashboard 能拿到指定 workflow 的 descriptor(含 graph):
|
||||
|
||||
```
|
||||
GET /workflows/:name → { descriptor: WorkflowDescriptor }
|
||||
```
|
||||
|
||||
或者在现有 `listWorkflows` 返回中附带。
|
||||
|
||||
## 前端渲染
|
||||
|
||||
### 库选型:React Flow + dagre
|
||||
|
||||
| 库 | 优势 | 劣势 |
|
||||
|---|---|---|
|
||||
| **React Flow** ✅ | React 原生、自定义节点/边、dagre 自动布局、~50KB gzip | 需要学 API |
|
||||
| Mermaid | 声明式简单 | 无交互、无法高亮当前步骤 |
|
||||
| D3 | 完全控制 | 太底层,手撸成本高 |
|
||||
| Cytoscape | 图论强 | React 集成差 |
|
||||
|
||||
**依赖新增**:
|
||||
|
||||
```json
|
||||
{
|
||||
"@xyflow/react": "^12",
|
||||
"@dagrejs/dagre": "^1"
|
||||
}
|
||||
```
|
||||
|
||||
### 图结构映射
|
||||
|
||||
```
|
||||
WorkflowGraph → React Flow nodes + edges
|
||||
|
||||
节点:
|
||||
- __start__ → 圆形小节点(入口)
|
||||
- role → 圆角矩形,显示 role name + description
|
||||
- __end__ → 圆形小节点(终止)
|
||||
|
||||
边:
|
||||
- FALLBACK → 虚线(dashed),无 label
|
||||
- condition → 实线,label = condition.name
|
||||
hover tooltip = condition.description
|
||||
```
|
||||
|
||||
### 布局
|
||||
|
||||
使用 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 — TransitionEdge, 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 1: 数据层 + 静态图
|
||||
|
||||
1. 在 `workflow-protocol` 中新增 `TransitionEdge` / `WorkflowGraph` 类型
|
||||
2. 在 `workflow-register` 的 `buildDescriptor` 中从 `ModeratorTable` 提取 graph
|
||||
3. `stringifyWorkflowDescriptor` / `validateWorkflowDescriptor` 支持 graph 字段
|
||||
4. CLI serve 的 `/workflows` API 返回 descriptor(含 graph)
|
||||
5. Dashboard 新增 `WorkflowGraph` 组件,静态渲染图
|
||||
|
||||
**产出**:打开 ThreadDetail 看到 workflow 流程图,无高亮。
|
||||
|
||||
### Phase 2: 运行时高亮
|
||||
|
||||
1. ThreadDetail 根据 records 计算 nodeStates
|
||||
2. 节点/边样式响应状态变化
|
||||
3. SSE live 模式下实时更新高亮
|
||||
|
||||
**产出**:正在运行的 thread 能看到当前执行到哪个 role。
|
||||
|
||||
### Phase 3: 交互增强
|
||||
|
||||
1. 点击节点滚动到对应 role 的 RecordCard
|
||||
2. 边 hover 显示 condition description tooltip
|
||||
3. 节点 hover 显示 role description + schema summary
|
||||
|
||||
**产出**:图和记录列表联动。
|
||||
|
||||
## 注意事项
|
||||
|
||||
- **向后兼容**:`graph` 字段为 `null` 时(旧 bundle),不渲染图,只显示 records
|
||||
- **自循环边**:如 `coder → coder (FALLBACK)`,React Flow 支持自循环,dagre 需要特殊处理(self-edge 用 loop 路径)
|
||||
- **大图性能**:dagre 在 <50 节点时性能无忧,workflow 通常 <10 个 role
|
||||
- **暗色主题**:Dashboard 已使用 CSS variables,节点/边样式复用现有色板
|
||||
- **不提交 pnpm-lock.yaml**
|
||||
|
||||
## 开放问题
|
||||
|
||||
1. **graph 放 descriptor 还是独立字段?** — 建议放 descriptor,因为它描述的就是 workflow 结构
|
||||
2. **是否需要 WorkflowList 页也展示图?** — Phase 1 先只在 ThreadDetail,后续按需扩展
|
||||
3. **`buildDescriptor` 需要 `ModeratorTable` 参数** — 当前 `buildDescriptor` 只接收 roles,需要扩展签名或在 bundle 注册时额外传入 table
|
||||
@@ -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,7 +45,7 @@ describe("gc cli and garbageCollectCas", () => {
|
||||
{
|
||||
name: "demo",
|
||||
hash: bundleHash,
|
||||
maxRounds: 5,
|
||||
|
||||
depth: 0,
|
||||
},
|
||||
promptHash,
|
||||
@@ -100,7 +100,7 @@ describe("gc cli and garbageCollectCas", () => {
|
||||
{
|
||||
name: "demo",
|
||||
hash: bundleHash,
|
||||
maxRounds: 5,
|
||||
|
||||
depth: 0,
|
||||
},
|
||||
promptHash,
|
||||
@@ -135,7 +135,7 @@ describe("gc cli and garbageCollectCas", () => {
|
||||
{
|
||||
name: "demo",
|
||||
hash: bundleHash,
|
||||
maxRounds: 5,
|
||||
|
||||
depth: 0,
|
||||
},
|
||||
promptHash,
|
||||
|
||||
@@ -64,6 +64,7 @@ describe("init template", () => {
|
||||
|
||||
const moder = await readFile(join(tdir, "src", "moderator.ts"), "utf8");
|
||||
expect(moder).not.toContain("export default");
|
||||
expect(moder).toContain("ModeratorTable");
|
||||
});
|
||||
|
||||
test("finds workspace walking up from nested cwd", async () => {
|
||||
|
||||
@@ -82,7 +82,7 @@ describe("init workspace", () => {
|
||||
for (const term of [
|
||||
"RoleDefinition",
|
||||
"WorkflowDefinition",
|
||||
"Moderator",
|
||||
"ModeratorTable",
|
||||
"AgentFn",
|
||||
"ExtractFn",
|
||||
"RoleMeta",
|
||||
|
||||
@@ -36,6 +36,7 @@ const threadFixtureDescriptor = `export const descriptor = {
|
||||
only: { description: "only", schema: {} },
|
||||
noop: { description: "noop", schema: {} },
|
||||
},
|
||||
graph: { edges: [] },
|
||||
};
|
||||
`;
|
||||
|
||||
@@ -305,8 +306,13 @@ describe("cli thread commands", () => {
|
||||
}
|
||||
|
||||
const threadId = ran.value.threadId;
|
||||
const killBundleDir = getBundleDir(storageRoot, added.value.hash);
|
||||
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
await waitUntilPredicate(async () => {
|
||||
const idx = await readThreadsIndex(killBundleDir);
|
||||
const ent = idx[threadId];
|
||||
return ent !== undefined && ent.head !== ent.start;
|
||||
}, 80);
|
||||
|
||||
const killed = await cmdKill(storageRoot, threadId);
|
||||
expect(killed.ok).toBe(true);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@uncaged/cli-workflow",
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.1",
|
||||
"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: {}
|
||||
@@ -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,
|
||||
};
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ function agentsMd(): string {
|
||||
| 层级 | 目录 / 产物 | 职责 |
|
||||
|------|----------------|------|
|
||||
| **Workspace** | 仓库根(\`package.json\` 含 \`workspaces: ["templates/*", "workflows"]\`) | Bun monorepo:统一管理本地模板包与 workflow 实例 |
|
||||
| **Template** | \`templates/<name>/\`(如 \`src/roles.ts\`、\`src/moderator.ts\`、\`src/index.ts\`) | 纯数据:**WorkflowDefinition**(各 **RoleDefinition** + **Moderator**),**不绑定**具体 Agent |
|
||||
| **Template** | \`templates/<name>/\`(如 \`src/roles.ts\`、\`src/moderator.ts\`、\`src/index.ts\`) | 纯数据:**WorkflowDefinition**(各 **RoleDefinition** + **ModeratorTable**),**不绑定**具体 Agent |
|
||||
| **Workflow instance** | \`workflows/\`(或单独包) | 把模板与运行时 **AgentFn** / **ExtractFn** 组合,产出可注册的 **单文件 ESM bundle**(\`run\` + \`descriptor\` 命名导出) |
|
||||
|
||||
Init 生成的骨架:\`templates/\` 下放可复用定义,\`workflows/\` 下放绑定与打包入口。
|
||||
@@ -93,20 +93,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 +153,7 @@ uncaged-workflow add <name> <path/to/bundle.esm.js>
|
||||
|
||||
---
|
||||
|
||||
编写新 workflow 时,先对齐 **RoleMeta → RoleDefinition(Zod)→ Moderator → 绑定 → 单文件 bundle**,再对照本节规范自检。
|
||||
编写新 workflow 时,先对齐 **RoleMeta → RoleDefinition(Zod)→ ModeratorTable → 绑定 → 单文件 bundle**,再对照本节规范自检。
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -164,7 +164,7 @@ Local workflow development workspace (Bun monorepo).
|
||||
|
||||
## Layout
|
||||
|
||||
- \`templates/\` — reusable workflow definition packages (roles + moderator), no agent binding
|
||||
- \`templates/\` — reusable workflow definition packages (roles + ModeratorTable), no agent binding
|
||||
- \`workflows/\` — workflow instances that bind templates to agents and export \`run\` + \`descriptor\`
|
||||
|
||||
## Commands
|
||||
|
||||
@@ -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,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;
|
||||
};
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -85,6 +85,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 +98,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 +118,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 +189,28 @@ export const run: WorkflowRun;
|
||||
|
||||
## WorkflowDescriptor
|
||||
|
||||
Defines the workflow's metadata and role sequence:
|
||||
Serialized metadata for the registry (per-role JSON Schema plus a static routing graph):
|
||||
|
||||
\`\`\`typescript
|
||||
type WorkflowDescriptor = {
|
||||
name: string; // verb-first kebab-case, e.g. "solve-issue"
|
||||
description: string; // one-line summary
|
||||
roles: string[]; // ordered role names, e.g. ["planner", "coder", "reviewer"]
|
||||
description: string;
|
||||
roles: Record<string, { description: string; schema: unknown /* JSON Schema */ }>;
|
||||
graph: {
|
||||
edges: Array<{
|
||||
from: string;
|
||||
to: string;
|
||||
condition: string;
|
||||
conditionDescription: string | null;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
\`\`\`
|
||||
|
||||
## WorkflowRun
|
||||
|
||||
The main function that creates and returns a moderator:
|
||||
Async generator from \`createWorkflow(definition, binding)\` (**@uncaged/workflow-runtime**) — yields each role output until the workflow completes.
|
||||
|
||||
\`\`\`typescript
|
||||
type WorkflowRun = (ctx: WorkflowContext) => Moderator;
|
||||
\`\`\`
|
||||
|
||||
The **Moderator** controls the flow — it decides which role runs next, handles retries, and determines when the workflow is complete.
|
||||
The **ModeratorTable** on **WorkflowDefinition** is declarative routing (from each role and \`START\` to the next role or \`END\`); the engine evaluates conditions at runtime.
|
||||
|
||||
## Role Definition
|
||||
|
||||
@@ -200,7 +220,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 +229,7 @@ Each role has:
|
||||
# 1. Initialize a workspace
|
||||
uncaged-workflow init workspace my-workflow
|
||||
|
||||
# 2. Write your template (roles + moderator + descriptor)
|
||||
# 2. Write your template (roles + ModeratorTable + descriptor)
|
||||
|
||||
# 3. Build the ESM bundle
|
||||
bun run build
|
||||
|
||||
@@ -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,37 +1,50 @@
|
||||
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({
|
||||
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({
|
||||
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 empty workspace string", () => {
|
||||
const r = validateCursorAgentConfig({
|
||||
model: null,
|
||||
timeout: 0,
|
||||
workspace: "",
|
||||
llmProvider: null,
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) {
|
||||
expect(r.error).toContain("extract");
|
||||
expect(r.error).toContain("workspace");
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects null workspace without llmProvider", () => {
|
||||
const r = validateCursorAgentConfig({
|
||||
model: null,
|
||||
timeout: 0,
|
||||
workspace: null,
|
||||
llmProvider: null,
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) {
|
||||
expect(r.error).toContain("llmProvider");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -39,18 +52,30 @@ describe("validateCursorAgentConfig", () => {
|
||||
const r = validateCursorAgentConfig({
|
||||
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({
|
||||
model: null,
|
||||
timeout: 0,
|
||||
extract: testExtract,
|
||||
workspace: "/tmp/test-project",
|
||||
llmProvider: null,
|
||||
});
|
||||
expect(typeof agent).toBe("function");
|
||||
});
|
||||
|
||||
test("returns an AgentFn with null workspace and llmProvider", () => {
|
||||
const agent = createCursorAgent({
|
||||
model: null,
|
||||
timeout: 0,
|
||||
workspace: null,
|
||||
llmProvider: { baseUrl: "http://localhost", apiKey: "test", model: "test" },
|
||||
});
|
||||
expect(typeof agent).toBe("function");
|
||||
});
|
||||
@@ -60,7 +85,19 @@ describe("createCursorAgent", () => {
|
||||
createCursorAgent({
|
||||
model: null,
|
||||
timeout: -1,
|
||||
extract: testExtract,
|
||||
workspace: "/tmp/test-project",
|
||||
llmProvider: null,
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
test("throws when null workspace without llmProvider", () => {
|
||||
expect(() =>
|
||||
createCursorAgent({
|
||||
model: null,
|
||||
timeout: 0,
|
||||
workspace: null,
|
||||
llmProvider: null,
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-agent-cursor",
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.1",
|
||||
"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,7 +28,7 @@ function resolveCursorModel(model: string | null): string {
|
||||
return model === null ? "auto" : model;
|
||||
}
|
||||
|
||||
/** Runs `cursor-agent` with workspace from {@link CursorAgentConfig.extract} and prompt from context. */
|
||||
/** Runs `cursor-agent` with workspace from config or extracted from context via LLM. */
|
||||
export function createCursorAgent(config: CursorAgentConfig): AgentFn {
|
||||
const validated = validateCursorAgentConfig(config);
|
||||
if (!validated.ok) {
|
||||
@@ -42,18 +37,27 @@ export function createCursorAgent(config: CursorAgentConfig): AgentFn {
|
||||
|
||||
const modelFlag = resolveCursorModel(config.model);
|
||||
const timeoutMs = config.timeout > 0 ? config.timeout : null;
|
||||
const logger = createLogger({ sink: { kind: "stderr" } });
|
||||
|
||||
return async (ctx) => {
|
||||
const 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;
|
||||
let workspace: string;
|
||||
|
||||
if (config.workspace !== null) {
|
||||
workspace = config.workspace;
|
||||
} else {
|
||||
if (config.llmProvider === null) {
|
||||
throw new Error("cursor-agent: llmProvider is required when workspace is null");
|
||||
}
|
||||
const extracted = await extractWorkspacePath(ctx, config.llmProvider, logger);
|
||||
if (extracted === null) {
|
||||
throw new Error(
|
||||
"cursor-agent: failed to extract workspace path from context. Provide an explicit workspace or ensure previous steps include a repoPath.",
|
||||
);
|
||||
}
|
||||
workspace = extracted;
|
||||
}
|
||||
|
||||
logger("R5HN3YKQ", `cursor-agent workspace: ${workspace}`);
|
||||
const fullPrompt = await buildAgentPrompt(ctx);
|
||||
const args = [
|
||||
"-p",
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { ExtractFn } from "@uncaged/workflow-runtime";
|
||||
import type { LlmProvider } from "@uncaged/workflow-protocol";
|
||||
|
||||
export type CursorAgentConfig = {
|
||||
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,13 @@
|
||||
import { err, ok, type Result } from "@uncaged/workflow-runtime";
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
|
||||
import type { CursorAgentConfig } from "./types.js";
|
||||
|
||||
export function validateCursorAgentConfig(config: CursorAgentConfig): Result<void, string> {
|
||||
if (typeof config.extract !== "function") {
|
||||
return err("extract must be a function");
|
||||
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");
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-agent-hermes",
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.1",
|
||||
"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
|
||||
@@ -8,7 +8,7 @@ function makeCtx(userContent: string): AgentContext {
|
||||
start: {
|
||||
role: START,
|
||||
content: userContent,
|
||||
meta: { maxRounds: 10 },
|
||||
meta: {},
|
||||
timestamp: 1,
|
||||
},
|
||||
depth: 0,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-agent-llm",
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.1",
|
||||
"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
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-cas",
|
||||
"version": "0.1.0",
|
||||
"version": "0.3.1",
|
||||
"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: {}
|
||||
@@ -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,
|
||||
|
||||
@@ -21,7 +21,6 @@ function isStartPayload(value: unknown): value is StartNodePayload {
|
||||
return (
|
||||
typeof value.name === "string" &&
|
||||
typeof value.hash === "string" &&
|
||||
typeof value.maxRounds === "number" &&
|
||||
typeof value.depth === "number"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -10,7 +10,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"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,14 +47,23 @@ 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;
|
||||
@@ -35,50 +78,77 @@ 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;
|
||||
|
||||
// ── 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 function listThreads(agent: string): Promise<{ threads: ThreadSummary[] }> {
|
||||
return fetchJson(agentBase(agent), "/threads");
|
||||
}
|
||||
|
||||
export function getThread(id: string): Promise<{ records: ThreadRecord[] }> {
|
||||
return fetchJson(`/threads/${id}`);
|
||||
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,126 @@
|
||||
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 }: { record: RoleRecord }) {
|
||||
const color = roleColor(record.role);
|
||||
return (
|
||||
<div
|
||||
className="p-3 rounded-lg border text-sm"
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
export function RecordCard({ record }: { record: ThreadRecord }) {
|
||||
switch (record.type) {
|
||||
case "thread-start":
|
||||
return <StartCard record={record} />;
|
||||
case "role":
|
||||
return <RoleMessage record={record} />;
|
||||
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>
|
||||
|
||||
@@ -2,15 +2,17 @@ import { useEffect, useRef, useState } from "react";
|
||||
import { getThread, killThread, pauseThread, resumeThread } from "../api.ts";
|
||||
import { useFetch } from "../hooks.ts";
|
||||
import { useSSE } from "../use-sse.ts";
|
||||
import { RecordCard } from "./record-card.tsx";
|
||||
|
||||
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]);
|
||||
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);
|
||||
|
||||
@@ -30,7 +32,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 +80,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)" }}
|
||||
@@ -101,39 +103,8 @@ 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)" }}
|
||||
>
|
||||
{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>
|
||||
{records.map((r, i) => (
|
||||
<RecordCard key={`${threadId}-${i}`} record={r} />
|
||||
))}
|
||||
<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",
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { listWorkflows } from "../api.ts";
|
||||
import { useFetch } from "../hooks.ts";
|
||||
|
||||
export function WorkflowList() {
|
||||
const { status, data, error } = useFetch(() => listWorkflows(), []);
|
||||
type Props = {
|
||||
agent: string;
|
||||
};
|
||||
|
||||
export function WorkflowList({ agent }: Props) {
|
||||
const { status, data, error } = useFetch(() => listWorkflows(agent), [agent]);
|
||||
|
||||
if (status === "loading")
|
||||
return <p style={{ color: "var(--color-text-muted)" }}>Loading workflows...</p>;
|
||||
|
||||
@@ -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,7 +34,6 @@ function noLogger(): (tag: string, content: string) => void {
|
||||
|
||||
function makeOptions(overrides: Partial<ExecuteThreadOptions>): ExecuteThreadOptions {
|
||||
return {
|
||||
maxRounds: 5,
|
||||
depth: 0,
|
||||
signal: new AbortController().signal,
|
||||
awaitAfterEachYield: async () => {},
|
||||
@@ -107,7 +106,7 @@ describe("executeThread (Phase 2 — CAS thread storage)", () => {
|
||||
wf,
|
||||
"demo",
|
||||
{ prompt: "hello", steps: [] },
|
||||
makeOptions({ storageRoot, maxRounds: 5 }),
|
||||
makeOptions({ storageRoot }),
|
||||
io,
|
||||
noLogger(),
|
||||
);
|
||||
@@ -127,7 +126,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);
|
||||
@@ -164,7 +162,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 }>;
|
||||
@@ -228,7 +225,7 @@ describe("executeThread (Phase 2 — CAS thread storage)", () => {
|
||||
wf,
|
||||
"demo",
|
||||
{ prompt: "p", steps: [] },
|
||||
makeOptions({ storageRoot, maxRounds: 5 }),
|
||||
makeOptions({ storageRoot }),
|
||||
io,
|
||||
noLogger(),
|
||||
);
|
||||
@@ -279,7 +276,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,7 +45,6 @@ describe("garbageCollectCas (mark-and-sweep)", () => {
|
||||
{
|
||||
name: "demo",
|
||||
hash: bundleHash,
|
||||
maxRounds: 5,
|
||||
depth: 0,
|
||||
},
|
||||
promptHash,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-execute",
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.1",
|
||||
"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: {
|
||||
@@ -211,17 +217,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 +256,7 @@ async function driveWorkflowGenerator(params: {
|
||||
bundleDir: string;
|
||||
startHash: string;
|
||||
chain: ChainState;
|
||||
summarize: SummarizeFn;
|
||||
}): Promise<WorkflowResult> {
|
||||
const {
|
||||
fn,
|
||||
@@ -262,6 +269,7 @@ async function driveWorkflowGenerator(params: {
|
||||
cas,
|
||||
bundleDir,
|
||||
startHash,
|
||||
summarize,
|
||||
} = params;
|
||||
let chain: ChainState = params.chain;
|
||||
const gen = fn(thread, runtime);
|
||||
@@ -270,6 +278,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 +296,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 },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -335,6 +339,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 +388,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,7 +438,6 @@ export async function executeThread(
|
||||
{
|
||||
name: workflowName,
|
||||
hash: io.hash,
|
||||
maxRounds: options.maxRounds,
|
||||
depth: options.depth,
|
||||
},
|
||||
promptHash,
|
||||
@@ -475,21 +479,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);
|
||||
@@ -501,7 +490,7 @@ export async function executeThread(
|
||||
start: {
|
||||
role: START,
|
||||
content: input.prompt,
|
||||
meta: { maxRounds: options.maxRounds },
|
||||
meta: {},
|
||||
timestamp: nowMs,
|
||||
},
|
||||
steps: input.steps.map((out, i) => ({
|
||||
@@ -530,5 +519,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,
|
||||
});
|
||||
@@ -317,7 +314,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,7 +39,6 @@ export type PrefilledDiskStep = {
|
||||
};
|
||||
|
||||
export type ExecuteThreadOptions = {
|
||||
maxRounds: number;
|
||||
/** Passed to the bundle thread context as `ThreadContext.depth`. */
|
||||
depth: number;
|
||||
signal: AbortSignal;
|
||||
@@ -68,7 +67,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;
|
||||
@@ -184,10 +185,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 +206,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 +379,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`);
|
||||
@@ -498,8 +512,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: [],
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
export {
|
||||
buildExtractUserContent,
|
||||
createExtract,
|
||||
type ExtractThreadContext,
|
||||
} from "./extract-fn.js";
|
||||
export { createExtract } from "./extract-fn.js";
|
||||
export {
|
||||
extractFunctionToolFromZodSchema,
|
||||
llmErrorToCause,
|
||||
|
||||
@@ -37,9 +37,7 @@ export { EMPTY_CHAIN_STATE } from "./engine/types.js";
|
||||
export { getWorkerHostScriptPath } from "./engine/worker-entry-path.js";
|
||||
export type { ExtractFn, LlmError, LlmExtractArgs } from "./extract/index.js";
|
||||
export {
|
||||
buildExtractUserContent,
|
||||
createExtract,
|
||||
type ExtractThreadContext,
|
||||
extractFunctionToolFromZodSchema,
|
||||
llmErrorToCause,
|
||||
llmExtract,
|
||||
|
||||
@@ -95,7 +95,6 @@ export function workflowAsAgent(
|
||||
workflowName,
|
||||
input,
|
||||
{
|
||||
maxRounds: ctx.start.meta.maxRounds,
|
||||
depth: nextDepth,
|
||||
signal: signalNever.signal,
|
||||
awaitAfterEachYield: async () => {},
|
||||
@@ -108,7 +107,7 @@ export function workflowAsAgent(
|
||||
io,
|
||||
logger,
|
||||
);
|
||||
return result.rootHash;
|
||||
return `Child workflow "${workflowName}" completed (returnCode=${result.returnCode}).\n\nSummary: ${result.summary}\n\nChild thread root hash: ${result.rootHash}`;
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
return `ERROR: ${message}`;
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-gateway",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "wrangler dev",
|
||||
"deploy": "wrangler deploy"
|
||||
},
|
||||
"dependencies": {
|
||||
"hono": "^4.7.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^4.20260425.1",
|
||||
"wrangler": "^4.20.0"
|
||||
}
|
||||
}
|
||||
Generated
+888
@@ -0,0 +1,888 @@
|
||||
lockfileVersion: '9.0'
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
hono:
|
||||
specifier: ^4.7.11
|
||||
version: 4.12.18
|
||||
devDependencies:
|
||||
'@cloudflare/workers-types':
|
||||
specifier: ^4.20260425.1
|
||||
version: 4.20260511.1
|
||||
wrangler:
|
||||
specifier: ^4.20.0
|
||||
version: 4.90.0(@cloudflare/workers-types@4.20260511.1)
|
||||
|
||||
packages:
|
||||
|
||||
'@cloudflare/kv-asset-handler@0.5.0':
|
||||
resolution: {integrity: sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==}
|
||||
engines: {node: '>=22.0.0'}
|
||||
|
||||
'@cloudflare/unenv-preset@2.16.1':
|
||||
resolution: {integrity: sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==}
|
||||
peerDependencies:
|
||||
unenv: 2.0.0-rc.24
|
||||
workerd: '>1.20260305.0 <2.0.0-0'
|
||||
peerDependenciesMeta:
|
||||
workerd:
|
||||
optional: true
|
||||
|
||||
'@cloudflare/workerd-darwin-64@1.20260507.1':
|
||||
resolution: {integrity: sha512-S85aMwcaPJUjKWDiG6iMMnioKWtPLACa6m0j/EhHR1GYfVpnxb974cBc6d25L+sf7jHWHJI2u5hGp0UTJ7MtXQ==}
|
||||
engines: {node: '>=16'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@cloudflare/workerd-darwin-arm64@1.20260507.1':
|
||||
resolution: {integrity: sha512-GMEBu8Zp9Q97HLnf7bWJN4KjWpN5MxpeqdvHjBGWNl8UYprJI0k+Jkp89+Wh5S8vIon+HoVbDfOzPa7VwgL6Eg==}
|
||||
engines: {node: '>=16'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@cloudflare/workerd-linux-64@1.20260507.1':
|
||||
resolution: {integrity: sha512-QlrKEBdgA3uVc0Ok0Q3+0/CW0CTjgj5ySir1i1YY5FXVv0X6GpwtnB5umjunjF2MFprss+L+iFGZzxcSvMC1nA==}
|
||||
engines: {node: '>=16'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@cloudflare/workerd-linux-arm64@1.20260507.1':
|
||||
resolution: {integrity: sha512-eGbbupEtK2nh9V9Dhcx3vv3GTKeXqSVNgAEYVCCN0NGS9tl9HbMoHRX/4JL181FKXROMigWBCQVL//qPhsAzBQ==}
|
||||
engines: {node: '>=16'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@cloudflare/workerd-windows-64@1.20260507.1':
|
||||
resolution: {integrity: sha512-dmClJ/E0BAcuDetQIZFqbeAXejWrG5pysGRMQ6T83Y0IW/7IAamY2zFEkAJ10I5xwZsdHuYsZtzlOxpEXpJs7A==}
|
||||
engines: {node: '>=16'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@cloudflare/workers-types@4.20260511.1':
|
||||
resolution: {integrity: sha512-FA+si7cOq9i/gtCHhIc0XJL0l1F/ApF+m00752Aj7WZFJrj3ZulT2T8/+rT3BabMT0QEnqFEGIqCgrmqhgEfMg==}
|
||||
|
||||
'@cspotcode/source-map-support@0.8.1':
|
||||
resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
'@emnapi/runtime@1.10.0':
|
||||
resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==}
|
||||
|
||||
'@esbuild/aix-ppc64@0.27.3':
|
||||
resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ppc64]
|
||||
os: [aix]
|
||||
|
||||
'@esbuild/android-arm64@0.27.3':
|
||||
resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/android-arm@0.27.3':
|
||||
resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/android-x64@0.27.3':
|
||||
resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/darwin-arm64@0.27.3':
|
||||
resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@esbuild/darwin-x64@0.27.3':
|
||||
resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@esbuild/freebsd-arm64@0.27.3':
|
||||
resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [freebsd]
|
||||
|
||||
'@esbuild/freebsd-x64@0.27.3':
|
||||
resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@esbuild/linux-arm64@0.27.3':
|
||||
resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-arm@0.27.3':
|
||||
resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-ia32@0.27.3':
|
||||
resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ia32]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-loong64@0.27.3':
|
||||
resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-mips64el@0.27.3':
|
||||
resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [mips64el]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-ppc64@0.27.3':
|
||||
resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-riscv64@0.27.3':
|
||||
resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-s390x@0.27.3':
|
||||
resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-x64@0.27.3':
|
||||
resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/netbsd-arm64@0.27.3':
|
||||
resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [netbsd]
|
||||
|
||||
'@esbuild/netbsd-x64@0.27.3':
|
||||
resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [netbsd]
|
||||
|
||||
'@esbuild/openbsd-arm64@0.27.3':
|
||||
resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [openbsd]
|
||||
|
||||
'@esbuild/openbsd-x64@0.27.3':
|
||||
resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [openbsd]
|
||||
|
||||
'@esbuild/openharmony-arm64@0.27.3':
|
||||
resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [openharmony]
|
||||
|
||||
'@esbuild/sunos-x64@0.27.3':
|
||||
resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [sunos]
|
||||
|
||||
'@esbuild/win32-arm64@0.27.3':
|
||||
resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@esbuild/win32-ia32@0.27.3':
|
||||
resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@esbuild/win32-x64@0.27.3':
|
||||
resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@img/colour@1.1.0':
|
||||
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@img/sharp-darwin-arm64@0.34.5':
|
||||
resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@img/sharp-darwin-x64@0.34.5':
|
||||
resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@img/sharp-libvips-darwin-arm64@1.2.4':
|
||||
resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@img/sharp-libvips-darwin-x64@1.2.4':
|
||||
resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@img/sharp-libvips-linux-arm64@1.2.4':
|
||||
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-arm@1.2.4':
|
||||
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-ppc64@1.2.4':
|
||||
resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-riscv64@1.2.4':
|
||||
resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-s390x@1.2.4':
|
||||
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-x64@1.2.4':
|
||||
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
|
||||
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
|
||||
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-linux-arm64@0.34.5':
|
||||
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-arm@0.34.5':
|
||||
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-ppc64@0.34.5':
|
||||
resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-riscv64@0.34.5':
|
||||
resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-s390x@0.34.5':
|
||||
resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-x64@0.34.5':
|
||||
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linuxmusl-arm64@0.34.5':
|
||||
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-linuxmusl-x64@0.34.5':
|
||||
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-wasm32@0.34.5':
|
||||
resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [wasm32]
|
||||
|
||||
'@img/sharp-win32-arm64@0.34.5':
|
||||
resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@img/sharp-win32-ia32@0.34.5':
|
||||
resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@img/sharp-win32-x64@0.34.5':
|
||||
resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@jridgewell/resolve-uri@3.1.2':
|
||||
resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
|
||||
'@jridgewell/sourcemap-codec@1.5.5':
|
||||
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
|
||||
|
||||
'@jridgewell/trace-mapping@0.3.9':
|
||||
resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==}
|
||||
|
||||
'@poppinss/colors@4.1.6':
|
||||
resolution: {integrity: sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==}
|
||||
|
||||
'@poppinss/dumper@0.6.5':
|
||||
resolution: {integrity: sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==}
|
||||
|
||||
'@poppinss/exception@1.2.3':
|
||||
resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==}
|
||||
|
||||
'@sindresorhus/is@7.2.0':
|
||||
resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@speed-highlight/core@1.2.15':
|
||||
resolution: {integrity: sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw==}
|
||||
|
||||
blake3-wasm@2.1.5:
|
||||
resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==}
|
||||
|
||||
cookie@1.1.1:
|
||||
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
detect-libc@2.1.2:
|
||||
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
error-stack-parser-es@1.0.5:
|
||||
resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==}
|
||||
|
||||
esbuild@0.27.3:
|
||||
resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
fsevents@2.3.3:
|
||||
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
os: [darwin]
|
||||
|
||||
hono@4.12.18:
|
||||
resolution: {integrity: sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==}
|
||||
engines: {node: '>=16.9.0'}
|
||||
|
||||
kleur@4.1.5:
|
||||
resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
miniflare@4.20260507.1:
|
||||
resolution: {integrity: sha512-PSXBiLExTdZ4UGO/raKCHQauUpYL7F880ZRB7j0+78Rv8h7TsdN2E/iEDK9sK2Y+SPQ5wJSeAa+rDeVKoZZoEw==}
|
||||
engines: {node: '>=22.0.0'}
|
||||
hasBin: true
|
||||
|
||||
path-to-regexp@6.3.0:
|
||||
resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==}
|
||||
|
||||
pathe@2.0.3:
|
||||
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
|
||||
|
||||
semver@7.8.0:
|
||||
resolution: {integrity: sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==}
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
|
||||
sharp@0.34.5:
|
||||
resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
|
||||
supports-color@10.2.2:
|
||||
resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
tslib@2.8.1:
|
||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||
|
||||
undici@7.24.8:
|
||||
resolution: {integrity: sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ==}
|
||||
engines: {node: '>=20.18.1'}
|
||||
|
||||
unenv@2.0.0-rc.24:
|
||||
resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==}
|
||||
|
||||
workerd@1.20260507.1:
|
||||
resolution: {integrity: sha512-z7JhsFSe6+X1b5fUHaVpo15VM1IRMJiLofEkq8iKdCo+Veqc+FUg5lIsuz8NwePxuSKrXtO4ZQpGkQLbPVXFhg==}
|
||||
engines: {node: '>=16'}
|
||||
hasBin: true
|
||||
|
||||
wrangler@4.90.0:
|
||||
resolution: {integrity: sha512-bmNIykl59TfCUn5xQgU7IWylSsPx3LQaPLMSAq2VQHt89CBrcj9qXQ0eYfjBCWA5XTBVgten391evt7xxtXwcA==}
|
||||
engines: {node: '>=22.0.0'}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@cloudflare/workers-types': ^4.20260507.1
|
||||
peerDependenciesMeta:
|
||||
'@cloudflare/workers-types':
|
||||
optional: true
|
||||
|
||||
ws@8.18.0:
|
||||
resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
peerDependencies:
|
||||
bufferutil: ^4.0.1
|
||||
utf-8-validate: '>=5.0.2'
|
||||
peerDependenciesMeta:
|
||||
bufferutil:
|
||||
optional: true
|
||||
utf-8-validate:
|
||||
optional: true
|
||||
|
||||
youch-core@0.3.3:
|
||||
resolution: {integrity: sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==}
|
||||
|
||||
youch@4.1.0-beta.10:
|
||||
resolution: {integrity: sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==}
|
||||
|
||||
snapshots:
|
||||
|
||||
'@cloudflare/kv-asset-handler@0.5.0': {}
|
||||
|
||||
'@cloudflare/unenv-preset@2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260507.1)':
|
||||
dependencies:
|
||||
unenv: 2.0.0-rc.24
|
||||
optionalDependencies:
|
||||
workerd: 1.20260507.1
|
||||
|
||||
'@cloudflare/workerd-darwin-64@1.20260507.1':
|
||||
optional: true
|
||||
|
||||
'@cloudflare/workerd-darwin-arm64@1.20260507.1':
|
||||
optional: true
|
||||
|
||||
'@cloudflare/workerd-linux-64@1.20260507.1':
|
||||
optional: true
|
||||
|
||||
'@cloudflare/workerd-linux-arm64@1.20260507.1':
|
||||
optional: true
|
||||
|
||||
'@cloudflare/workerd-windows-64@1.20260507.1':
|
||||
optional: true
|
||||
|
||||
'@cloudflare/workers-types@4.20260511.1': {}
|
||||
|
||||
'@cspotcode/source-map-support@0.8.1':
|
||||
dependencies:
|
||||
'@jridgewell/trace-mapping': 0.3.9
|
||||
|
||||
'@emnapi/runtime@1.10.0':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
optional: true
|
||||
|
||||
'@esbuild/aix-ppc64@0.27.3':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-arm64@0.27.3':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-arm@0.27.3':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-x64@0.27.3':
|
||||
optional: true
|
||||
|
||||
'@esbuild/darwin-arm64@0.27.3':
|
||||
optional: true
|
||||
|
||||
'@esbuild/darwin-x64@0.27.3':
|
||||
optional: true
|
||||
|
||||
'@esbuild/freebsd-arm64@0.27.3':
|
||||
optional: true
|
||||
|
||||
'@esbuild/freebsd-x64@0.27.3':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-arm64@0.27.3':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-arm@0.27.3':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-ia32@0.27.3':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-loong64@0.27.3':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-mips64el@0.27.3':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-ppc64@0.27.3':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-riscv64@0.27.3':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-s390x@0.27.3':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-x64@0.27.3':
|
||||
optional: true
|
||||
|
||||
'@esbuild/netbsd-arm64@0.27.3':
|
||||
optional: true
|
||||
|
||||
'@esbuild/netbsd-x64@0.27.3':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openbsd-arm64@0.27.3':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openbsd-x64@0.27.3':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openharmony-arm64@0.27.3':
|
||||
optional: true
|
||||
|
||||
'@esbuild/sunos-x64@0.27.3':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-arm64@0.27.3':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-ia32@0.27.3':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-x64@0.27.3':
|
||||
optional: true
|
||||
|
||||
'@img/colour@1.1.0': {}
|
||||
|
||||
'@img/sharp-darwin-arm64@0.34.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-darwin-arm64': 1.2.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-darwin-x64@0.34.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-darwin-x64': 1.2.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-darwin-arm64@1.2.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-darwin-x64@1.2.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linux-arm64@1.2.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linux-arm@1.2.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linux-ppc64@1.2.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linux-riscv64@1.2.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linux-s390x@1.2.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linux-x64@1.2.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linux-arm64@0.34.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linux-arm64': 1.2.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linux-arm@0.34.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linux-arm': 1.2.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linux-ppc64@0.34.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linux-ppc64': 1.2.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linux-riscv64@0.34.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linux-riscv64': 1.2.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linux-s390x@0.34.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linux-s390x': 1.2.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linux-x64@0.34.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linux-x64': 1.2.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linuxmusl-arm64@0.34.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linuxmusl-arm64': 1.2.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linuxmusl-x64@0.34.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linuxmusl-x64': 1.2.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-wasm32@0.34.5':
|
||||
dependencies:
|
||||
'@emnapi/runtime': 1.10.0
|
||||
optional: true
|
||||
|
||||
'@img/sharp-win32-arm64@0.34.5':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-win32-ia32@0.34.5':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-win32-x64@0.34.5':
|
||||
optional: true
|
||||
|
||||
'@jridgewell/resolve-uri@3.1.2': {}
|
||||
|
||||
'@jridgewell/sourcemap-codec@1.5.5': {}
|
||||
|
||||
'@jridgewell/trace-mapping@0.3.9':
|
||||
dependencies:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
'@poppinss/colors@4.1.6':
|
||||
dependencies:
|
||||
kleur: 4.1.5
|
||||
|
||||
'@poppinss/dumper@0.6.5':
|
||||
dependencies:
|
||||
'@poppinss/colors': 4.1.6
|
||||
'@sindresorhus/is': 7.2.0
|
||||
supports-color: 10.2.2
|
||||
|
||||
'@poppinss/exception@1.2.3': {}
|
||||
|
||||
'@sindresorhus/is@7.2.0': {}
|
||||
|
||||
'@speed-highlight/core@1.2.15': {}
|
||||
|
||||
blake3-wasm@2.1.5: {}
|
||||
|
||||
cookie@1.1.1: {}
|
||||
|
||||
detect-libc@2.1.2: {}
|
||||
|
||||
error-stack-parser-es@1.0.5: {}
|
||||
|
||||
esbuild@0.27.3:
|
||||
optionalDependencies:
|
||||
'@esbuild/aix-ppc64': 0.27.3
|
||||
'@esbuild/android-arm': 0.27.3
|
||||
'@esbuild/android-arm64': 0.27.3
|
||||
'@esbuild/android-x64': 0.27.3
|
||||
'@esbuild/darwin-arm64': 0.27.3
|
||||
'@esbuild/darwin-x64': 0.27.3
|
||||
'@esbuild/freebsd-arm64': 0.27.3
|
||||
'@esbuild/freebsd-x64': 0.27.3
|
||||
'@esbuild/linux-arm': 0.27.3
|
||||
'@esbuild/linux-arm64': 0.27.3
|
||||
'@esbuild/linux-ia32': 0.27.3
|
||||
'@esbuild/linux-loong64': 0.27.3
|
||||
'@esbuild/linux-mips64el': 0.27.3
|
||||
'@esbuild/linux-ppc64': 0.27.3
|
||||
'@esbuild/linux-riscv64': 0.27.3
|
||||
'@esbuild/linux-s390x': 0.27.3
|
||||
'@esbuild/linux-x64': 0.27.3
|
||||
'@esbuild/netbsd-arm64': 0.27.3
|
||||
'@esbuild/netbsd-x64': 0.27.3
|
||||
'@esbuild/openbsd-arm64': 0.27.3
|
||||
'@esbuild/openbsd-x64': 0.27.3
|
||||
'@esbuild/openharmony-arm64': 0.27.3
|
||||
'@esbuild/sunos-x64': 0.27.3
|
||||
'@esbuild/win32-arm64': 0.27.3
|
||||
'@esbuild/win32-ia32': 0.27.3
|
||||
'@esbuild/win32-x64': 0.27.3
|
||||
|
||||
fsevents@2.3.3:
|
||||
optional: true
|
||||
|
||||
hono@4.12.18: {}
|
||||
|
||||
kleur@4.1.5: {}
|
||||
|
||||
miniflare@4.20260507.1:
|
||||
dependencies:
|
||||
'@cspotcode/source-map-support': 0.8.1
|
||||
sharp: 0.34.5
|
||||
undici: 7.24.8
|
||||
workerd: 1.20260507.1
|
||||
ws: 8.18.0
|
||||
youch: 4.1.0-beta.10
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- utf-8-validate
|
||||
|
||||
path-to-regexp@6.3.0: {}
|
||||
|
||||
pathe@2.0.3: {}
|
||||
|
||||
semver@7.8.0: {}
|
||||
|
||||
sharp@0.34.5:
|
||||
dependencies:
|
||||
'@img/colour': 1.1.0
|
||||
detect-libc: 2.1.2
|
||||
semver: 7.8.0
|
||||
optionalDependencies:
|
||||
'@img/sharp-darwin-arm64': 0.34.5
|
||||
'@img/sharp-darwin-x64': 0.34.5
|
||||
'@img/sharp-libvips-darwin-arm64': 1.2.4
|
||||
'@img/sharp-libvips-darwin-x64': 1.2.4
|
||||
'@img/sharp-libvips-linux-arm': 1.2.4
|
||||
'@img/sharp-libvips-linux-arm64': 1.2.4
|
||||
'@img/sharp-libvips-linux-ppc64': 1.2.4
|
||||
'@img/sharp-libvips-linux-riscv64': 1.2.4
|
||||
'@img/sharp-libvips-linux-s390x': 1.2.4
|
||||
'@img/sharp-libvips-linux-x64': 1.2.4
|
||||
'@img/sharp-libvips-linuxmusl-arm64': 1.2.4
|
||||
'@img/sharp-libvips-linuxmusl-x64': 1.2.4
|
||||
'@img/sharp-linux-arm': 0.34.5
|
||||
'@img/sharp-linux-arm64': 0.34.5
|
||||
'@img/sharp-linux-ppc64': 0.34.5
|
||||
'@img/sharp-linux-riscv64': 0.34.5
|
||||
'@img/sharp-linux-s390x': 0.34.5
|
||||
'@img/sharp-linux-x64': 0.34.5
|
||||
'@img/sharp-linuxmusl-arm64': 0.34.5
|
||||
'@img/sharp-linuxmusl-x64': 0.34.5
|
||||
'@img/sharp-wasm32': 0.34.5
|
||||
'@img/sharp-win32-arm64': 0.34.5
|
||||
'@img/sharp-win32-ia32': 0.34.5
|
||||
'@img/sharp-win32-x64': 0.34.5
|
||||
|
||||
supports-color@10.2.2: {}
|
||||
|
||||
tslib@2.8.1:
|
||||
optional: true
|
||||
|
||||
undici@7.24.8: {}
|
||||
|
||||
unenv@2.0.0-rc.24:
|
||||
dependencies:
|
||||
pathe: 2.0.3
|
||||
|
||||
workerd@1.20260507.1:
|
||||
optionalDependencies:
|
||||
'@cloudflare/workerd-darwin-64': 1.20260507.1
|
||||
'@cloudflare/workerd-darwin-arm64': 1.20260507.1
|
||||
'@cloudflare/workerd-linux-64': 1.20260507.1
|
||||
'@cloudflare/workerd-linux-arm64': 1.20260507.1
|
||||
'@cloudflare/workerd-windows-64': 1.20260507.1
|
||||
|
||||
wrangler@4.90.0(@cloudflare/workers-types@4.20260511.1):
|
||||
dependencies:
|
||||
'@cloudflare/kv-asset-handler': 0.5.0
|
||||
'@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260507.1)
|
||||
blake3-wasm: 2.1.5
|
||||
esbuild: 0.27.3
|
||||
miniflare: 4.20260507.1
|
||||
path-to-regexp: 6.3.0
|
||||
unenv: 2.0.0-rc.24
|
||||
workerd: 1.20260507.1
|
||||
optionalDependencies:
|
||||
'@cloudflare/workers-types': 4.20260511.1
|
||||
fsevents: 2.3.3
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- utf-8-validate
|
||||
|
||||
ws@8.18.0: {}
|
||||
|
||||
youch-core@0.3.3:
|
||||
dependencies:
|
||||
'@poppinss/exception': 1.2.3
|
||||
error-stack-parser-es: 1.0.5
|
||||
|
||||
youch@4.1.0-beta.10:
|
||||
dependencies:
|
||||
'@poppinss/colors': 4.1.6
|
||||
'@poppinss/dumper': 0.6.5
|
||||
'@speed-highlight/core': 1.2.15
|
||||
cookie: 1.1.1
|
||||
youch-core: 0.3.3
|
||||
@@ -0,0 +1,152 @@
|
||||
import { Hono } from "hono";
|
||||
import { cors } from "hono/cors";
|
||||
|
||||
type Env = {
|
||||
Bindings: {
|
||||
ENDPOINTS: KVNamespace;
|
||||
GATEWAY_SECRET: string;
|
||||
DASHBOARD_API_KEY: string;
|
||||
};
|
||||
};
|
||||
|
||||
type EndpointRecord = {
|
||||
name: string;
|
||||
url: string;
|
||||
agentToken: string;
|
||||
registeredAt: number;
|
||||
lastHeartbeat: number;
|
||||
};
|
||||
|
||||
const TTL_SECONDS = 300; // 5 min — offline if no heartbeat
|
||||
|
||||
const app = new Hono<Env>();
|
||||
|
||||
app.use("*", cors());
|
||||
|
||||
function checkDashboardAuth(c: {
|
||||
req: { header: (n: string) => string | undefined; query: (n: string) => string | undefined };
|
||||
env: Env["Bindings"];
|
||||
}): boolean {
|
||||
const bearer = c.req.header("Authorization")?.replace("Bearer ", "");
|
||||
const query = c.req.query("key");
|
||||
const key = bearer ?? query;
|
||||
return key === c.env.DASHBOARD_API_KEY;
|
||||
}
|
||||
|
||||
// ── Health ──────────────────────────────────────────────────────────
|
||||
app.get("/healthz", (c) => c.json({ ok: true }));
|
||||
|
||||
// ── Gateway management (GATEWAY_SECRET auth) ────────────────────────
|
||||
const gateway = new Hono<Env>();
|
||||
|
||||
gateway.post("/register", async (c) => {
|
||||
const body = await c.req.json<{
|
||||
name?: string;
|
||||
url?: string;
|
||||
secret?: string;
|
||||
agentToken?: string;
|
||||
}>();
|
||||
const { name, url, secret, agentToken } = body;
|
||||
|
||||
if (!name || !url) {
|
||||
return c.json({ error: "name and url required" }, 400);
|
||||
}
|
||||
if (secret !== c.env.GATEWAY_SECRET) {
|
||||
return c.json({ error: "unauthorized" }, 401);
|
||||
}
|
||||
|
||||
const existing = await c.env.ENDPOINTS.get<EndpointRecord>(name, "json");
|
||||
const now = Date.now();
|
||||
|
||||
const record: EndpointRecord = {
|
||||
name,
|
||||
url: url.replace(/\/+$/, ""), // strip trailing slash
|
||||
agentToken: agentToken ?? existing?.agentToken ?? "",
|
||||
registeredAt: existing?.registeredAt ?? now,
|
||||
lastHeartbeat: now,
|
||||
};
|
||||
|
||||
await c.env.ENDPOINTS.put(name, JSON.stringify(record), {
|
||||
expirationTtl: TTL_SECONDS,
|
||||
});
|
||||
|
||||
const status = existing ? 200 : 201;
|
||||
return c.json({ registered: name }, status);
|
||||
});
|
||||
|
||||
gateway.delete("/register/:name", async (c) => {
|
||||
const auth = c.req.header("Authorization");
|
||||
if (auth !== `Bearer ${c.env.GATEWAY_SECRET}`) {
|
||||
return c.json({ error: "unauthorized" }, 401);
|
||||
}
|
||||
|
||||
const name = c.req.param("name");
|
||||
await c.env.ENDPOINTS.delete(name);
|
||||
return c.json({ unregistered: name });
|
||||
});
|
||||
|
||||
// endpoints requires dashboard auth
|
||||
gateway.get("/endpoints", async (c) => {
|
||||
if (!checkDashboardAuth(c)) return c.json({ error: "unauthorized" }, 401);
|
||||
|
||||
const list = await c.env.ENDPOINTS.list();
|
||||
const endpoints: Array<{ name: string; url: string; status: string; lastHeartbeat: number }> = [];
|
||||
|
||||
for (const key of list.keys) {
|
||||
const record = await c.env.ENDPOINTS.get<EndpointRecord>(key.name, "json");
|
||||
if (record) {
|
||||
const age = Date.now() - record.lastHeartbeat;
|
||||
endpoints.push({
|
||||
name: record.name,
|
||||
url: record.url,
|
||||
status: age < TTL_SECONDS * 1000 ? "online" : "offline",
|
||||
lastHeartbeat: record.lastHeartbeat,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return c.json(endpoints);
|
||||
});
|
||||
|
||||
app.route("/api/gateway", gateway);
|
||||
|
||||
// ── API proxy: /api/agents/:agent/* → agent's tunnel URL (dashboard auth) ──
|
||||
app.all("/api/agents/:agent/*", async (c) => {
|
||||
if (!checkDashboardAuth(c)) return c.json({ error: "unauthorized" }, 401);
|
||||
const agent = c.req.param("agent");
|
||||
const record = await c.env.ENDPOINTS.get<EndpointRecord>(agent, "json");
|
||||
|
||||
if (!record) {
|
||||
return c.json({ error: "agent not found" }, 404);
|
||||
}
|
||||
|
||||
// Build target URL: strip /api/:agent prefix, forward the rest
|
||||
const url = new URL(c.req.url);
|
||||
const pathAfterAgent = url.pathname.replace(`/api/agents/${agent}`, "");
|
||||
const targetUrl = `${record.url}/api${pathAfterAgent}${url.search}`;
|
||||
|
||||
const headers = new Headers(c.req.raw.headers);
|
||||
headers.delete("host");
|
||||
headers.delete("Authorization"); // don't forward dashboard key to agent
|
||||
if (record.agentToken) {
|
||||
headers.set("X-Agent-Token", record.agentToken);
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await fetch(targetUrl, {
|
||||
method: c.req.method,
|
||||
headers,
|
||||
body: c.req.method !== "GET" && c.req.method !== "HEAD" ? c.req.raw.body : undefined,
|
||||
});
|
||||
|
||||
// Stream response back
|
||||
return new Response(resp.body, {
|
||||
status: resp.status,
|
||||
headers: resp.headers,
|
||||
});
|
||||
} catch (err) {
|
||||
return c.json({ error: "agent unreachable", detail: String(err) }, 502);
|
||||
}
|
||||
});
|
||||
|
||||
export default app;
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "bundler",
|
||||
"types": ["@cloudflare/workers-types"],
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
name = "workflow-gateway"
|
||||
main = "src/index.ts"
|
||||
compatibility_date = "2025-04-01"
|
||||
|
||||
[[kv_namespaces]]
|
||||
binding = "ENDPOINTS"
|
||||
id = "88b118d1cfab4c049f9c1684848811a3"
|
||||
|
||||
# GATEWAY_SECRET is set via `wrangler secret put`
|
||||
@@ -0,0 +1,152 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { tableToModerator } from "../src/moderator-table.js";
|
||||
import type { ModeratorContext, ModeratorTable, StartStep } from "../src/types.js";
|
||||
import { END, START } from "../src/types.js";
|
||||
|
||||
type TestMeta = {
|
||||
planner: { plan: string };
|
||||
coder: { code: string };
|
||||
reviewer: { approved: boolean };
|
||||
};
|
||||
|
||||
function makeCtx(roles: (keyof TestMeta & string)[]): ModeratorContext<TestMeta> {
|
||||
const steps = roles.map((role, i) => ({
|
||||
role,
|
||||
meta: {} as TestMeta[typeof role],
|
||||
contentHash: `hash-${i}`,
|
||||
refs: [],
|
||||
timestamp: Date.now() + i,
|
||||
}));
|
||||
return {
|
||||
threadId: "test-thread",
|
||||
depth: 0,
|
||||
start: {
|
||||
role: START,
|
||||
content: "test",
|
||||
meta: {},
|
||||
timestamp: Date.now(),
|
||||
} as StartStep,
|
||||
steps,
|
||||
};
|
||||
}
|
||||
|
||||
describe("tableToModerator", () => {
|
||||
test("START -> role A (FALLBACK) returns A on first call", () => {
|
||||
const table: ModeratorTable<TestMeta> = {
|
||||
[START]: [{ condition: "FALLBACK", role: "planner" }],
|
||||
planner: [],
|
||||
coder: [],
|
||||
reviewer: [],
|
||||
};
|
||||
const mod = tableToModerator(table);
|
||||
expect(mod(makeCtx([]))).toBe("planner");
|
||||
});
|
||||
|
||||
test("condition true wins over FALLBACK", () => {
|
||||
const table: ModeratorTable<TestMeta> = {
|
||||
[START]: [
|
||||
{
|
||||
condition: {
|
||||
name: "always",
|
||||
description: "always true",
|
||||
check: () => true,
|
||||
},
|
||||
role: "planner",
|
||||
},
|
||||
{ condition: "FALLBACK", role: "coder" },
|
||||
],
|
||||
planner: [],
|
||||
coder: [],
|
||||
reviewer: [],
|
||||
};
|
||||
const mod = tableToModerator(table);
|
||||
expect(mod(makeCtx([]))).toBe("planner");
|
||||
});
|
||||
|
||||
test("condition false falls through to FALLBACK", () => {
|
||||
const table: ModeratorTable<TestMeta> = {
|
||||
[START]: [
|
||||
{
|
||||
condition: {
|
||||
name: "never",
|
||||
description: "always false",
|
||||
check: () => false,
|
||||
},
|
||||
role: "planner",
|
||||
},
|
||||
{ condition: "FALLBACK", role: "coder" },
|
||||
],
|
||||
planner: [],
|
||||
coder: [],
|
||||
reviewer: [],
|
||||
};
|
||||
const mod = tableToModerator(table);
|
||||
expect(mod(makeCtx([]))).toBe("coder");
|
||||
});
|
||||
|
||||
test("no matching transitions returns END", () => {
|
||||
const table: ModeratorTable<TestMeta> = {
|
||||
[START]: [
|
||||
{
|
||||
condition: {
|
||||
name: "never",
|
||||
description: "always false",
|
||||
check: () => false,
|
||||
},
|
||||
role: "planner",
|
||||
},
|
||||
],
|
||||
planner: [],
|
||||
coder: [],
|
||||
reviewer: [],
|
||||
};
|
||||
const mod = tableToModerator(table);
|
||||
expect(mod(makeCtx([]))).toBe(END);
|
||||
});
|
||||
|
||||
test("multi-step: A -> FALLBACK END returns END after A", () => {
|
||||
const table: ModeratorTable<TestMeta> = {
|
||||
[START]: [{ condition: "FALLBACK", role: "planner" }],
|
||||
planner: [{ condition: "FALLBACK", role: END }],
|
||||
coder: [],
|
||||
reviewer: [],
|
||||
};
|
||||
const mod = tableToModerator(table);
|
||||
expect(mod(makeCtx(["planner"]))).toBe(END);
|
||||
});
|
||||
|
||||
test("role not in table returns END", () => {
|
||||
const table: ModeratorTable<TestMeta> = {
|
||||
[START]: [{ condition: "FALLBACK", role: "planner" }],
|
||||
planner: [{ condition: "FALLBACK", role: "coder" }],
|
||||
coder: [],
|
||||
reviewer: [],
|
||||
};
|
||||
const mod = tableToModerator(table);
|
||||
// coder has empty transitions array
|
||||
expect(mod(makeCtx(["planner", "coder"]))).toBe(END);
|
||||
});
|
||||
|
||||
test("condition receives ctx", () => {
|
||||
const table: ModeratorTable<TestMeta> = {
|
||||
[START]: [
|
||||
{
|
||||
condition: {
|
||||
name: "has-steps",
|
||||
description: "checks ctx.steps",
|
||||
check: (ctx) => ctx.steps.length > 0,
|
||||
},
|
||||
role: "coder",
|
||||
},
|
||||
{ condition: "FALLBACK", role: "planner" },
|
||||
],
|
||||
planner: [],
|
||||
coder: [],
|
||||
reviewer: [],
|
||||
};
|
||||
const mod = tableToModerator(table);
|
||||
// No steps -> condition false -> FALLBACK -> planner
|
||||
expect(mod(makeCtx([]))).toBe("planner");
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,15 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-protocol",
|
||||
"version": "0.1.0",
|
||||
"version": "0.3.1",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./src/index.ts"
|
||||
},
|
||||
"./moderator-table.js": {
|
||||
"types": "./dist/moderator-table.d.ts",
|
||||
"import": "./src/moderator-table.ts"
|
||||
}
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
lockfileVersion: '9.0'
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
devDependencies:
|
||||
typescript:
|
||||
specifier: ^5.8.3
|
||||
version: 5.9.3
|
||||
zod:
|
||||
specifier: ^4.0.0
|
||||
version: 4.4.3
|
||||
|
||||
packages:
|
||||
|
||||
typescript@5.9.3:
|
||||
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
|
||||
engines: {node: '>=14.17'}
|
||||
hasBin: true
|
||||
|
||||
zod@4.4.3:
|
||||
resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==}
|
||||
|
||||
snapshots:
|
||||
|
||||
typescript@5.9.3: {}
|
||||
|
||||
zod@4.4.3: {}
|
||||
@@ -3,7 +3,6 @@
|
||||
export type StartNodePayload = {
|
||||
name: string;
|
||||
hash: string;
|
||||
maxRounds: number;
|
||||
depth: number;
|
||||
};
|
||||
|
||||
|
||||
@@ -14,12 +14,14 @@ export type {
|
||||
AgentContext,
|
||||
AgentFn,
|
||||
CasStore,
|
||||
ExtractContext,
|
||||
ExtractFn,
|
||||
ExtractResult,
|
||||
FALLBACK,
|
||||
LlmProvider,
|
||||
Moderator,
|
||||
ModeratorCondition,
|
||||
ModeratorContext,
|
||||
ModeratorTable,
|
||||
ModeratorTransition,
|
||||
ProviderConfig,
|
||||
ResolvedModel,
|
||||
Result,
|
||||
@@ -34,6 +36,8 @@ export type {
|
||||
WorkflowDefinition,
|
||||
WorkflowDescriptor,
|
||||
WorkflowFn,
|
||||
WorkflowGraph,
|
||||
WorkflowGraphEdge,
|
||||
WorkflowResult,
|
||||
WorkflowRoleDescriptor,
|
||||
WorkflowRoleSchema,
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { Moderator, ModeratorTable, RoleMeta } from "./types.js";
|
||||
import { END, START } from "./types.js";
|
||||
|
||||
export function tableToModerator<M extends RoleMeta>(table: ModeratorTable<M>): Moderator<M> {
|
||||
return (ctx) => {
|
||||
const lastStep = ctx.steps.length > 0 ? ctx.steps[ctx.steps.length - 1] : null;
|
||||
const currentRole: string = lastStep ? lastStep.role : START;
|
||||
|
||||
const transitions = (table as Record<string, (typeof table)[string]>)[currentRole];
|
||||
if (!transitions) {
|
||||
return END;
|
||||
}
|
||||
|
||||
for (const transition of transitions) {
|
||||
if (transition.condition === "FALLBACK" || transition.condition.check(ctx)) {
|
||||
return transition.role;
|
||||
}
|
||||
}
|
||||
|
||||
return END;
|
||||
};
|
||||
}
|
||||
@@ -27,9 +27,22 @@ export type WorkflowRoleDescriptor = {
|
||||
schema: WorkflowRoleSchema;
|
||||
};
|
||||
|
||||
/** Serializable routing edges derived from a moderator transition table. */
|
||||
export type WorkflowGraphEdge = {
|
||||
from: string;
|
||||
to: string;
|
||||
condition: string;
|
||||
conditionDescription: string | null;
|
||||
};
|
||||
|
||||
export type WorkflowGraph = {
|
||||
edges: readonly WorkflowGraphEdge[];
|
||||
};
|
||||
|
||||
export type WorkflowDescriptor = {
|
||||
description: string;
|
||||
roles: Record<string, WorkflowRoleDescriptor>;
|
||||
graph: WorkflowGraph;
|
||||
};
|
||||
|
||||
// ── Role & Thread ──────────────────────────────────────────────────
|
||||
@@ -46,7 +59,7 @@ export type RoleOutput = {
|
||||
export type StartStep = {
|
||||
role: typeof START;
|
||||
content: string;
|
||||
meta: { maxRounds: number };
|
||||
meta: Record<string, never>;
|
||||
timestamp: number;
|
||||
};
|
||||
|
||||
@@ -76,10 +89,6 @@ export type AgentContext<M extends RoleMeta = RoleMeta> = ModeratorContext<M> &
|
||||
};
|
||||
};
|
||||
|
||||
export type ExtractContext<M extends RoleMeta = RoleMeta> = AgentContext<M> & {
|
||||
agentContent: string;
|
||||
};
|
||||
|
||||
// ── Workflow Completion ────────────────────────────────────────────
|
||||
|
||||
export type WorkflowCompletion = {
|
||||
@@ -128,8 +137,7 @@ export type ExtractResult<T extends Record<string, unknown>> = {
|
||||
|
||||
export type ExtractFn = <T extends Record<string, unknown>>(
|
||||
schema: z.ZodType<T>,
|
||||
prompt: string,
|
||||
ctx: ExtractContext,
|
||||
contentHash: string,
|
||||
) => Promise<ExtractResult<T>>;
|
||||
|
||||
export type AgentFn = (ctx: AgentContext) => Promise<string>;
|
||||
@@ -154,7 +162,6 @@ export type WorkflowFn = (
|
||||
export type RoleDefinition<Meta extends Record<string, unknown>> = {
|
||||
description: string;
|
||||
systemPrompt: string;
|
||||
extractPrompt: string;
|
||||
schema: z.ZodType<Meta>;
|
||||
extractRefs: ((meta: Meta) => string[]) | null;
|
||||
};
|
||||
@@ -166,9 +173,31 @@ export type Moderator<M extends RoleMeta> = (
|
||||
export type WorkflowDefinition<M extends RoleMeta> = {
|
||||
description: string;
|
||||
roles: { [K in keyof M & string]: RoleDefinition<M[K]> };
|
||||
moderator: Moderator<M>;
|
||||
table: ModeratorTable<M>;
|
||||
};
|
||||
|
||||
// ── Declarative Moderator Table ────────────────────────────────────
|
||||
|
||||
export type ModeratorCondition<M extends RoleMeta> = {
|
||||
name: string;
|
||||
description: string;
|
||||
check: (ctx: ModeratorContext<M>) => boolean;
|
||||
};
|
||||
|
||||
export type FALLBACK = "FALLBACK";
|
||||
|
||||
export type ModeratorTransition<M extends RoleMeta> = {
|
||||
condition: ModeratorCondition<M> | FALLBACK;
|
||||
role: (keyof M & string) | typeof END;
|
||||
};
|
||||
|
||||
export type ModeratorTable<M extends RoleMeta> = Record<
|
||||
(keyof M & string) | typeof START,
|
||||
ModeratorTransition<M>[]
|
||||
>;
|
||||
|
||||
// ── Advance Outcome ────────────────────────────────────────────────
|
||||
|
||||
export type AdvanceOutcome<M extends RoleMeta> =
|
||||
| { kind: "complete"; completion: WorkflowCompletion }
|
||||
| { kind: "yield"; output: RoleOutput; step: RoleStep<M> };
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-reactor",
|
||||
"version": "0.1.0",
|
||||
"version": "0.3.1",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
|
||||
Generated
+36
@@ -0,0 +1,36 @@
|
||||
lockfileVersion: '9.0'
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@uncaged/workflow-protocol':
|
||||
specifier: workspace:*
|
||||
version: link:../workflow-protocol
|
||||
devDependencies:
|
||||
typescript:
|
||||
specifier: ^5.8.3
|
||||
version: 5.9.3
|
||||
zod:
|
||||
specifier: ^4.0.0
|
||||
version: 4.4.3
|
||||
|
||||
packages:
|
||||
|
||||
typescript@5.9.3:
|
||||
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
|
||||
engines: {node: '>=14.17'}
|
||||
hasBin: true
|
||||
|
||||
zod@4.4.3:
|
||||
resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==}
|
||||
|
||||
snapshots:
|
||||
|
||||
typescript@5.9.3: {}
|
||||
|
||||
zod@4.4.3: {}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-register",
|
||||
"version": "0.1.0",
|
||||
"version": "0.3.1",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
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
|
||||
devDependencies:
|
||||
acorn:
|
||||
specifier: ^8.14.1
|
||||
version: 8.16.0
|
||||
typescript:
|
||||
specifier: ^5.8.3
|
||||
version: 5.9.3
|
||||
yaml:
|
||||
specifier: ^2.7.1
|
||||
version: 2.8.4
|
||||
zod:
|
||||
specifier: ^4.0.0
|
||||
version: 4.4.3
|
||||
|
||||
packages:
|
||||
|
||||
acorn@8.16.0:
|
||||
resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
hasBin: true
|
||||
|
||||
typescript@5.9.3:
|
||||
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
|
||||
engines: {node: '>=14.17'}
|
||||
hasBin: true
|
||||
|
||||
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:
|
||||
|
||||
acorn@8.16.0: {}
|
||||
|
||||
typescript@5.9.3: {}
|
||||
|
||||
yaml@2.8.4: {}
|
||||
|
||||
zod@4.4.3: {}
|
||||
@@ -1,12 +1,35 @@
|
||||
import type { RoleMeta, WorkflowDefinition } from "@uncaged/workflow-protocol";
|
||||
import type {
|
||||
ModeratorTable,
|
||||
ModeratorTransition,
|
||||
RoleMeta,
|
||||
WorkflowDefinition,
|
||||
WorkflowDescriptor,
|
||||
WorkflowGraph,
|
||||
WorkflowGraphEdge,
|
||||
} from "@uncaged/workflow-protocol";
|
||||
import { END } from "@uncaged/workflow-protocol";
|
||||
import * as z from "zod/v4";
|
||||
import type { WorkflowDescriptor, WorkflowRoleSchema } from "./types.js";
|
||||
import type { WorkflowRoleSchema } from "./types.js";
|
||||
|
||||
function stripJsonSchemaMeta(json: Record<string, unknown>): WorkflowRoleSchema {
|
||||
const { $schema: _drop, ...rest } = json;
|
||||
return rest as WorkflowRoleSchema;
|
||||
}
|
||||
|
||||
function graphFromTable<M extends RoleMeta>(table: ModeratorTable<M>): WorkflowGraph {
|
||||
const edges: WorkflowGraphEdge[] = [];
|
||||
const entries = Object.entries(table) as Array<[string, ModeratorTransition<M>[]]>;
|
||||
for (const [from, transitions] of entries) {
|
||||
for (const t of transitions) {
|
||||
const conditionName = t.condition === "FALLBACK" ? "FALLBACK" : t.condition.name;
|
||||
const conditionDescription = t.condition === "FALLBACK" ? null : t.condition.description;
|
||||
const to = t.role === END ? END : t.role;
|
||||
edges.push({ from, to, condition: conditionName, conditionDescription });
|
||||
}
|
||||
}
|
||||
return { edges };
|
||||
}
|
||||
|
||||
export function buildDescriptor<M extends RoleMeta>(
|
||||
def: WorkflowDefinition<M>,
|
||||
): WorkflowDescriptor {
|
||||
@@ -20,5 +43,9 @@ export function buildDescriptor<M extends RoleMeta>(
|
||||
schema: stripJsonSchemaMeta(rawJsonSchema),
|
||||
};
|
||||
}
|
||||
return { description: def.description, roles };
|
||||
return {
|
||||
description: def.description,
|
||||
roles,
|
||||
graph: graphFromTable(def.table),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -40,7 +40,9 @@ function isAllowedImportSpecifier(spec: string): boolean {
|
||||
if (
|
||||
spec === "@uncaged/workflow" ||
|
||||
spec === "@uncaged/workflow-runtime" ||
|
||||
spec === "@uncaged/workflow-cas"
|
||||
spec === "@uncaged/workflow-protocol" ||
|
||||
spec === "@uncaged/workflow-cas" ||
|
||||
spec === "@uncaged/workflow-util"
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
@@ -402,7 +404,7 @@ export function validateWorkflowBundle(input: WorkflowBundleValidationInput): Re
|
||||
|
||||
if (!descriptorExportExists(program)) {
|
||||
return err(
|
||||
'workflow bundle must export descriptor (e.g. "export const descriptor = { description, roles }")',
|
||||
'workflow bundle must export descriptor (e.g. "export const descriptor = { description, roles, graph }")',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ export type {
|
||||
ExtractedBundleExports,
|
||||
WorkflowBundleValidationInput,
|
||||
WorkflowDescriptor,
|
||||
WorkflowGraph,
|
||||
WorkflowGraphEdge,
|
||||
WorkflowRoleDescriptor,
|
||||
WorkflowRoleSchema,
|
||||
} from "./types.js";
|
||||
|
||||
@@ -3,6 +3,8 @@ import type { WorkflowDescriptor, WorkflowFn } from "@uncaged/workflow-protocol"
|
||||
export type {
|
||||
WorkflowDescriptor,
|
||||
WorkflowFn,
|
||||
WorkflowGraph,
|
||||
WorkflowGraphEdge,
|
||||
WorkflowRoleDescriptor,
|
||||
WorkflowRoleSchema,
|
||||
} from "@uncaged/workflow-protocol";
|
||||
|
||||
@@ -1,6 +1,64 @@
|
||||
import { err, ok, type Result } from "@uncaged/workflow-util";
|
||||
|
||||
import type { WorkflowDescriptor, WorkflowRoleDescriptor, WorkflowRoleSchema } from "./types.js";
|
||||
import type {
|
||||
WorkflowDescriptor,
|
||||
WorkflowGraph,
|
||||
WorkflowGraphEdge,
|
||||
WorkflowRoleDescriptor,
|
||||
WorkflowRoleSchema,
|
||||
} from "./types.js";
|
||||
|
||||
function validateDescriptorGraphEdge(
|
||||
item: unknown,
|
||||
index: number,
|
||||
): Result<WorkflowGraphEdge, string> {
|
||||
if (item === null || typeof item !== "object" || Array.isArray(item)) {
|
||||
return err(`descriptor.graph.edges[${index}] must be a non-array object`);
|
||||
}
|
||||
const e = item as Record<string, unknown>;
|
||||
if (typeof e.from !== "string") {
|
||||
return err(`descriptor.graph.edges[${index}].from must be a string`);
|
||||
}
|
||||
if (typeof e.to !== "string") {
|
||||
return err(`descriptor.graph.edges[${index}].to must be a string`);
|
||||
}
|
||||
if (typeof e.condition !== "string") {
|
||||
return err(`descriptor.graph.edges[${index}].condition must be a string`);
|
||||
}
|
||||
const cdRaw = e.conditionDescription;
|
||||
if (cdRaw !== null && cdRaw !== undefined && typeof cdRaw !== "string") {
|
||||
return err(`descriptor.graph.edges[${index}].conditionDescription must be a string or null`);
|
||||
}
|
||||
const conditionDescription: string | null = cdRaw === undefined || cdRaw === null ? null : cdRaw;
|
||||
return ok({
|
||||
from: e.from,
|
||||
to: e.to,
|
||||
condition: e.condition,
|
||||
conditionDescription,
|
||||
});
|
||||
}
|
||||
|
||||
function validateDescriptorGraph(graphRaw: unknown): Result<WorkflowGraph, string> {
|
||||
if (graphRaw === null || typeof graphRaw !== "object" || Array.isArray(graphRaw)) {
|
||||
return err("descriptor.graph must be a non-array object");
|
||||
}
|
||||
const graphRecord = graphRaw as Record<string, unknown>;
|
||||
const edgesRaw = graphRecord.edges;
|
||||
if (!Array.isArray(edgesRaw)) {
|
||||
return err("descriptor.graph.edges must be an array");
|
||||
}
|
||||
|
||||
const edges: WorkflowGraphEdge[] = [];
|
||||
for (let i = 0; i < edgesRaw.length; i++) {
|
||||
const edgeResult = validateDescriptorGraphEdge(edgesRaw[i], i);
|
||||
if (!edgeResult.ok) {
|
||||
return edgeResult;
|
||||
}
|
||||
edges.push(edgeResult.value);
|
||||
}
|
||||
|
||||
return ok({ edges });
|
||||
}
|
||||
|
||||
export function validateWorkflowDescriptor(value: unknown): Result<WorkflowDescriptor, string> {
|
||||
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
||||
@@ -36,5 +94,10 @@ export function validateWorkflowDescriptor(value: unknown): Result<WorkflowDescr
|
||||
};
|
||||
}
|
||||
|
||||
return ok({ description, roles });
|
||||
const graphResult = validateDescriptorGraph(root.graph);
|
||||
if (!graphResult.ok) {
|
||||
return graphResult;
|
||||
}
|
||||
|
||||
return ok({ description, roles, graph: graphResult.value });
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ export type {
|
||||
ExtractedBundleExports,
|
||||
WorkflowBundleValidationInput,
|
||||
WorkflowDescriptor,
|
||||
WorkflowGraph,
|
||||
WorkflowGraphEdge,
|
||||
WorkflowRoleDescriptor,
|
||||
WorkflowRoleSchema,
|
||||
} from "./bundle/index.js";
|
||||
|
||||
@@ -27,7 +27,7 @@ describe("buildThreadContext", () => {
|
||||
const bundleHash = "BHAAAAAAAAAAA";
|
||||
const startHash = await putStartNode(
|
||||
cas,
|
||||
{ name: "demo", hash: bundleHash, maxRounds: 99, depth: 2 },
|
||||
{ name: "demo", hash: bundleHash, depth: 2 },
|
||||
promptHash,
|
||||
);
|
||||
|
||||
@@ -59,7 +59,6 @@ describe("buildThreadContext", () => {
|
||||
expect(ctx.depth).toBe(2);
|
||||
expect(ctx.start.role).toBe(START);
|
||||
expect(ctx.start.content).toBe("hello-task");
|
||||
expect(ctx.start.meta.maxRounds).toBe(99);
|
||||
expect(ctx.steps.map((s) => s.role)).toEqual(["planner", "coder"]);
|
||||
expect(ctx.steps[0]?.refs).toEqual([art]);
|
||||
expect(ctx.steps[1]?.refs).toEqual([]);
|
||||
@@ -72,7 +71,7 @@ describe("buildThreadContext", () => {
|
||||
const promptHash = await cas.put("only-prompt");
|
||||
const startHash = await putStartNode(
|
||||
cas,
|
||||
{ name: "solo", hash: "BHBBBBBBBBBBB", maxRounds: 3, depth: 1 },
|
||||
{ name: "solo", hash: "BHBBBBBBBBBBB", depth: 1 },
|
||||
promptHash,
|
||||
);
|
||||
|
||||
@@ -80,7 +79,6 @@ describe("buildThreadContext", () => {
|
||||
expect(ctx.steps).toEqual([]);
|
||||
expect(ctx.start.content).toBe("only-prompt");
|
||||
expect(ctx.depth).toBe(1);
|
||||
expect(ctx.start.meta.maxRounds).toBe(3);
|
||||
});
|
||||
|
||||
test("omits __end__ states from steps", async () => {
|
||||
@@ -89,7 +87,7 @@ describe("buildThreadContext", () => {
|
||||
const bundleHash = "BHCCCCCCCCCCC";
|
||||
const startHash = await putStartNode(
|
||||
cas,
|
||||
{ name: "demo", hash: bundleHash, maxRounds: 10, depth: 0 },
|
||||
{ name: "demo", hash: bundleHash, depth: 0 },
|
||||
promptHash,
|
||||
);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-runtime",
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.1",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
|
||||
Generated
+29
@@ -0,0 +1,29 @@
|
||||
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
|
||||
devDependencies:
|
||||
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: {}
|
||||
@@ -57,7 +57,7 @@ async function threadFromStartHead<M extends RoleMeta>(
|
||||
start: {
|
||||
role: START,
|
||||
content: prompt,
|
||||
meta: { maxRounds: p.maxRounds },
|
||||
meta: {},
|
||||
timestamp: 0,
|
||||
},
|
||||
steps: [],
|
||||
@@ -116,7 +116,7 @@ async function threadFromStateHead<M extends RoleMeta>(
|
||||
start: {
|
||||
role: START,
|
||||
content: prompt,
|
||||
meta: { maxRounds: sp.maxRounds },
|
||||
meta: {},
|
||||
timestamp: firstTs,
|
||||
},
|
||||
steps,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user