Compare commits
81 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 019d8c1ee9 | |||
| 5e783e7a24 | |||
| a450a88b16 | |||
| 5b47317cef | |||
| 3384c38d02 | |||
| b370d96504 | |||
| 8cae114c7e | |||
| c2c6fc5304 | |||
| 94f725c50b | |||
| 9b23e6f85a | |||
| 238a94f7a6 | |||
| 236c771e4e | |||
| 0ffd84cf7d | |||
| e14643a50b | |||
| 76830c5e22 | |||
| 90a388f5ab | |||
| 82e40f0c21 | |||
| 8d650326db | |||
| dd3eec7d35 | |||
| 9276689cb6 | |||
| b4584cbaa6 | |||
| 1cf963a1fb | |||
| ce5bc50210 | |||
| 439e203113 | |||
| 522afdd4bd | |||
| ca644dabaa | |||
| 9d9c00df98 | |||
| a1c5dc3e92 | |||
| c85980f604 | |||
| eff5fb332a | |||
| 658a4a24ef | |||
| aabfd90a87 | |||
| 0207f93303 | |||
| e1423f196b | |||
| ae6954a02f | |||
| aede8f7613 | |||
| 6d1e0498ba | |||
| 6cce5e2593 | |||
| d3a7ed9062 | |||
| e7f733c393 | |||
| d4bb4a9324 | |||
| e4900b6fd6 | |||
| 39540d9ae8 | |||
| 10899364d4 | |||
| dc5fdd7358 | |||
| bb1293f6b9 | |||
| 55b3b61498 | |||
| 484ed520cd | |||
| 497f03c747 | |||
| cfe4543d39 | |||
| 399b967c59 | |||
| 061926b86a | |||
| acb0ebed97 | |||
| d5d7be6100 | |||
| 1566a43395 | |||
| afbde4573a | |||
| 63e447fc3d | |||
| 34fcbf29cb | |||
| 256799fcfd | |||
| 21cf3db111 | |||
| ed38543db4 | |||
| 78771fbebc | |||
| c15f58bdeb | |||
| 6d4bf108bb | |||
| 5b7c9b844b | |||
| f0d1bb9ae8 | |||
| 04cfd33f99 | |||
| a8c00f169b | |||
| c4d34530e8 | |||
| 90a410c00a | |||
| 6276ca5a4a | |||
| 8e63f99eb6 | |||
| 9ca70bbb69 | |||
| ed1f38c7da | |||
| 1664d68b50 | |||
| 1871ef31b4 | |||
| ec3c97b200 | |||
| 18e3dc7603 | |||
| fc229cac79 | |||
| ec555b43d1 | |||
| c8de86d7c9 |
@@ -0,0 +1,8 @@
|
||||
# Changesets
|
||||
|
||||
Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
|
||||
with multi-package repos, or single-package repos to help you version and publish your code. You can
|
||||
find the full documentation for it [in our repository](https://github.com/changesets/changesets).
|
||||
|
||||
We have a quick list of common questions to get you started engaging with this project in
|
||||
[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md).
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"$schema": "https://unpkg.com/@changesets/config@3.1.4/schema.json",
|
||||
"changelog": "@changesets/cli/changelog",
|
||||
"commit": false,
|
||||
"fixed": [["@uncaged/*"]],
|
||||
"linked": [],
|
||||
"access": "public",
|
||||
"baseBranch": "main",
|
||||
"updateInternalDependencies": "patch",
|
||||
"ignore": ["@uncaged/workflow-dashboard"]
|
||||
}
|
||||
Executable
+6
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
# pre-push hook: typecheck + biome + lint-log-tags
|
||||
set -euo pipefail
|
||||
echo "🔍 pre-push: running checks..."
|
||||
bun run check
|
||||
echo "✅ pre-push: all checks passed"
|
||||
@@ -5,3 +5,4 @@ bun.lock
|
||||
tsconfig.tsbuildinfo
|
||||
.npmrc
|
||||
|
||||
bunfig.toml
|
||||
|
||||
@@ -30,6 +30,7 @@ workflow/
|
||||
workflow-agent-cursor/ # @uncaged/workflow-agent-cursor
|
||||
workflow-agent-hermes/ # @uncaged/workflow-agent-hermes
|
||||
workflow-agent-llm/ # @uncaged/workflow-agent-llm
|
||||
workflow-agent-react/ # @uncaged/workflow-agent-react
|
||||
workflow-util-agent/ # @uncaged/workflow-util-agent — buildAgentPrompt, spawnCli
|
||||
workflow-template-develop/ # @uncaged/workflow-template-develop
|
||||
workflow-template-solve-issue/ # @uncaged/workflow-template-solve-issue
|
||||
@@ -40,7 +41,7 @@ workflow/
|
||||
```
|
||||
|
||||
- Execution stack layers: `workflow-protocol` → (`workflow-runtime`, `workflow-util`, `workflow-reactor`) → (`workflow-cas`, `workflow-register`) → `workflow-execute` → `cli-workflow`
|
||||
- Packages use `workspace:*` protocol
|
||||
- Packages use `workspace:^` protocol (resolves to `^x.y.z` on publish)
|
||||
|
||||
## Language & Paradigm
|
||||
|
||||
@@ -245,61 +246,47 @@ bun run format # biome format --write
|
||||
bun test # run tests
|
||||
```
|
||||
|
||||
### Publishing to Gitea npm Registry
|
||||
### Version Management & Publishing
|
||||
|
||||
All public `@uncaged/*` packages are published to the Gitea npm registry at `git.shazhou.work`. Workflow workspaces consume packages from this registry via `bunfig.toml`.
|
||||
All public `@uncaged/*` packages are published to **npmjs.org** via `@changesets/cli` with **fixed mode** (all packages share the same version number). `workflow-dashboard` is private and excluded.
|
||||
|
||||
```bash
|
||||
# Publish all packages (bun pm pack resolves workspace:* → actual versions)
|
||||
bun run publish:gitea
|
||||
# 1. After making changes, add a changeset describing the change
|
||||
bun changeset
|
||||
|
||||
# Dry run — see what would be published
|
||||
bun run publish:gitea:dry
|
||||
# 2. Before release, bump all package versions + generate CHANGELOGs
|
||||
bun version
|
||||
|
||||
# 3. Build, test, and publish to npmjs
|
||||
bun release
|
||||
```
|
||||
|
||||
Prerequisites: `.npmrc` in monorepo root with Gitea auth token (`//git.shazhou.work/api/packages/shazhou/npm/:_authToken=<token>`).
|
||||
- `workspace:^` dependencies resolve to `^x.y.z` on publish
|
||||
- Changesets config: `.changeset/config.json` (fixed mode, public access)
|
||||
- Each package has auto-generated `CHANGELOG.md`
|
||||
|
||||
### Workflow Workspace Setup
|
||||
### Consuming @uncaged/* Packages
|
||||
|
||||
External workflow repos (e.g. `xingyue-workflows`) use the Gitea registry for `@uncaged/*` packages. Add a `bunfig.toml`:
|
||||
|
||||
```toml
|
||||
[install.scopes]
|
||||
"@uncaged" = "https://git.shazhou.work/api/packages/shazhou/npm/"
|
||||
```
|
||||
|
||||
Then `bun install` resolves `@uncaged/*` from Gitea, all other packages from npmjs.
|
||||
|
||||
### Cross-repo Development (bun link)
|
||||
|
||||
Alternative for development against un-published local changes:
|
||||
|
||||
```bash
|
||||
bun run link # Register all packages (from monorepo root)
|
||||
bun run link:consume # Link into CWD's project (⚠️ don't bun install after)
|
||||
bun run link:unlink # Restore original deps
|
||||
```
|
||||
External workflow repos just `bun install` — packages come from npmjs like any other dependency. No special registry config needed.
|
||||
|
||||
### End-to-end: Monorepo → Registry → Workspace → Bundle
|
||||
|
||||
The recommended development flow for building workflows:
|
||||
|
||||
```
|
||||
workflow/ (monorepo) — engine, runtime, templates, agents
|
||||
│ bun run publish:gitea — auto topo-sort, bun pm pack → npm publish
|
||||
│ bun release — build + test + changeset publish
|
||||
▼
|
||||
git.shazhou.work npm registry — @uncaged/* scoped packages
|
||||
│ bun install — via bunfig.toml scoped registry
|
||||
npmjs.org — @uncaged/* scoped packages (public)
|
||||
│ bun install
|
||||
▼
|
||||
my-workflows/ (workspace) — bunfig.toml + normal package.json
|
||||
my-workflows/ (workspace) — normal package.json
|
||||
│ bun run build:develop — bun build → single .esm.js
|
||||
▼
|
||||
uncaged-workflow workflow add — register bundle locally
|
||||
uncaged-workflow run — execute workflow
|
||||
```
|
||||
|
||||
1. **Monorepo changes** → `bun run publish:gitea` (packages auto-discovered from `packages/*/`, topologically sorted, `workspace:*` resolved to real versions)
|
||||
2. **Workspace** → `bun install` fetches latest from Gitea, `bun install` is safe to run anytime
|
||||
1. **Monorepo changes** → `bun changeset` (describe change) → `bun version` (bump) → `bun release` (publish)
|
||||
2. **Workspace** → `bun install` fetches latest from npmjs
|
||||
3. **Build** → produces single-file ESM bundle with `@uncaged/*` as externals
|
||||
4. **Register & Run** → `uncaged-workflow workflow add <name> <bundle>` then `uncaged-workflow run <name>`
|
||||
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.14/schema.json",
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.15/schema.json",
|
||||
"files": {
|
||||
"includes": ["**", "!**/dist", "!**/node_modules", "!packages/workflow/workflow"]
|
||||
},
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
[test]
|
||||
pathIgnorePatterns = ["dist/**"]
|
||||
@@ -0,0 +1,15 @@
|
||||
import { createCursorAgent } from "./packages/workflow-agent-cursor/src/index.js";
|
||||
import { createWorkflow } from "./packages/workflow-runtime/src/create-workflow.js";
|
||||
import {
|
||||
buildDevelopDescriptor,
|
||||
developWorkflowDefinition,
|
||||
} from "./packages/workflow-template-develop/src/index.js";
|
||||
|
||||
const agent = createCursorAgent({
|
||||
command: "/home/azureuser/.local/bin/cursor-agent",
|
||||
model: "auto",
|
||||
timeout: 300_000,
|
||||
});
|
||||
|
||||
export const descriptor = buildDevelopDescriptor();
|
||||
export const run = createWorkflow(developWorkflowDefinition, { adapter: agent, overrides: null });
|
||||
+6
-6
@@ -6,18 +6,18 @@
|
||||
],
|
||||
"scripts": {
|
||||
"build": "bunx tsc --build",
|
||||
"check": "bunx tsc --build && biome check .",
|
||||
"check": "bunx tsc --build && biome check . && bash scripts/lint-log-tags.sh",
|
||||
"typecheck": "bunx tsc --build",
|
||||
"format": "biome format --write .",
|
||||
"test": "bun run --filter '*' test",
|
||||
"link": "./scripts/link-all.sh",
|
||||
"link:consume": "./scripts/link-all.sh --consume",
|
||||
"link:unlink": "./scripts/link-all.sh --unlink",
|
||||
"publish:gitea": "./scripts/publish-all.sh",
|
||||
"publish:gitea:dry": "./scripts/publish-all.sh --dry-run"
|
||||
"changeset": "bunx changeset",
|
||||
"version": "bunx changeset version",
|
||||
"release": "bun run build && bun test && npx changeset publish --no-git-tag"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.4.14",
|
||||
"@changesets/cli": "^2.31.0",
|
||||
"@types/node": "^25.7.0",
|
||||
"@types/xxhashjs": "^0.2.4",
|
||||
"bun-types": "^1.3.13"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
# @uncaged/cli-workflow
|
||||
|
||||
## 0.4.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-protocol@0.4.5
|
||||
- @uncaged/workflow-cas@0.4.5
|
||||
- @uncaged/workflow-execute@0.4.5
|
||||
- @uncaged/workflow-gateway@0.4.5
|
||||
- @uncaged/workflow-register@0.4.5
|
||||
- @uncaged/workflow-runtime@0.4.5
|
||||
- @uncaged/workflow-util@0.4.5
|
||||
|
||||
## 0.4.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-protocol@0.4.4
|
||||
- @uncaged/workflow-cas@0.4.4
|
||||
- @uncaged/workflow-execute@0.4.4
|
||||
- @uncaged/workflow-gateway@0.4.4
|
||||
- @uncaged/workflow-register@0.4.4
|
||||
- @uncaged/workflow-runtime@0.4.4
|
||||
- @uncaged/workflow-util@0.4.4
|
||||
|
||||
## 0.4.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Include src/ in published packages so bun runtime can resolve the 'bun' exports condition.
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-cas@0.4.3
|
||||
- @uncaged/workflow-execute@0.4.3
|
||||
- @uncaged/workflow-gateway@0.4.3
|
||||
- @uncaged/workflow-protocol@0.4.3
|
||||
- @uncaged/workflow-register@0.4.3
|
||||
- @uncaged/workflow-runtime@0.4.3
|
||||
- @uncaged/workflow-util@0.4.3
|
||||
|
||||
## 0.4.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fix workspace dependency resolution: use workspace:^ so published packages resolve to compatible versions instead of exact (non-existent) versions.
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-cas@0.4.2
|
||||
- @uncaged/workflow-execute@0.4.2
|
||||
- @uncaged/workflow-gateway@0.4.2
|
||||
- @uncaged/workflow-protocol@0.4.2
|
||||
- @uncaged/workflow-register@0.4.2
|
||||
- @uncaged/workflow-runtime@0.4.2
|
||||
- @uncaged/workflow-util@0.4.2
|
||||
|
||||
## 0.4.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Fix package exports for published packages and adopt changesets for version management.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-cas@0.4.0
|
||||
- @uncaged/workflow-execute@0.4.0
|
||||
- @uncaged/workflow-gateway@0.4.0
|
||||
- @uncaged/workflow-protocol@0.4.0
|
||||
- @uncaged/workflow-register@0.4.0
|
||||
- @uncaged/workflow-runtime@0.4.0
|
||||
- @uncaged/workflow-util@0.4.0
|
||||
+4
-4
@@ -2,14 +2,14 @@ import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { createContentMerkleNode, serializeMerkleNode } from "@uncaged/workflow-cas";
|
||||
|
||||
import { createApp } from "../src/commands/serve/app.js";
|
||||
import { createApp } from "../src/commands/connect/app.js";
|
||||
|
||||
function casStoredForm(raw: string): string {
|
||||
return serializeMerkleNode(createContentMerkleNode(raw));
|
||||
}
|
||||
|
||||
function buildApp(storageRoot: string) {
|
||||
const app = createApp(storageRoot);
|
||||
const app = createApp(storageRoot, null);
|
||||
return {
|
||||
fetch: (path: string, init?: RequestInit) =>
|
||||
app.fetch(new Request(`http://localhost${path}`, init)),
|
||||
@@ -115,7 +115,7 @@ describe("serve error handling", () => {
|
||||
});
|
||||
|
||||
test("global error handler returns 500 with JSON", async () => {
|
||||
const app = createApp("/tmp/uncaged-serve-test-nonexistent");
|
||||
const app = createApp("/tmp/uncaged-serve-test-nonexistent", null);
|
||||
app.get("/test-error", () => {
|
||||
throw new Error("boom");
|
||||
});
|
||||
@@ -128,7 +128,7 @@ describe("serve error handling", () => {
|
||||
|
||||
describe("serve security", () => {
|
||||
test("CORS headers present on responses", async () => {
|
||||
const app = createApp("/tmp/uncaged-serve-test-nonexistent");
|
||||
const app = createApp("/tmp/uncaged-serve-test-nonexistent", null);
|
||||
const res2 = await app.fetch(
|
||||
new Request("http://localhost/healthz", {
|
||||
headers: { Origin: "http://localhost:5173" },
|
||||
@@ -91,7 +91,7 @@ describe("init workspace", () => {
|
||||
"RoleDefinition",
|
||||
"WorkflowDefinition",
|
||||
"ModeratorTable",
|
||||
"AgentFn",
|
||||
"AdapterFn",
|
||||
"ExtractFn",
|
||||
"RoleMeta",
|
||||
]) {
|
||||
|
||||
@@ -70,10 +70,10 @@ const cliEntryPath = fileURLToPath(new URL("../src/cli.ts", import.meta.url));
|
||||
const abortablePlannerBundleSource = `${threadFixtureDescriptor}
|
||||
${wfPutImport}
|
||||
export const run = async function* (input, options) {
|
||||
await new Promise((r) => setTimeout(r, 600));
|
||||
const cas = options.cas;
|
||||
let h = await putContentMerkleNode(cas, "plan");
|
||||
yield { role: "planner", contentHash: h, meta: { plan: input.prompt }, refs: [h] };
|
||||
await new Promise((r) => setTimeout(r, 10000));
|
||||
h = await putContentMerkleNode(cas, "code");
|
||||
yield { role: "coder", contentHash: h, meta: { diff: "y" }, refs: [h] };
|
||||
return { returnCode: 0, summary: "done" };
|
||||
|
||||
@@ -1,21 +1,30 @@
|
||||
{
|
||||
"name": "@uncaged/cli-workflow",
|
||||
"version": "0.3.5",
|
||||
"version": "0.4.5",
|
||||
"files": [
|
||||
"src",
|
||||
"dist",
|
||||
"package.json"
|
||||
],
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"uncaged-workflow": "src/cli.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/workflow-protocol": "workspace:*",
|
||||
"@uncaged/workflow-util": "workspace:*",
|
||||
"@uncaged/workflow-cas": "workspace:*",
|
||||
"@uncaged/workflow-execute": "workspace:*",
|
||||
"@uncaged/workflow-register": "workspace:*",
|
||||
"@uncaged/workflow-runtime": "workspace:*",
|
||||
"@uncaged/workflow-gateway": "workspace:^",
|
||||
"@uncaged/workflow-protocol": "workspace:^",
|
||||
"@uncaged/workflow-util": "workspace:^",
|
||||
"@uncaged/workflow-cas": "workspace:^",
|
||||
"@uncaged/workflow-execute": "workspace:^",
|
||||
"@uncaged/workflow-register": "workspace:^",
|
||||
"@uncaged/workflow-runtime": "workspace:^",
|
||||
"hono": "^4.12.18",
|
||||
"yaml": "^2.8.4"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "bun test"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { getCommandRegistry } from "./cli-registry.js";
|
||||
import { formatCliUsage as formatCliUsageWithGroups } from "./cli-usage.js";
|
||||
import { createCasDispatcher } from "./commands/cas/index.js";
|
||||
import { createInitDispatcher } from "./commands/init/index.js";
|
||||
import { dispatchServe } from "./commands/serve/index.js";
|
||||
import { dispatchConnect } from "./commands/connect/index.js";
|
||||
import { dispatchSetup } from "./commands/setup/index.js";
|
||||
import { createThreadDispatcher, dispatchLive, dispatchRun } from "./commands/thread/index.js";
|
||||
import { createWorkflowDispatcher } from "./commands/workflow/index.js";
|
||||
@@ -71,7 +71,7 @@ const COMMAND_TABLE: Record<string, DispatchFn> = {
|
||||
skill: dispatchSkill,
|
||||
run: dispatchRun,
|
||||
live: dispatchLive,
|
||||
serve: dispatchServe,
|
||||
connect: dispatchConnect,
|
||||
};
|
||||
|
||||
export async function runCli(storageRoot: string, argv: string[]): Promise<number> {
|
||||
|
||||
@@ -59,12 +59,12 @@ export function formatCliUsage(
|
||||
);
|
||||
lines.push("");
|
||||
|
||||
lines.push("Server:");
|
||||
lines.push("Gateway:");
|
||||
lines.push(
|
||||
...formatUsageCommandLines([
|
||||
{
|
||||
prefix: "serve [--port N] [--host ADDR]",
|
||||
description: "Start HTTP API server (default: 127.0.0.1:7860)",
|
||||
prefix: "connect [--name NAME] [--gateway URL]",
|
||||
description: "Connect to workflow gateway via WebSocket",
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
+5
-5
@@ -8,7 +8,7 @@ import { createWorkflowRoutes } from "./routes-workflow.js";
|
||||
|
||||
const MAX_BODY_SIZE = 1_048_576; // 1 MB
|
||||
|
||||
export function createApp(storageRoot: string, agentToken: string | null): Hono {
|
||||
export function createApp(storageRoot: string, clientToken: string | null): Hono {
|
||||
const app = new Hono();
|
||||
|
||||
app.onError((_err, c) => {
|
||||
@@ -37,11 +37,11 @@ export function createApp(storageRoot: string, agentToken: string | null): Hono
|
||||
await next();
|
||||
});
|
||||
|
||||
// ── Agent token auth (skip healthz) ───────────────────────────────
|
||||
if (agentToken !== null) {
|
||||
// ── Client token auth (skip healthz) ───────────────────────────────
|
||||
if (clientToken !== null) {
|
||||
app.use("/api/*", async (c, next) => {
|
||||
const token = c.req.header("X-Agent-Token");
|
||||
if (token !== agentToken) {
|
||||
const token = c.req.header("X-Client-Token");
|
||||
if (token !== clientToken) {
|
||||
return c.json({ error: "unauthorized" }, 401);
|
||||
}
|
||||
await next();
|
||||
@@ -0,0 +1,111 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { hostname as osHostname } from "node:os";
|
||||
import { ok, type Result } from "@uncaged/workflow-protocol";
|
||||
import { createLogger } from "@uncaged/workflow-util";
|
||||
|
||||
import { printCliLine } from "../../cli-output.js";
|
||||
import { createApp } from "./app.js";
|
||||
import { registerWithGateway, startHeartbeat, unregisterFromGateway } from "./gateway.js";
|
||||
import type { ConnectOptions } from "./types.js";
|
||||
import { startGatewayWsClient } from "./ws-client.js";
|
||||
|
||||
const DEFAULT_GATEWAY_URL = "https://workflow-gateway.shazhou.workers.dev";
|
||||
const HEARTBEAT_INTERVAL_MS = 60_000;
|
||||
|
||||
function requireNextArg(argv: string[], i: number, flag: string): Result<string, string> {
|
||||
const next = argv[i + 1];
|
||||
if (next === undefined) {
|
||||
return { ok: false, error: `${flag} requires a value` };
|
||||
}
|
||||
return ok(next);
|
||||
}
|
||||
|
||||
function parseConnectArgv(argv: string[]): Result<ConnectOptions, string> {
|
||||
let name = osHostname().split(".")[0].toLowerCase();
|
||||
let gatewayUrl = DEFAULT_GATEWAY_URL;
|
||||
const gatewaySecret = process.env.WORKFLOW_GATEWAY_SECRET ?? "";
|
||||
const stringFlags: Record<string, (v: string) => void> = {
|
||||
"--name": (v) => {
|
||||
name = v;
|
||||
},
|
||||
"--gateway": (v) => {
|
||||
gatewayUrl = v;
|
||||
},
|
||||
};
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
if (arg in stringFlags) {
|
||||
const r = requireNextArg(argv, i, arg);
|
||||
if (!r.ok) return r;
|
||||
stringFlags[arg](r.value);
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
return ok({ name, gatewayUrl, gatewaySecret });
|
||||
}
|
||||
|
||||
export async function dispatchConnect(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const parsed = parseConnectArgv(argv);
|
||||
if (!parsed.ok) {
|
||||
printCliLine(`error: ${parsed.error}`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
const options = parsed.value;
|
||||
|
||||
if (options.gatewaySecret === "") {
|
||||
printCliLine("error: WORKFLOW_GATEWAY_SECRET is required");
|
||||
return 1;
|
||||
}
|
||||
|
||||
const clientToken = randomUUID();
|
||||
const app = createApp(storageRoot, clientToken);
|
||||
|
||||
const log = createLogger({ sink: { kind: "stderr" } });
|
||||
const stopWsClient = startGatewayWsClient({
|
||||
gatewayUrl: options.gatewayUrl,
|
||||
name: options.name,
|
||||
secret: options.gatewaySecret,
|
||||
appFetch: app.fetch,
|
||||
log,
|
||||
});
|
||||
|
||||
printCliLine("connected to gateway via WebSocket");
|
||||
|
||||
// Register with gateway for discovery
|
||||
const registered = await registerWithGateway(
|
||||
options.gatewayUrl,
|
||||
options.name,
|
||||
`ws://${options.name}`,
|
||||
options.gatewaySecret,
|
||||
clientToken,
|
||||
);
|
||||
if (registered) {
|
||||
printCliLine(`registered with gateway as "${options.name}"`);
|
||||
}
|
||||
|
||||
const heartbeatTimer = startHeartbeat(
|
||||
options.gatewayUrl,
|
||||
options.name,
|
||||
`ws://${options.name}`,
|
||||
options.gatewaySecret,
|
||||
clientToken,
|
||||
HEARTBEAT_INTERVAL_MS,
|
||||
);
|
||||
|
||||
const cleanup = async () => {
|
||||
clearInterval(heartbeatTimer);
|
||||
stopWsClient();
|
||||
printCliLine("unregistering from gateway...");
|
||||
await unregisterFromGateway(options.gatewayUrl, options.name, options.gatewaySecret);
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.on("SIGINT", cleanup);
|
||||
process.on("SIGTERM", cleanup);
|
||||
|
||||
await new Promise(() => {});
|
||||
return 0;
|
||||
}
|
||||
+6
-40
@@ -1,51 +1,17 @@
|
||||
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,
|
||||
localUrl: string,
|
||||
secret: string,
|
||||
agentToken: string,
|
||||
clientToken: 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 }),
|
||||
body: JSON.stringify({ name, url: localUrl, secret, clientToken }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await resp.text();
|
||||
@@ -77,12 +43,12 @@ export async function unregisterFromGateway(
|
||||
export function startHeartbeat(
|
||||
gatewayUrl: string,
|
||||
name: string,
|
||||
tunnelUrl: string,
|
||||
localUrl: string,
|
||||
secret: string,
|
||||
agentToken: string,
|
||||
clientToken: string,
|
||||
intervalMs: number,
|
||||
): ReturnType<typeof setInterval> {
|
||||
return setInterval(() => {
|
||||
registerWithGateway(gatewayUrl, name, tunnelUrl, secret, agentToken).catch(() => {});
|
||||
registerWithGateway(gatewayUrl, name, localUrl, secret, clientToken).catch(() => {});
|
||||
}, intervalMs);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { dispatchConnect } from "./connect.js";
|
||||
export type { ConnectOptions } from "./types.js";
|
||||
@@ -0,0 +1,5 @@
|
||||
export type ConnectOptions = {
|
||||
name: string;
|
||||
gatewayUrl: string;
|
||||
gatewaySecret: string;
|
||||
};
|
||||
@@ -0,0 +1,162 @@
|
||||
import { parseWsRequestJson, type WsResponse } from "@uncaged/workflow-gateway/ws-protocol";
|
||||
import type { LogFn } from "@uncaged/workflow-util";
|
||||
|
||||
export type GatewayWsClientParams = {
|
||||
gatewayUrl: string;
|
||||
name: string;
|
||||
secret: string;
|
||||
appFetch: (request: Request) => Response | Promise<Response>;
|
||||
log: LogFn;
|
||||
};
|
||||
|
||||
const INITIAL_BACKOFF_MS = 1000;
|
||||
const MAX_BACKOFF_MS = 30_000;
|
||||
|
||||
export function buildGatewayWsConnectUrl(gatewayUrl: string, name: string, secret: string): string {
|
||||
const u = new URL(gatewayUrl);
|
||||
if (u.protocol === "https:") {
|
||||
u.protocol = "wss:";
|
||||
} else if (u.protocol === "http:") {
|
||||
u.protocol = "ws:";
|
||||
}
|
||||
u.pathname = "/ws/connect";
|
||||
u.search = "";
|
||||
u.searchParams.set("name", name);
|
||||
u.searchParams.set("secret", secret);
|
||||
return u.href;
|
||||
}
|
||||
|
||||
function headersToRecord(h: Headers): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const [k, v] of h) {
|
||||
out[k] = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function handleGatewayMessage(
|
||||
ws: WebSocket,
|
||||
raw: string,
|
||||
params: GatewayWsClientParams,
|
||||
): Promise<void> {
|
||||
const req = parseWsRequestJson(raw);
|
||||
if (req === null) {
|
||||
params.log("ZM8K2PQ1", "gateway WebSocket dropped non-request message");
|
||||
return;
|
||||
}
|
||||
const localUrl = `http://localhost${req.path}`;
|
||||
const headers = new Headers(req.headers);
|
||||
let resp: Response;
|
||||
try {
|
||||
resp = await params.appFetch(new Request(localUrl, {
|
||||
method: req.method,
|
||||
headers,
|
||||
body: req.body === null ? undefined : req.body,
|
||||
}));
|
||||
} catch (e) {
|
||||
params.log("R4N7BQ3C", `app.fetch failed: ${String(e)}`);
|
||||
const errBody: WsResponse = {
|
||||
id: req.id,
|
||||
status: 502,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ error: "local fetch failed", detail: String(e) }),
|
||||
};
|
||||
ws.send(JSON.stringify(errBody));
|
||||
return;
|
||||
}
|
||||
const bodyText = await resp.text();
|
||||
const headerRecord = headersToRecord(resp.headers);
|
||||
const out: WsResponse = {
|
||||
id: req.id,
|
||||
status: resp.status,
|
||||
headers: headerRecord,
|
||||
body: bodyText,
|
||||
};
|
||||
ws.send(JSON.stringify(out));
|
||||
}
|
||||
|
||||
/** Maintains a reverse WebSocket to the workflow gateway; reconnects with exponential backoff. */
|
||||
export function startGatewayWsClient(params: GatewayWsClientParams): () => void {
|
||||
const wsUrl = buildGatewayWsConnectUrl(params.gatewayUrl, params.name, params.secret);
|
||||
let socket: WebSocket | null = null;
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let stopped = false;
|
||||
let attempt = 0;
|
||||
|
||||
const clearReconnectTimer = (): void => {
|
||||
if (reconnectTimer !== null) {
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleReconnect = (): void => {
|
||||
if (stopped) {
|
||||
return;
|
||||
}
|
||||
clearReconnectTimer();
|
||||
const delayMs = Math.min(INITIAL_BACKOFF_MS * 2 ** attempt, MAX_BACKOFF_MS);
|
||||
attempt++;
|
||||
params.log("6CJX2R8P", `gateway WebSocket reconnect in ${delayMs}ms (attempt ${attempt})`);
|
||||
reconnectTimer = setTimeout(connect, delayMs);
|
||||
};
|
||||
|
||||
const connect = (): void => {
|
||||
if (stopped) {
|
||||
return;
|
||||
}
|
||||
clearReconnectTimer();
|
||||
params.log("2XK7HM9Q", "gateway WebSocket connecting...");
|
||||
try {
|
||||
socket = new WebSocket(wsUrl);
|
||||
} catch (e) {
|
||||
params.log("7NQW4HBT", `gateway WebSocket create failed: ${String(e)}`);
|
||||
scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
const ws = socket;
|
||||
|
||||
ws.addEventListener("open", () => {
|
||||
attempt = 0;
|
||||
params.log("4PWN3V82", "gateway WebSocket connected");
|
||||
});
|
||||
|
||||
ws.addEventListener("close", (ev) => {
|
||||
socket = null;
|
||||
params.log(
|
||||
"8QTR6ZKC",
|
||||
`gateway WebSocket closed code=${String(ev.code)} reason=${ev.reason} wasClean=${String(ev.wasClean)}`,
|
||||
);
|
||||
if (!stopped) {
|
||||
scheduleReconnect();
|
||||
}
|
||||
});
|
||||
|
||||
ws.addEventListener("error", () => {
|
||||
params.log("9BWS1M7F", "gateway WebSocket error");
|
||||
});
|
||||
|
||||
ws.addEventListener("message", (ev) => {
|
||||
const data = ev.data;
|
||||
if (typeof data !== "string") {
|
||||
params.log("T9W2K35H", "gateway WebSocket non-text frame ignored");
|
||||
return;
|
||||
}
|
||||
void handleGatewayMessage(ws, data, params).catch((e: unknown) => {
|
||||
params.log("V7KX2M9P", `gateway WebSocket handler error: ${String(e)}`);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
connect();
|
||||
|
||||
return (): void => {
|
||||
stopped = true;
|
||||
clearReconnectTimer();
|
||||
if (socket !== null && socket.readyState === WebSocket.OPEN) {
|
||||
socket.close(1000, "shutdown");
|
||||
}
|
||||
socket = null;
|
||||
};
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
|
||||
import { pathExists } from "../../fs-utils.js";
|
||||
import type { CmdInitWorkspaceSuccess } from "./types.js";
|
||||
import { validateWorkspaceSegment } from "./validate.js";
|
||||
|
||||
function rootPackageJson(workspaceName: string): string {
|
||||
return `${JSON.stringify(
|
||||
@@ -91,7 +90,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** + **ModeratorTable**),**不绑定**具体 Agent |
|
||||
| **Workflow instance** | \`workflows/\`(或单独包) | 把模板与运行时 **AgentFn** / **ExtractFn** 组合,产出可注册的 **单文件 ESM bundle**(\`run\` + \`descriptor\` 命名导出) |
|
||||
| **Workflow instance** | \`workflows/\`(或单独包) | 把模板与运行时 **AdapterFn** / **ExtractFn** 组合,产出可注册的 **单文件 ESM bundle**(\`run\` + \`descriptor\` 命名导出) |
|
||||
|
||||
Init 生成的骨架:\`templates/\` 下放可复用定义,\`workflows/\` 下放绑定与打包入口。
|
||||
|
||||
@@ -101,10 +100,10 @@ Init 生成的骨架:\`templates/\` 下放可复用定义,\`workflows/\` 下
|
||||
- **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**:从 CAS content hash 解析结构化数据(引擎与 Agent 都可使用)。
|
||||
- **AdapterFn**:接收系统提示词与 Zod schema,返回角色执行函数(RoleFn)。
|
||||
- **ExtractFn**:从 CAS content hash 解析结构化数据(引擎与 Adapter 都可使用)。
|
||||
|
||||
引擎循环简述:按 **ModeratorTable** 选下一角色 → **Agent** 产出文本 → **Extract** 写入 **meta** → 追加 step,重复直至 **END**。详见 \`docs/architecture.md\` 中的三阶段说明。
|
||||
引擎循环简述:按 **ModeratorTable** 选下一角色 → **Adapter** 产出 typed meta → 追加 step,重复直至 **END**。详见 \`docs/architecture.md\` 中的三阶段说明。
|
||||
|
||||
## 3. 开发流程
|
||||
|
||||
@@ -112,7 +111,7 @@ Init 生成的骨架:\`templates/\` 下放可复用定义,\`workflows/\` 下
|
||||
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\`。
|
||||
5. **实例化**:在 workflow 包中使用 \`createWorkflow(def, binding)\`(或项目约定的封装)绑定 **AdapterFn**;**ExtractFn** 由引擎从 **workflow.yaml** 注入 \`WorkflowRuntime\`。
|
||||
6. **构建**:打包为单个 **.esm.js** bundle,使用 **uncaged-workflow add** 注册。
|
||||
|
||||
## 4. 编码规范
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
export { createApp } from "./app.js";
|
||||
export { dispatchServe, startServer } from "./serve.js";
|
||||
export type { ServeOptions } from "./types.js";
|
||||
@@ -1,163 +0,0 @@
|
||||
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";
|
||||
|
||||
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,
|
||||
port: options.port,
|
||||
hostname: options.hostname,
|
||||
});
|
||||
|
||||
printCliLine(`uncaged-workflow API server listening on http://${server.hostname}:${server.port}`);
|
||||
}
|
||||
|
||||
function parsePortValue(value: string | undefined): Result<number, string> {
|
||||
if (value === undefined) {
|
||||
return err("--port requires a value");
|
||||
}
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (!Number.isFinite(parsed) || parsed < 0 || parsed > 65535) {
|
||||
return err(`invalid port: ${value}`);
|
||||
}
|
||||
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;
|
||||
port = portResult.value;
|
||||
i++;
|
||||
} 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, name, noTunnel, gatewayUrl, gatewaySecret });
|
||||
}
|
||||
|
||||
export async function dispatchServe(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const parsed = parseServeArgv(argv);
|
||||
if (!parsed.ok) {
|
||||
printCliLine(`error: ${parsed.error}`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
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(() => {});
|
||||
return 0;
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
export type ServeOptions = {
|
||||
port: number;
|
||||
hostname: string;
|
||||
name: string;
|
||||
noTunnel: boolean;
|
||||
gatewayUrl: string;
|
||||
gatewaySecret: string;
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { resolve as resolvePath } from "node:path";
|
||||
import { stdin as input, stdout as output } from "node:process";
|
||||
import { createInterface } from "node:readline/promises";
|
||||
import { resolve as resolvePath } from "node:path";
|
||||
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
|
||||
@@ -10,6 +10,7 @@ import { createLogger } from "@uncaged/workflow-util";
|
||||
import { printCliError, printCliLine, printCliWarn } from "../../cli-output.js";
|
||||
|
||||
const setupDispatchLog = createLogger({ sink: { kind: "stderr" } });
|
||||
|
||||
import { loadPresetProviders } from "./preset-providers.js";
|
||||
import { cmdSetup, printSetupSummary } from "./setup.js";
|
||||
import type { SetupCliArgs } from "./types.js";
|
||||
@@ -154,11 +155,67 @@ async function promptLine(
|
||||
return raw.trim();
|
||||
}
|
||||
|
||||
type SecretInputState = {
|
||||
buf: string;
|
||||
rawWasSet: boolean;
|
||||
onData: (chunk: string) => void;
|
||||
fulfill: (value: string) => void;
|
||||
};
|
||||
|
||||
function isLineTerminator(c: string): boolean {
|
||||
return c === "\n" || c === "\r" || c === "\u0004";
|
||||
}
|
||||
|
||||
function handleLineTerminator(state: SecretInputState): void {
|
||||
if (process.stdin.isTTY) {
|
||||
process.stdin.setRawMode(state.rawWasSet);
|
||||
}
|
||||
process.stdin.pause();
|
||||
process.stdin.removeListener("data", state.onData);
|
||||
process.stdout.write("\n");
|
||||
state.fulfill(state.buf.trim());
|
||||
}
|
||||
|
||||
function handleBackspace(state: SecretInputState): void {
|
||||
if (state.buf.length > 0) {
|
||||
state.buf = state.buf.slice(0, -1);
|
||||
process.stdout.write("\b \b");
|
||||
}
|
||||
}
|
||||
|
||||
function handleInterrupt(rawWasSet: boolean): void {
|
||||
if (process.stdin.isTTY) {
|
||||
process.stdin.setRawMode(rawWasSet);
|
||||
}
|
||||
process.exit(130);
|
||||
}
|
||||
|
||||
function isBackspace(c: string): boolean {
|
||||
return c === "\u007F" || c === "\b";
|
||||
}
|
||||
|
||||
/** Process a single character in secret input. Returns "done" to stop reading. */
|
||||
function processSecretChar(c: string, state: SecretInputState): "done" | "skip" | "append" {
|
||||
if (isLineTerminator(c)) {
|
||||
handleLineTerminator(state);
|
||||
return "done";
|
||||
}
|
||||
if (isBackspace(c)) {
|
||||
handleBackspace(state);
|
||||
return "skip";
|
||||
}
|
||||
if (c === "\u0003") {
|
||||
handleInterrupt(state.rawWasSet);
|
||||
}
|
||||
state.buf += c;
|
||||
process.stdout.write("*");
|
||||
return "append";
|
||||
}
|
||||
|
||||
/** Read a line with terminal echo disabled (for secrets). */
|
||||
async function promptSecret(label: string): Promise<string> {
|
||||
process.stdout.write(label);
|
||||
return new Promise((fulfill) => {
|
||||
let buf = "";
|
||||
const rawWasSet = process.stdin.isRaw;
|
||||
if (process.stdin.isTTY) {
|
||||
process.stdin.setRawMode(true);
|
||||
@@ -166,46 +223,22 @@ async function promptSecret(label: string): Promise<string> {
|
||||
process.stdin.resume();
|
||||
process.stdin.setEncoding("utf8");
|
||||
|
||||
const state: SecretInputState = { buf: "", rawWasSet, fulfill, onData: () => {} };
|
||||
|
||||
const onData = (chunk: string) => {
|
||||
for (const c of chunk.toString()) {
|
||||
if (c === "\n" || c === "\r" || c === "\u0004") {
|
||||
if (process.stdin.isTTY) {
|
||||
process.stdin.setRawMode(rawWasSet);
|
||||
}
|
||||
process.stdin.pause();
|
||||
process.stdin.removeListener("data", onData);
|
||||
process.stdout.write("\n");
|
||||
fulfill(buf.trim());
|
||||
return;
|
||||
}
|
||||
if (c === "\u007F" || c === "\b") {
|
||||
if (buf.length > 0) {
|
||||
buf = buf.slice(0, -1);
|
||||
process.stdout.write("\b \b");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (c === "\u0003") {
|
||||
if (process.stdin.isTTY) {
|
||||
process.stdin.setRawMode(rawWasSet);
|
||||
}
|
||||
process.exit(130);
|
||||
}
|
||||
buf += c;
|
||||
process.stdout.write("*");
|
||||
if (processSecretChar(c, state) === "done") return;
|
||||
}
|
||||
};
|
||||
|
||||
state.onData = onData;
|
||||
process.stdin.on("data", onData);
|
||||
});
|
||||
}
|
||||
|
||||
/** Fetch available models from an OpenAI-compatible /models endpoint. */
|
||||
async function fetchAvailableModels(
|
||||
baseUrl: string,
|
||||
apiKey: string,
|
||||
): Promise<string[]> {
|
||||
const url = baseUrl.replace(/\/+$/, "") + "/models";
|
||||
async function fetchAvailableModels(baseUrl: string, apiKey: string): Promise<string[]> {
|
||||
const url = `${baseUrl.replace(/\/+$/, "")}/models`;
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${apiKey}` },
|
||||
@@ -228,139 +261,158 @@ async function fetchAvailableModels(
|
||||
.filter((id) => !NON_CHAT_RE.test(id))
|
||||
.sort();
|
||||
} catch (e) {
|
||||
setupDispatchLog("V8NQ4JT6", `fetch models failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||
setupDispatchLog(
|
||||
"V8NQ4JT6",
|
||||
`fetch models failed: ${e instanceof Error ? e.message : String(e)}`,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
type PresetProvider = ReturnType<typeof loadPresetProviders>[number];
|
||||
|
||||
function printProviderMenu(presets: readonly PresetProvider[]): void {
|
||||
const numWidth = String(presets.length + 1).length;
|
||||
printCliLine("Select a provider:\n");
|
||||
for (let i = 0; i < presets.length; i++) {
|
||||
const p = presets.at(i);
|
||||
if (!p) continue;
|
||||
const num = String(i + 1).padStart(numWidth);
|
||||
printCliLine(` ${num}) ${p.label.padEnd(28)} ${p.baseUrl}`);
|
||||
}
|
||||
const customNum = String(presets.length + 1).padStart(numWidth);
|
||||
printCliLine(` ${customNum}) Custom (enter name and URL manually)`);
|
||||
printCliLine("");
|
||||
}
|
||||
|
||||
async function selectProvider(
|
||||
rl: { question: (q: string) => Promise<string> },
|
||||
presets: readonly PresetProvider[],
|
||||
): Promise<Result<{ provider: string; baseUrl: string }, string>> {
|
||||
const choice = await promptLine(rl, `Choose [1-${presets.length + 1}]: `);
|
||||
const choiceNum = Number.parseInt(choice, 10);
|
||||
if (Number.isNaN(choiceNum) || choiceNum < 1 || choiceNum > presets.length + 1) {
|
||||
return err(`invalid choice: ${choice}`);
|
||||
}
|
||||
|
||||
if (choiceNum <= presets.length) {
|
||||
const selected = presets.at(choiceNum - 1);
|
||||
if (!selected) return err(`invalid choice: ${choice}`);
|
||||
printCliLine(`\n → ${selected.label} (${selected.baseUrl})\n`);
|
||||
return ok({ provider: selected.name, baseUrl: selected.baseUrl });
|
||||
}
|
||||
|
||||
const provider = await promptLine(rl, "Provider name (e.g. my-proxy): ");
|
||||
if (provider === "") return err("provider name must not be empty");
|
||||
const baseUrl = await promptLine(rl, "OpenAI-compatible API base URL: ");
|
||||
if (baseUrl === "") return err("base URL must not be empty");
|
||||
return ok({ provider, baseUrl });
|
||||
}
|
||||
|
||||
function printModelList(models: string[]): void {
|
||||
const cols = process.stdout.columns || 80;
|
||||
const nw = String(models.length).length;
|
||||
const prefixLen = nw + 4;
|
||||
const maxModelLen = Math.max(...models.map((m) => m.length));
|
||||
const cellWidth = prefixLen + maxModelLen + 2;
|
||||
const numCols = Math.max(1, Math.floor(cols / cellWidth));
|
||||
for (let i = 0; i < models.length; i += numCols) {
|
||||
const cells: string[] = [];
|
||||
for (let j = i; j < Math.min(i + numCols, models.length); j++) {
|
||||
const num = String(j + 1).padStart(nw);
|
||||
const model = models.at(j) ?? "";
|
||||
cells.push(` ${num}) ${model.padEnd(maxModelLen + 2)}`);
|
||||
}
|
||||
printCliLine(cells.join(""));
|
||||
}
|
||||
}
|
||||
|
||||
async function selectModel(
|
||||
rl: { question: (q: string) => Promise<string> },
|
||||
models: string[],
|
||||
): Promise<Result<string, string>> {
|
||||
if (models.length > 0) {
|
||||
printCliLine(`\nAvailable models (${models.length}):\n`);
|
||||
printModelList(models);
|
||||
printCliLine(`\nChoose a number, or type a model name directly.`);
|
||||
const modelInput = await promptLine(rl, `Default model [1-${models.length}]: `);
|
||||
if (modelInput === "") return err("default model must not be empty");
|
||||
const modelNum = Number.parseInt(modelInput, 10);
|
||||
if (!Number.isNaN(modelNum) && modelNum >= 1 && modelNum <= models.length) {
|
||||
return ok(models.at(modelNum - 1) ?? modelInput);
|
||||
}
|
||||
return ok(modelInput);
|
||||
}
|
||||
|
||||
printCliWarn("Could not fetch models (API may not support /models endpoint).");
|
||||
const modelInput = await promptLine(rl, `Default model (e.g. qwen-plus, gpt-4o): `);
|
||||
if (modelInput === "") return err("default model must not be empty");
|
||||
return ok(modelInput);
|
||||
}
|
||||
|
||||
async function selectWorkspace(rl: {
|
||||
question: (q: string) => Promise<string>;
|
||||
}): Promise<string | null> {
|
||||
while (true) {
|
||||
const wsPath = await promptLine(
|
||||
rl,
|
||||
"\nWorkflow workspace path (default: ./workflows, type 'skip' to skip): ",
|
||||
);
|
||||
if (wsPath.toLowerCase() === "skip") return null;
|
||||
const candidate = wsPath === "" ? "./workflows" : wsPath;
|
||||
const resolved = resolvePath(process.cwd(), candidate);
|
||||
if (existsSync(resolved)) {
|
||||
printCliWarn(`directory already exists: ${resolved}`);
|
||||
printCliLine("Please enter a different path, or type 'skip' to skip.");
|
||||
continue;
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
function stripProviderPrefix(model: string): string {
|
||||
if (model.includes("/")) {
|
||||
return model.split("/").pop() ?? model;
|
||||
}
|
||||
return model;
|
||||
}
|
||||
|
||||
async function collectInteractiveSetup(): Promise<Result<SetupCliArgs, string>> {
|
||||
const rl = createInterface({ input, output });
|
||||
try {
|
||||
printCliLine("Configure the LLM provider that workflow agents will use.\n");
|
||||
|
||||
const presets = loadPresetProviders();
|
||||
const numWidth = String(presets.length + 1).length;
|
||||
printCliLine("Select a provider:\n");
|
||||
for (let i = 0; i < presets.length; i++) {
|
||||
const p = presets[i]!;
|
||||
const num = String(i + 1).padStart(numWidth);
|
||||
printCliLine(` ${num}) ${p.label.padEnd(28)} ${p.baseUrl}`);
|
||||
}
|
||||
const customNum = String(presets.length + 1).padStart(numWidth);
|
||||
printCliLine(` ${customNum}) Custom (enter name and URL manually)`);
|
||||
printCliLine("");
|
||||
printProviderMenu(presets);
|
||||
|
||||
const choice = await promptLine(rl, `Choose [1-${presets.length + 1}]: `);
|
||||
const choiceNum = Number.parseInt(choice, 10);
|
||||
if (Number.isNaN(choiceNum) || choiceNum < 1 || choiceNum > presets.length + 1) {
|
||||
const providerResult = await selectProvider(rl, presets);
|
||||
if (!providerResult.ok) {
|
||||
rl.close();
|
||||
return err(`invalid choice: ${choice}`);
|
||||
return providerResult;
|
||||
}
|
||||
const { provider, baseUrl } = providerResult.value;
|
||||
|
||||
let provider: string;
|
||||
let baseUrl: string;
|
||||
if (choiceNum <= presets.length) {
|
||||
const selected = presets[choiceNum - 1]!;
|
||||
provider = selected.name;
|
||||
baseUrl = selected.baseUrl;
|
||||
printCliLine(`\n → ${selected.label} (${baseUrl})\n`);
|
||||
} else {
|
||||
provider = await promptLine(rl, "Provider name (e.g. my-proxy): ");
|
||||
if (provider === "") {
|
||||
return err("provider name must not be empty");
|
||||
}
|
||||
baseUrl = await promptLine(rl, "OpenAI-compatible API base URL: ");
|
||||
if (baseUrl === "") {
|
||||
return err("base URL must not be empty");
|
||||
}
|
||||
}
|
||||
|
||||
// Close readline before raw-mode secret prompt, reopen after.
|
||||
rl.close();
|
||||
const apiKey = await promptSecret("API key for this provider: ");
|
||||
if (apiKey === "") {
|
||||
return err("API key must not be empty");
|
||||
}
|
||||
if (apiKey === "") return err("API key must not be empty");
|
||||
const rl2 = createInterface({ input, output });
|
||||
|
||||
// Try to list available models from the provider.
|
||||
printCliLine("\nFetching available models...");
|
||||
const models = await fetchAvailableModels(baseUrl, apiKey);
|
||||
let selectedModel: string;
|
||||
if (models.length > 0) {
|
||||
printCliLine(`\nAvailable models (${models.length}):\n`);
|
||||
const cols = process.stdout.columns || 80;
|
||||
const nw = String(models.length).length; // number width
|
||||
// Each cell: " <num>) <model> " — prefix is 2 + nw + 2 = nw+4
|
||||
const prefixLen = nw + 4;
|
||||
const maxModelLen = Math.max(...models.map((m) => m.length));
|
||||
const cellWidth = prefixLen + maxModelLen + 2; // +2 gap between columns
|
||||
const numCols = Math.max(1, Math.floor(cols / cellWidth));
|
||||
for (let i = 0; i < models.length; i += numCols) {
|
||||
const cells: string[] = [];
|
||||
for (let j = i; j < Math.min(i + numCols, models.length); j++) {
|
||||
const num = String(j + 1).padStart(nw);
|
||||
cells.push(` ${num}) ${(models[j]!).padEnd(maxModelLen + 2)}`);
|
||||
}
|
||||
printCliLine(cells.join(""));
|
||||
}
|
||||
printCliLine(`\nChoose a number, or type a model name directly.`);
|
||||
const modelInput = await promptLine(rl2, `Default model [1-${models.length}]: `);
|
||||
if (modelInput === "") {
|
||||
rl2.close();
|
||||
return err("default model must not be empty");
|
||||
}
|
||||
const modelNum = Number.parseInt(modelInput, 10);
|
||||
if (!Number.isNaN(modelNum) && modelNum >= 1 && modelNum <= models.length) {
|
||||
selectedModel = models[modelNum - 1]!;
|
||||
} else {
|
||||
// Treat as a literal model name.
|
||||
selectedModel = modelInput;
|
||||
}
|
||||
} else {
|
||||
printCliWarn("Could not fetch models (API may not support /models endpoint).");
|
||||
const modelInput = await promptLine(rl2, `Default model (e.g. qwen-plus, gpt-4o): `);
|
||||
if (modelInput === "") {
|
||||
rl2.close();
|
||||
return err("default model must not be empty");
|
||||
}
|
||||
selectedModel = modelInput;
|
||||
const modelResult = await selectModel(rl2, models);
|
||||
if (!modelResult.ok) {
|
||||
rl2.close();
|
||||
return modelResult;
|
||||
}
|
||||
// Strip provider prefix if user included one (e.g. pasted "MiniMax/MiniMax-M2.7").
|
||||
const bare = selectedModel.includes("/") ? selectedModel.split("/").pop()! : selectedModel;
|
||||
|
||||
const bare = stripProviderPrefix(modelResult.value);
|
||||
const defaultModel = `${provider}/${bare}`;
|
||||
printCliLine(` → ${defaultModel}`);
|
||||
|
||||
let initWorkspaceName: string | null = null;
|
||||
// Loop until a valid workspace path is provided or the user skips.
|
||||
while (true) {
|
||||
const wsPath = await promptLine(
|
||||
rl2,
|
||||
"\nWorkflow workspace path (default: ./workflows, type 'skip' to skip): ",
|
||||
);
|
||||
if (wsPath.toLowerCase() === "skip") {
|
||||
break;
|
||||
}
|
||||
const candidate = wsPath === "" ? "./workflows" : wsPath;
|
||||
// Validate path before passing to cmdSetup.
|
||||
const resolved = resolvePath(process.cwd(), candidate);
|
||||
if (existsSync(resolved)) {
|
||||
printCliWarn(`directory already exists: ${resolved}`);
|
||||
printCliLine("Please enter a different path, or type 'skip' to skip.");
|
||||
continue;
|
||||
}
|
||||
initWorkspaceName = candidate;
|
||||
break;
|
||||
}
|
||||
const initWorkspaceName = await selectWorkspace(rl2);
|
||||
rl2.close();
|
||||
|
||||
return ok({
|
||||
provider,
|
||||
baseUrl,
|
||||
apiKey,
|
||||
defaultModel,
|
||||
initWorkspaceName,
|
||||
});
|
||||
return ok({ provider, baseUrl, apiKey, defaultModel, initWorkspaceName });
|
||||
} catch (e) {
|
||||
return err(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
|
||||
@@ -5,45 +5,43 @@ import { parse as parseYaml } from "yaml";
|
||||
|
||||
import type { PresetProvider } from "./types.js";
|
||||
|
||||
|
||||
|
||||
type RawPresetEntry = {
|
||||
name: unknown;
|
||||
label: unknown;
|
||||
baseUrl: unknown;
|
||||
name: unknown;
|
||||
label: unknown;
|
||||
baseUrl: unknown;
|
||||
};
|
||||
|
||||
function isRawEntry(v: unknown): v is RawPresetEntry {
|
||||
if (typeof v !== "object" || v === null) return false;
|
||||
const o = v as Record<string, unknown>;
|
||||
return typeof o.name === "string" && typeof o.label === "string" && typeof o.baseUrl === "string";
|
||||
if (typeof v !== "object" || v === null) return false;
|
||||
const o = v as Record<string, unknown>;
|
||||
return typeof o.name === "string" && typeof o.label === "string" && typeof o.baseUrl === "string";
|
||||
}
|
||||
|
||||
let cached: ReadonlyArray<PresetProvider> | null = null;
|
||||
|
||||
export function loadPresetProviders(): ReadonlyArray<PresetProvider> {
|
||||
if (cached !== null) return cached;
|
||||
if (cached !== null) return cached;
|
||||
|
||||
const yamlPath = join(import.meta.dirname, "providers.yaml");
|
||||
const raw = readFileSync(yamlPath, "utf8");
|
||||
const parsed: unknown = parseYaml(raw);
|
||||
const yamlPath = join(import.meta.dirname, "providers.yaml");
|
||||
const raw = readFileSync(yamlPath, "utf8");
|
||||
const parsed: unknown = parseYaml(raw);
|
||||
|
||||
if (!Array.isArray(parsed)) {
|
||||
throw new Error(`providers.yaml: expected array, got ${typeof parsed}`);
|
||||
}
|
||||
if (!Array.isArray(parsed)) {
|
||||
throw new Error(`providers.yaml: expected array, got ${typeof parsed}`);
|
||||
}
|
||||
|
||||
const result: PresetProvider[] = [];
|
||||
for (const entry of parsed) {
|
||||
if (!isRawEntry(entry)) {
|
||||
throw new Error(`providers.yaml: invalid entry: ${JSON.stringify(entry)}`);
|
||||
}
|
||||
result.push({
|
||||
name: entry.name as string,
|
||||
label: entry.label as string,
|
||||
baseUrl: entry.baseUrl as string,
|
||||
});
|
||||
}
|
||||
const result: PresetProvider[] = [];
|
||||
for (const entry of parsed) {
|
||||
if (!isRawEntry(entry)) {
|
||||
throw new Error(`providers.yaml: invalid entry: ${JSON.stringify(entry)}`);
|
||||
}
|
||||
result.push({
|
||||
name: entry.name as string,
|
||||
label: entry.label as string,
|
||||
baseUrl: entry.baseUrl as string,
|
||||
});
|
||||
}
|
||||
|
||||
cached = result;
|
||||
return result;
|
||||
cached = result;
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -13,8 +13,6 @@ import type { CmdSetupSuccess, SetupCliArgs } from "./types.js";
|
||||
|
||||
const setupLog = createLogger({ sink: { kind: "stderr" } });
|
||||
|
||||
|
||||
|
||||
function mergeWorkflowConfig(
|
||||
prev: WorkflowConfig | null,
|
||||
input: SetupCliArgs,
|
||||
|
||||
@@ -86,11 +86,11 @@ ${commandSections.join("\n\n")}
|
||||
| \`run\` | \`thread run\` | Shortcut to start a thread |
|
||||
| \`live\` | \`thread live\` | Shortcut to attach to a thread |
|
||||
|
||||
### serve
|
||||
### connect
|
||||
|
||||
| Command | Args | Description |
|
||||
|---------|------|-------------|
|
||||
| \`serve\` | \`[--port N] [--host ADDR] [--name NAME]\` | Start HTTP API server with auto-tunnel. \`--name\` registers with the gateway. |
|
||||
| \`connect\` | \`[--name NAME] [--gateway URL]\` | Connect to workflow gateway via WebSocket. \`--name\` registers with the gateway. |
|
||||
|
||||
## Typical Workflow
|
||||
|
||||
@@ -183,35 +183,63 @@ How to build, test, and publish workflow bundles for uncaged-workflow.
|
||||
A workflow bundle is a single ESM file (\`.esm.js\`) that exports:
|
||||
|
||||
\`\`\`typescript
|
||||
// Required exports
|
||||
// Required named exports (no default export)
|
||||
export const descriptor: WorkflowDescriptor;
|
||||
export const run: WorkflowRun;
|
||||
export const run: WorkflowFn;
|
||||
\`\`\`
|
||||
|
||||
## WorkflowDescriptor
|
||||
|
||||
Serialized metadata for the registry (per-role JSON Schema plus a static routing graph):
|
||||
Serialized metadata for the registry. Every role must include both \`description\` and \`schema\` (JSON Schema object). The graph uses an edges array where each edge has \`from\`, \`to\`, and \`condition\`.
|
||||
|
||||
\`\`\`typescript
|
||||
type WorkflowDescriptor = {
|
||||
description: string;
|
||||
roles: Record<string, { description: string; schema: unknown /* JSON Schema */ }>;
|
||||
roles: Record<string, {
|
||||
description: string;
|
||||
schema: object; // JSON Schema — use z.toJSONSchema(zodSchema) to generate
|
||||
}>;
|
||||
graph: {
|
||||
edges: Array<{
|
||||
from: string;
|
||||
to: string;
|
||||
condition: string;
|
||||
conditionDescription: string | null;
|
||||
from: string; // role name, or "__start__"
|
||||
to: string; // role name, or "__end__"
|
||||
condition: string; // e.g. "FALLBACK"
|
||||
conditionDescription?: string | null;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
\`\`\`
|
||||
|
||||
## WorkflowRun
|
||||
**descriptor is static data** — it is read at \`workflow add\` (register) time via \`import()\`. It must NOT trigger any side effects or read environment variables.
|
||||
|
||||
## WorkflowFn
|
||||
|
||||
Async generator from \`createWorkflow(definition, binding)\` (**@uncaged/workflow-runtime**) — yields each role output until the workflow completes.
|
||||
|
||||
The **ModeratorTable** on **WorkflowDefinition** is declarative routing (from each role and \`START\` to the next role or \`END\`); the engine evaluates conditions at runtime.
|
||||
## ModeratorTable
|
||||
|
||||
Declarative routing table. Transitions use the \`role\` field (not \`next\`):
|
||||
|
||||
\`\`\`typescript
|
||||
import { START, END, type ModeratorTable } from "@uncaged/workflow-runtime";
|
||||
|
||||
const table: ModeratorTable<MyMeta> = {
|
||||
[START]: [{ condition: "FALLBACK", role: "firstRole" }],
|
||||
firstRole: [{ condition: "FALLBACK", role: END }],
|
||||
};
|
||||
\`\`\`
|
||||
|
||||
## AdapterFn / AdapterBinding
|
||||
|
||||
The adapter receives a system prompt and Zod schema, returns a \`RoleFn<T>\` that produces typed meta:
|
||||
|
||||
\`\`\`typescript
|
||||
type AdapterFn = <T>(prompt: string, schema: ZodType<T>) => RoleFn<T>;
|
||||
type AdapterBinding = {
|
||||
adapter: AdapterFn;
|
||||
overrides: Partial<Record<string, AdapterFn>> | null;
|
||||
};
|
||||
\`\`\`
|
||||
|
||||
## Role Definition
|
||||
|
||||
@@ -230,15 +258,16 @@ Each role has:
|
||||
# 1. Initialize a workspace
|
||||
uncaged-workflow init workspace my-workflow
|
||||
|
||||
# 2. Write your template (roles + ModeratorTable + descriptor)
|
||||
# 2. Write your template (roles + ModeratorTable + definition)
|
||||
# 3. Write entry file (workflows/*-entry.ts) with adapter binding + descriptor
|
||||
|
||||
# 3. Build the ESM bundle
|
||||
bun run build
|
||||
# 4. Build the ESM bundle
|
||||
bun run bundle # uses scripts/bundle.ts
|
||||
|
||||
# 4. Register locally
|
||||
uncaged-workflow workflow add my-workflow ./dist/my-workflow.esm.js
|
||||
# 5. Register locally
|
||||
uncaged-workflow workflow add my-workflow ./dist/my-workflow-entry.esm.js
|
||||
|
||||
# 5. Test
|
||||
# 6. Test
|
||||
uncaged-workflow run my-workflow --prompt "test task"
|
||||
uncaged-workflow live --latest
|
||||
\`\`\`
|
||||
@@ -246,5 +275,46 @@ uncaged-workflow live --latest
|
||||
## Versioning
|
||||
|
||||
Bundles are immutable and identified by XXH64 hash. Re-registering a workflow with a new bundle creates a new version. Use \`workflow history\` and \`workflow rollback\` to manage versions.
|
||||
|
||||
## Pitfalls
|
||||
|
||||
### Lazy initialization is mandatory
|
||||
|
||||
The bundle is \`import()\`-ed at register time (\`workflow add\`) to read the descriptor. At that point, no runtime env vars (API keys, etc.) are available.
|
||||
|
||||
**Never read env at module top-level.** Wrap provider/adapter creation in a lazy closure:
|
||||
|
||||
\`\`\`typescript
|
||||
// ❌ WRONG — breaks register
|
||||
const provider = { apiKey: process.env.MY_KEY! };
|
||||
const adapter = createAdapter(provider);
|
||||
|
||||
// ✅ CORRECT — only reads env when run() is called
|
||||
function createLazyAdapter(): AdapterFn {
|
||||
let cached: Provider | null = null;
|
||||
return (prompt, schema) => {
|
||||
return async (ctx, runtime) => {
|
||||
if (!cached) cached = { apiKey: process.env.MY_KEY! };
|
||||
// ... use cached provider
|
||||
};
|
||||
};
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
### Bundle import restrictions
|
||||
|
||||
The bundle validator only allows these import specifiers:
|
||||
- Node built-ins (\`node:fs\`, \`node:path\`, etc.)
|
||||
- \`@uncaged/workflow-*\` packages
|
||||
|
||||
Third-party packages (**including zod**) must be bundled into the \`.esm.js\` file, not left as external imports. When using \`bun build\`, only mark \`@uncaged/*\` as external.
|
||||
|
||||
### No default exports
|
||||
|
||||
The engine only reads named exports \`run\` and \`descriptor\`. Using \`export default\` will cause registration to fail silently.
|
||||
|
||||
### Single-file ESM
|
||||
|
||||
The bundle must be a single \`.esm.js\` file. No dynamic \`import()\` inside the bundle — it breaks hash verification and the loader sandbox.
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
# @uncaged/workflow-agent-cursor
|
||||
|
||||
## 0.4.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-protocol@0.4.5
|
||||
- @uncaged/workflow-reactor@0.4.5
|
||||
- @uncaged/workflow-runtime@0.4.5
|
||||
- @uncaged/workflow-util@0.4.5
|
||||
- @uncaged/workflow-util-agent@0.4.5
|
||||
|
||||
## 0.4.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-protocol@0.4.4
|
||||
- @uncaged/workflow-reactor@0.4.4
|
||||
- @uncaged/workflow-runtime@0.4.4
|
||||
- @uncaged/workflow-util@0.4.4
|
||||
- @uncaged/workflow-util-agent@0.4.4
|
||||
|
||||
## 0.4.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Include src/ in published packages so bun runtime can resolve the 'bun' exports condition.
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-protocol@0.4.3
|
||||
- @uncaged/workflow-reactor@0.4.3
|
||||
- @uncaged/workflow-runtime@0.4.3
|
||||
- @uncaged/workflow-util-agent@0.4.3
|
||||
- @uncaged/workflow-util@0.4.3
|
||||
|
||||
## 0.4.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fix workspace dependency resolution: use workspace:^ so published packages resolve to compatible versions instead of exact (non-existent) versions.
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-protocol@0.4.2
|
||||
- @uncaged/workflow-reactor@0.4.2
|
||||
- @uncaged/workflow-runtime@0.4.2
|
||||
- @uncaged/workflow-util-agent@0.4.2
|
||||
- @uncaged/workflow-util@0.4.2
|
||||
|
||||
## 0.4.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Fix package exports for published packages and adopt changesets for version management.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-protocol@0.4.0
|
||||
- @uncaged/workflow-reactor@0.4.0
|
||||
- @uncaged/workflow-runtime@0.4.0
|
||||
- @uncaged/workflow-util-agent@0.4.0
|
||||
- @uncaged/workflow-util@0.4.0
|
||||
@@ -2,24 +2,11 @@ import { describe, expect, test } from "bun:test";
|
||||
import { createCursorAgent, validateCursorAgentConfig } from "../src/index.js";
|
||||
|
||||
describe("validateCursorAgentConfig", () => {
|
||||
test("accepts valid config with explicit workspace", () => {
|
||||
test("accepts valid config", () => {
|
||||
const r = validateCursorAgentConfig({
|
||||
command: "/usr/local/bin/cursor-agent",
|
||||
model: null,
|
||||
timeout: 0,
|
||||
workspace: "/tmp/test-project",
|
||||
llmProvider: null,
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
});
|
||||
|
||||
test("accepts valid config with null workspace and llmProvider", () => {
|
||||
const r = validateCursorAgentConfig({
|
||||
command: "/usr/local/bin/cursor-agent",
|
||||
model: null,
|
||||
timeout: 0,
|
||||
workspace: null,
|
||||
llmProvider: { baseUrl: "http://localhost", apiKey: "test", model: "test" },
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
});
|
||||
@@ -29,8 +16,6 @@ describe("validateCursorAgentConfig", () => {
|
||||
command: "cursor-agent",
|
||||
model: null,
|
||||
timeout: 0,
|
||||
workspace: "/tmp/test-project",
|
||||
llmProvider: null,
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) {
|
||||
@@ -38,65 +23,22 @@ describe("validateCursorAgentConfig", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects empty workspace string", () => {
|
||||
const r = validateCursorAgentConfig({
|
||||
command: "/usr/local/bin/cursor-agent",
|
||||
model: null,
|
||||
timeout: 0,
|
||||
workspace: "",
|
||||
llmProvider: null,
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) {
|
||||
expect(r.error).toContain("workspace");
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects null workspace without llmProvider", () => {
|
||||
const r = validateCursorAgentConfig({
|
||||
command: "/usr/local/bin/cursor-agent",
|
||||
model: null,
|
||||
timeout: 0,
|
||||
workspace: null,
|
||||
llmProvider: null,
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) {
|
||||
expect(r.error).toContain("llmProvider");
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects negative timeout", () => {
|
||||
const r = validateCursorAgentConfig({
|
||||
command: "/usr/local/bin/cursor-agent",
|
||||
model: null,
|
||||
timeout: -1,
|
||||
workspace: "/tmp/test-project",
|
||||
llmProvider: null,
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createCursorAgent", () => {
|
||||
test("returns an AgentFn with explicit workspace", () => {
|
||||
test("returns an AdapterFn", () => {
|
||||
const agent = createCursorAgent({
|
||||
command: "/usr/local/bin/cursor-agent",
|
||||
model: null,
|
||||
timeout: 0,
|
||||
workspace: "/tmp/test-project",
|
||||
llmProvider: null,
|
||||
});
|
||||
expect(typeof agent).toBe("function");
|
||||
});
|
||||
|
||||
test("returns an AgentFn with null workspace and llmProvider", () => {
|
||||
const agent = createCursorAgent({
|
||||
command: "/usr/local/bin/cursor-agent",
|
||||
model: null,
|
||||
timeout: 0,
|
||||
workspace: null,
|
||||
llmProvider: { baseUrl: "http://localhost", apiKey: "test", model: "test" },
|
||||
});
|
||||
expect(typeof agent).toBe("function");
|
||||
});
|
||||
@@ -106,19 +48,6 @@ describe("createCursorAgent", () => {
|
||||
command: "/usr/local/bin/cursor-agent",
|
||||
model: null,
|
||||
timeout: -1,
|
||||
workspace: "/tmp/test-project",
|
||||
llmProvider: null,
|
||||
});
|
||||
expect(typeof agent).toBe("function");
|
||||
});
|
||||
|
||||
test("defers validation — null workspace without llmProvider does not throw at construction", () => {
|
||||
const agent = createCursorAgent({
|
||||
command: "/usr/local/bin/cursor-agent",
|
||||
model: null,
|
||||
timeout: 0,
|
||||
workspace: null,
|
||||
llmProvider: null,
|
||||
});
|
||||
expect(typeof agent).toBe("function");
|
||||
});
|
||||
|
||||
@@ -1,18 +1,32 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-agent-cursor",
|
||||
"version": "0.3.5",
|
||||
"version": "0.4.5",
|
||||
"files": [
|
||||
"src",
|
||||
"dist",
|
||||
"package.json"
|
||||
],
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"scripts": {
|
||||
"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:*",
|
||||
"@uncaged/workflow-cas": "workspace:^",
|
||||
"@uncaged/workflow-protocol": "workspace:^",
|
||||
"@uncaged/workflow-runtime": "workspace:^",
|
||||
"@uncaged/workflow-util": "workspace:^",
|
||||
"@uncaged/workflow-util-agent": "workspace:^",
|
||||
"zod": "^4.0.0"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"bun": "./src/index.ts",
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { AgentContext, LlmProvider } from "@uncaged/workflow-protocol";
|
||||
import { createLlmFn, createThreadReactor } from "@uncaged/workflow-reactor";
|
||||
import { putContentNodeWithRefs } from "@uncaged/workflow-cas";
|
||||
import type { ThreadContext, WorkflowRuntime } from "@uncaged/workflow-runtime";
|
||||
import type { LogFn } from "@uncaged/workflow-util";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
@@ -7,10 +7,7 @@ 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 {
|
||||
function buildExtractionInput(ctx: ThreadContext): string {
|
||||
const lines: string[] = [];
|
||||
lines.push("## Task");
|
||||
lines.push(ctx.start.content);
|
||||
@@ -21,48 +18,25 @@ function buildExtractionInput(ctx: AgentContext): string {
|
||||
lines.push(`Meta: ${JSON.stringify(step.meta)}`);
|
||||
}
|
||||
|
||||
lines.push("");
|
||||
lines.push(
|
||||
"Extract the absolute filesystem path of the project workspace where code changes should be made.",
|
||||
);
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export async function extractWorkspacePath(
|
||||
ctx: AgentContext,
|
||||
provider: LlmProvider,
|
||||
ctx: ThreadContext,
|
||||
runtime: WorkflowRuntime,
|
||||
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 input = buildExtractionInput(ctx);
|
||||
const contentHash = await putContentNodeWithRefs(runtime.cas, input, []);
|
||||
|
||||
const result = await reactor({
|
||||
thread: null,
|
||||
input: buildExtractionInput(ctx),
|
||||
schema: workspaceSchema,
|
||||
});
|
||||
const result = await runtime.extract(workspaceSchema, contentHash);
|
||||
const workspace = result.meta.workspace.trim();
|
||||
|
||||
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;
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import type { AgentFn } from "@uncaged/workflow-runtime";
|
||||
import type { WorkflowRuntime } from "@uncaged/workflow-runtime";
|
||||
import { createLogger } from "@uncaged/workflow-util";
|
||||
import { buildAgentPrompt, type SpawnCliError, spawnCli } from "@uncaged/workflow-util-agent";
|
||||
import {
|
||||
buildThreadInput,
|
||||
createTextAdapter,
|
||||
type SpawnCliError,
|
||||
spawnCli,
|
||||
} from "@uncaged/workflow-util-agent";
|
||||
|
||||
import { extractWorkspacePath } from "./extract-workspace.js";
|
||||
import type { CursorAgentConfig } from "./types.js";
|
||||
@@ -28,37 +33,28 @@ function resolveCursorModel(model: string | null): string {
|
||||
return model === null ? "auto" : model;
|
||||
}
|
||||
|
||||
/** Runs `cursor-agent` with workspace from config or extracted from context via LLM. */
|
||||
export function createCursorAgent(config: CursorAgentConfig): AgentFn {
|
||||
/** Runs `cursor-agent` with workspace extracted from thread context via runtime.extract. */
|
||||
export function createCursorAgent(config: CursorAgentConfig) {
|
||||
const modelFlag = resolveCursorModel(config.model);
|
||||
const timeoutMs = config.timeout > 0 ? config.timeout : null;
|
||||
const logger = createLogger({ sink: { kind: "stderr" } });
|
||||
|
||||
return async (ctx) => {
|
||||
return createTextAdapter(async (ctx, prompt, runtime: WorkflowRuntime) => {
|
||||
const validated = validateCursorAgentConfig(config);
|
||||
if (!validated.ok) {
|
||||
throw new Error(validated.error);
|
||||
}
|
||||
|
||||
let workspace: string;
|
||||
|
||||
if (config.workspace !== null) {
|
||||
workspace = config.workspace;
|
||||
} else {
|
||||
if (config.llmProvider === null) {
|
||||
throw new Error("cursor-agent: llmProvider is required when workspace is null");
|
||||
}
|
||||
const extracted = await extractWorkspacePath(ctx, config.llmProvider, logger);
|
||||
if (extracted === null) {
|
||||
throw new Error(
|
||||
"cursor-agent: failed to extract workspace path from context. Provide an explicit workspace or ensure previous steps include a repoPath.",
|
||||
);
|
||||
}
|
||||
workspace = extracted;
|
||||
const workspace = await extractWorkspacePath(ctx, runtime, logger);
|
||||
if (workspace === null) {
|
||||
throw new Error(
|
||||
"cursor-agent: failed to extract workspace path from context. Ensure the task prompt or previous steps include a project path.",
|
||||
);
|
||||
}
|
||||
|
||||
logger("R5HN3YKQ", `cursor-agent workspace: ${workspace}`);
|
||||
const fullPrompt = await buildAgentPrompt(ctx);
|
||||
const threadInput = await buildThreadInput(ctx);
|
||||
const fullPrompt = `${prompt}\n\n${threadInput}`;
|
||||
const args = [
|
||||
"-p",
|
||||
fullPrompt,
|
||||
@@ -79,5 +75,5 @@ export function createCursorAgent(config: CursorAgentConfig): AgentFn {
|
||||
throwCursorSpawnError(run.error);
|
||||
}
|
||||
return run.value;
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
import type { LlmProvider } from "@uncaged/workflow-protocol";
|
||||
|
||||
export type CursorAgentConfig = {
|
||||
/** Absolute path to the cursor-agent CLI binary. */
|
||||
command: string;
|
||||
model: string | null;
|
||||
timeout: number;
|
||||
/** 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;
|
||||
};
|
||||
|
||||
@@ -8,12 +8,6 @@ export function validateCursorAgentConfig(config: CursorAgentConfig): Result<voi
|
||||
if (!isAbsolute(config.command)) {
|
||||
return err("command must be an absolute path to the cursor-agent CLI binary");
|
||||
}
|
||||
if (config.workspace !== null && config.workspace.length === 0) {
|
||||
return err("workspace must be a non-empty string (absolute path) or null for auto-detection");
|
||||
}
|
||||
if (config.workspace === null && config.llmProvider === null) {
|
||||
return err("llmProvider is required when workspace is null (needed for workspace extraction)");
|
||||
}
|
||||
if (config.timeout < 0) {
|
||||
return err("timeout must be a non-negative number (milliseconds); use 0 for no limit");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# @uncaged/workflow-agent-hermes
|
||||
|
||||
## 0.4.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @uncaged/workflow-runtime@0.4.5
|
||||
- @uncaged/workflow-util-agent@0.4.5
|
||||
|
||||
## 0.4.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @uncaged/workflow-runtime@0.4.4
|
||||
- @uncaged/workflow-util-agent@0.4.4
|
||||
|
||||
## 0.4.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Include src/ in published packages so bun runtime can resolve the 'bun' exports condition.
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-runtime@0.4.3
|
||||
- @uncaged/workflow-util-agent@0.4.3
|
||||
|
||||
## 0.4.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fix workspace dependency resolution: use workspace:^ so published packages resolve to compatible versions instead of exact (non-existent) versions.
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-runtime@0.4.2
|
||||
- @uncaged/workflow-util-agent@0.4.2
|
||||
|
||||
## 0.4.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Fix package exports for published packages and adopt changesets for version management.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-runtime@0.4.0
|
||||
- @uncaged/workflow-util-agent@0.4.0
|
||||
@@ -37,7 +37,7 @@ describe("validateHermesAgentConfig", () => {
|
||||
});
|
||||
|
||||
describe("createHermesAgent", () => {
|
||||
test("returns an AgentFn even with invalid config (validation deferred to call)", () => {
|
||||
test("returns an AdapterFn even with invalid config (validation deferred to call)", () => {
|
||||
const agent = createHermesAgent({
|
||||
command: "/usr/local/bin/hermes",
|
||||
model: null,
|
||||
|
||||
@@ -1,14 +1,28 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-agent-hermes",
|
||||
"version": "0.3.5",
|
||||
"version": "0.4.5",
|
||||
"files": [
|
||||
"src",
|
||||
"dist",
|
||||
"package.json"
|
||||
],
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"scripts": {
|
||||
"test": "bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/workflow-runtime": "workspace:*",
|
||||
"@uncaged/workflow-util-agent": "workspace:*"
|
||||
"@uncaged/workflow-runtime": "workspace:^",
|
||||
"@uncaged/workflow-util-agent": "workspace:^"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"bun": "./src/index.ts",
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import type { AgentFn } from "@uncaged/workflow-runtime";
|
||||
import { buildAgentPrompt, type SpawnCliError, spawnCli } from "@uncaged/workflow-util-agent";
|
||||
import type { AdapterFn } from "@uncaged/workflow-runtime";
|
||||
import {
|
||||
buildThreadInput,
|
||||
createTextAdapter,
|
||||
type SpawnCliError,
|
||||
spawnCli,
|
||||
} from "@uncaged/workflow-util-agent";
|
||||
|
||||
import type { HermesAgentConfig } from "./types.js";
|
||||
import { validateHermesAgentConfig } from "./validate-config.js";
|
||||
@@ -25,16 +30,17 @@ function throwHermesSpawnError(error: SpawnCliError): never {
|
||||
}
|
||||
|
||||
/** Runs `hermes chat` non-interactively with the Nerve-style argv contract (`-q`, `--yolo`, `--quiet`). */
|
||||
export function createHermesAgent(config: HermesAgentConfig): AgentFn {
|
||||
export function createHermesAgent(config: HermesAgentConfig): AdapterFn {
|
||||
const timeoutMs = config.timeout;
|
||||
|
||||
return async (ctx) => {
|
||||
return createTextAdapter(async (ctx, prompt, _runtime) => {
|
||||
const validated = validateHermesAgentConfig(config);
|
||||
if (!validated.ok) {
|
||||
throw new Error(validated.error);
|
||||
}
|
||||
|
||||
const fullPrompt = await buildAgentPrompt(ctx);
|
||||
const threadInput = await buildThreadInput(ctx);
|
||||
const fullPrompt = `${prompt}\n\n${threadInput}`;
|
||||
const args = [
|
||||
"chat",
|
||||
"-q",
|
||||
@@ -55,5 +61,5 @@ export function createHermesAgent(config: HermesAgentConfig): AgentFn {
|
||||
throwHermesSpawnError(run.error);
|
||||
}
|
||||
return run.value;
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# @uncaged/workflow-agent-llm
|
||||
|
||||
## 0.4.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @uncaged/workflow-runtime@0.4.5
|
||||
- @uncaged/workflow-util-agent@0.4.5
|
||||
|
||||
## 0.4.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @uncaged/workflow-runtime@0.4.4
|
||||
- @uncaged/workflow-util-agent@0.4.4
|
||||
|
||||
## 0.4.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Include src/ in published packages so bun runtime can resolve the 'bun' exports condition.
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-runtime@0.4.3
|
||||
- @uncaged/workflow-util-agent@0.4.3
|
||||
|
||||
## 0.4.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fix workspace dependency resolution: use workspace:^ so published packages resolve to compatible versions instead of exact (non-existent) versions.
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-runtime@0.4.2
|
||||
- @uncaged/workflow-util-agent@0.4.2
|
||||
|
||||
## 0.4.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Fix package exports for published packages and adopt changesets for version management.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-runtime@0.4.0
|
||||
- @uncaged/workflow-util-agent@0.4.0
|
||||
@@ -1,9 +1,16 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { type AgentContext, START } from "@uncaged/workflow-runtime";
|
||||
import {
|
||||
type CasStore,
|
||||
type ExtractFn,
|
||||
START,
|
||||
type ThreadContext,
|
||||
type WorkflowRuntime,
|
||||
} from "@uncaged/workflow-runtime";
|
||||
import * as z from "zod";
|
||||
|
||||
import { createLlmAdapter } from "../src/create-llm-adapter.js";
|
||||
|
||||
function makeCtx(userContent: string): AgentContext {
|
||||
function makeCtx(userContent: string): ThreadContext {
|
||||
return {
|
||||
start: {
|
||||
role: START,
|
||||
@@ -16,14 +23,34 @@ function makeCtx(userContent: string): AgentContext {
|
||||
bundleHash: "TESTHASH00001",
|
||||
steps: [],
|
||||
threadId: "01TEST000000000000000000TR",
|
||||
currentRole: { name: "planner", systemPrompt: "system instructions" },
|
||||
};
|
||||
}
|
||||
|
||||
const testSchema = z.object({ summary: z.string() });
|
||||
|
||||
function makeRuntime(): WorkflowRuntime {
|
||||
let stored = "";
|
||||
const cas: CasStore = {
|
||||
put: async (content: string) => {
|
||||
stored = content;
|
||||
return "HASH001";
|
||||
},
|
||||
get: async () => stored,
|
||||
delete: async () => {},
|
||||
list: async () => [],
|
||||
};
|
||||
const extract: ExtractFn = async (_schema, _contentHash) => ({
|
||||
meta: { summary: "extracted" },
|
||||
contentPayload: stored,
|
||||
refs: [],
|
||||
});
|
||||
return { cas, extract };
|
||||
}
|
||||
|
||||
describe("createLlmAdapter", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
test("posts system + user (start.content) and returns assistant text", async () => {
|
||||
test("posts system + user (start.content) and returns typed meta with childThread: null", async () => {
|
||||
globalThis.fetch = (() =>
|
||||
Promise.resolve(
|
||||
new Response(JSON.stringify({ choices: [{ message: { content: "model reply" } }] }), {
|
||||
@@ -34,11 +61,13 @@ describe("createLlmAdapter", () => {
|
||||
|
||||
const provider = { baseUrl: "https://api.example/v1", apiKey: "k", model: "m" };
|
||||
const adapter = createLlmAdapter(provider);
|
||||
const out = await adapter(makeCtx("trigger text"));
|
||||
const roleFn = adapter("system instructions", testSchema);
|
||||
const result = await roleFn(makeCtx("trigger text"), makeRuntime());
|
||||
|
||||
globalThis.fetch = originalFetch;
|
||||
|
||||
expect(out).toBe("model reply");
|
||||
expect(result.meta).toEqual({ summary: "extracted" });
|
||||
expect(result.childThread).toBeNull();
|
||||
});
|
||||
|
||||
test("throws on non-ok fetch response", async () => {
|
||||
@@ -52,8 +81,9 @@ describe("createLlmAdapter", () => {
|
||||
|
||||
const provider = { baseUrl: "https://api.example/v1", apiKey: "k", model: "m" };
|
||||
const adapter = createLlmAdapter(provider);
|
||||
const roleFn = adapter("system", testSchema);
|
||||
|
||||
await expect(adapter(makeCtx("hi"))).rejects.toThrow("llm:");
|
||||
await expect(roleFn(makeCtx("hi"), makeRuntime())).rejects.toThrow("llm:");
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
@@ -62,8 +92,9 @@ describe("createLlmAdapter", () => {
|
||||
|
||||
const provider = { baseUrl: "https://api.example/v1", apiKey: "k", model: "m" };
|
||||
const adapter = createLlmAdapter(provider);
|
||||
const roleFn = adapter("system", testSchema);
|
||||
|
||||
await expect(adapter(makeCtx("hi"))).rejects.toThrow();
|
||||
await expect(roleFn(makeCtx("hi"), makeRuntime())).rejects.toThrow();
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,31 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-agent-llm",
|
||||
"version": "0.3.5",
|
||||
"version": "0.4.5",
|
||||
"files": [
|
||||
"src",
|
||||
"dist",
|
||||
"package.json"
|
||||
],
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"scripts": {
|
||||
"test": "bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/workflow-runtime": "workspace:*"
|
||||
"@uncaged/workflow-runtime": "workspace:^",
|
||||
"@uncaged/workflow-util-agent": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
"zod": "^4.0.0"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"bun": "./src/index.ts",
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import {
|
||||
type AgentContext,
|
||||
type AgentFn,
|
||||
err,
|
||||
type LlmProvider,
|
||||
ok,
|
||||
type Result,
|
||||
} from "@uncaged/workflow-runtime";
|
||||
import { type AdapterFn, err, type LlmProvider, ok, type Result } from "@uncaged/workflow-runtime";
|
||||
import { createTextAdapter } from "@uncaged/workflow-util-agent";
|
||||
|
||||
/** OpenAI chat completion message shape (passed to `/chat/completions`). */
|
||||
export type LlmMessage = { role: "system" | "user" | "assistant"; content: string };
|
||||
@@ -97,13 +91,13 @@ export async function chatCompletionText(options: {
|
||||
return parseAssistantText(res.value);
|
||||
}
|
||||
|
||||
/** Single-turn chat adapter: system prompt comes from {@link AgentContext.currentRole}. */
|
||||
export function createLlmAdapter(provider: LlmProvider): AgentFn {
|
||||
return async (ctx: AgentContext) => {
|
||||
/** Single-turn chat adapter: system prompt is passed by the workflow engine. */
|
||||
export function createLlmAdapter(provider: LlmProvider): AdapterFn {
|
||||
return createTextAdapter(async (ctx, prompt, _runtime) => {
|
||||
const result = await chatCompletionText({
|
||||
provider,
|
||||
messages: [
|
||||
{ role: "system", content: ctx.currentRole.systemPrompt },
|
||||
{ role: "system", content: prompt },
|
||||
{ role: "user", content: ctx.start.content },
|
||||
],
|
||||
});
|
||||
@@ -111,5 +105,5 @@ export function createLlmAdapter(provider: LlmProvider): AgentFn {
|
||||
throw new Error(`llm: ${formatLlmChatError(result.error)}`);
|
||||
}
|
||||
return result.value;
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"composite": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"references": [{ "path": "../workflow-runtime" }]
|
||||
"references": [{ "path": "../workflow-runtime" }, { "path": "../workflow-util-agent" }]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
# @uncaged/workflow-agent-react
|
||||
|
||||
## 0.4.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-protocol@0.4.5
|
||||
- @uncaged/workflow-reactor@0.4.5
|
||||
- @uncaged/workflow-util-agent@0.4.5
|
||||
|
||||
## 0.4.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-protocol@0.4.4
|
||||
- @uncaged/workflow-reactor@0.4.4
|
||||
- @uncaged/workflow-util-agent@0.4.4
|
||||
|
||||
## 0.4.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Include src/ in published packages so bun runtime can resolve the 'bun' exports condition.
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-protocol@0.4.3
|
||||
- @uncaged/workflow-reactor@0.4.3
|
||||
- @uncaged/workflow-util-agent@0.4.3
|
||||
|
||||
## 0.4.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fix workspace dependency resolution: use workspace:^ so published packages resolve to compatible versions instead of exact (non-existent) versions.
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-protocol@0.4.2
|
||||
- @uncaged/workflow-reactor@0.4.2
|
||||
- @uncaged/workflow-util-agent@0.4.2
|
||||
|
||||
## 0.4.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Fix package exports for published packages and adopt changesets for version management.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-protocol@0.4.0
|
||||
- @uncaged/workflow-reactor@0.4.0
|
||||
- @uncaged/workflow-util-agent@0.4.0
|
||||
@@ -1,7 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { ok, START, type ThreadContext, type WorkflowRuntime } from "@uncaged/workflow-protocol";
|
||||
import type { LlmFn, ToolDefinition } from "@uncaged/workflow-reactor";
|
||||
import { ok } from "@uncaged/workflow-protocol";
|
||||
import { START, type ThreadContext, type WorkflowRuntime } from "@uncaged/workflow-protocol";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
import { createReactAdapter } from "../src/create-react-adapter.js";
|
||||
@@ -39,10 +38,12 @@ const STUB_RUNTIME: WorkflowRuntime = {
|
||||
}),
|
||||
};
|
||||
|
||||
const TEST_SCHEMA = z.object({
|
||||
summary: z.string(),
|
||||
score: z.number(),
|
||||
}).meta({ title: "resolve", description: "Submit the final result." });
|
||||
const TEST_SCHEMA = z
|
||||
.object({
|
||||
summary: z.string(),
|
||||
score: z.number(),
|
||||
})
|
||||
.meta({ title: "resolve", description: "Submit the final result." });
|
||||
|
||||
function makeChatResponse(content: string | null, toolCalls: unknown[] | null): string {
|
||||
const message: Record<string, unknown> = { role: "assistant" };
|
||||
@@ -156,7 +157,9 @@ describe("createReactAdapter", () => {
|
||||
callCount += 1;
|
||||
if (callCount === 1) {
|
||||
// Invalid: score should be number, not string
|
||||
return ok(makeToolCallResponse("resolve", { summary: "bad", score: "not-a-number" }, "call_1"));
|
||||
return ok(
|
||||
makeToolCallResponse("resolve", { summary: "bad", score: "not-a-number" }, "call_1"),
|
||||
);
|
||||
}
|
||||
return ok(makeToolCallResponse("resolve", { summary: "fixed", score: 10 }, "call_2"));
|
||||
};
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { afterAll, describe, expect, test } from "bun:test";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { mkdirSync, readFileSync, unlinkSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { patchFileTool, readFileTool, shellExecTool, writeFileTool } from "../src/tools/index.js";
|
||||
|
||||
const TMP_DIR = join(tmpdir(), `tools-test-${randomBytes(4).toString("hex")}`);
|
||||
mkdirSync(TMP_DIR, { recursive: true });
|
||||
|
||||
const tmpFile = (name: string) => join(TMP_DIR, name);
|
||||
|
||||
const cleanupFiles: string[] = [];
|
||||
|
||||
afterAll(() => {
|
||||
for (const f of cleanupFiles) {
|
||||
try {
|
||||
unlinkSync(f);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
try {
|
||||
unlinkSync(TMP_DIR);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
});
|
||||
|
||||
describe("read_file", () => {
|
||||
test("reads file with line numbers", async () => {
|
||||
const p = tmpFile("read-test.txt");
|
||||
cleanupFiles.push(p);
|
||||
const content = "line1\nline2\nline3\n";
|
||||
require("node:fs").writeFileSync(p, content);
|
||||
|
||||
const result = await readFileTool.handler(
|
||||
JSON.stringify({ path: p, offset: null, limit: null }),
|
||||
);
|
||||
expect(result).toContain("1|line1");
|
||||
expect(result).toContain("2|line2");
|
||||
expect(result).toContain("3|line3");
|
||||
});
|
||||
|
||||
test("reads with offset and limit", async () => {
|
||||
const p = tmpFile("read-test2.txt");
|
||||
cleanupFiles.push(p);
|
||||
require("node:fs").writeFileSync(p, "a\nb\nc\nd\ne\n");
|
||||
|
||||
const result = await readFileTool.handler(JSON.stringify({ path: p, offset: 2, limit: 2 }));
|
||||
expect(result).toBe("2|b\n3|c");
|
||||
});
|
||||
|
||||
test("returns error for missing file", async () => {
|
||||
const result = await readFileTool.handler(
|
||||
JSON.stringify({ path: "/nonexistent/file.txt", offset: null, limit: null }),
|
||||
);
|
||||
expect(result).toContain("Error:");
|
||||
});
|
||||
});
|
||||
|
||||
describe("write_file", () => {
|
||||
test("writes file and creates dirs", async () => {
|
||||
const p = tmpFile("sub/write-test.txt");
|
||||
cleanupFiles.push(p);
|
||||
|
||||
const result = await writeFileTool.handler(JSON.stringify({ path: p, content: "hello world" }));
|
||||
expect(result).toContain("11 bytes");
|
||||
expect(readFileSync(p, "utf-8")).toBe("hello world");
|
||||
});
|
||||
});
|
||||
|
||||
describe("patch_file", () => {
|
||||
test("patches file content", async () => {
|
||||
const p = tmpFile("patch-test.txt");
|
||||
cleanupFiles.push(p);
|
||||
require("node:fs").writeFileSync(p, "foo bar baz");
|
||||
|
||||
const result = await patchFileTool.handler(
|
||||
JSON.stringify({ path: p, old_string: "bar", new_string: "qux" }),
|
||||
);
|
||||
expect(result).toContain("Successfully");
|
||||
expect(readFileSync(p, "utf-8")).toBe("foo qux baz");
|
||||
});
|
||||
|
||||
test("errors on not found", async () => {
|
||||
const p = tmpFile("patch-test2.txt");
|
||||
cleanupFiles.push(p);
|
||||
require("node:fs").writeFileSync(p, "foo");
|
||||
|
||||
const result = await patchFileTool.handler(
|
||||
JSON.stringify({ path: p, old_string: "xyz", new_string: "abc" }),
|
||||
);
|
||||
expect(result).toContain("not found");
|
||||
});
|
||||
|
||||
test("errors on non-unique match", async () => {
|
||||
const p = tmpFile("patch-test3.txt");
|
||||
cleanupFiles.push(p);
|
||||
require("node:fs").writeFileSync(p, "aaa bbb aaa");
|
||||
|
||||
const result = await patchFileTool.handler(
|
||||
JSON.stringify({ path: p, old_string: "aaa", new_string: "ccc" }),
|
||||
);
|
||||
expect(result).toContain("not unique");
|
||||
});
|
||||
});
|
||||
|
||||
describe("shell_exec", () => {
|
||||
test("runs echo", async () => {
|
||||
const result = await shellExecTool.handler(
|
||||
JSON.stringify({ command: "echo hello", timeout: null }),
|
||||
);
|
||||
expect(result.trim()).toBe("hello");
|
||||
});
|
||||
|
||||
test("handles timeout", async () => {
|
||||
const result = await shellExecTool.handler(JSON.stringify({ command: "sleep 10", timeout: 1 }));
|
||||
expect(result).toContain("timed out");
|
||||
});
|
||||
});
|
||||
@@ -1,27 +1,35 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-agent-react",
|
||||
"version": "0.3.5",
|
||||
"version": "0.4.5",
|
||||
"files": [
|
||||
"src",
|
||||
"dist",
|
||||
"package.json"
|
||||
],
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"default": "./src/index.ts"
|
||||
"bun": "./src/index.ts",
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"test": "bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/workflow-protocol": "workspace:*",
|
||||
"@uncaged/workflow-reactor": "workspace:*",
|
||||
"@uncaged/workflow-util-agent": "workspace:*"
|
||||
"@uncaged/workflow-protocol": "workspace:^",
|
||||
"@uncaged/workflow-reactor": "workspace:^",
|
||||
"@uncaged/workflow-util-agent": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
"zod": "^4.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "^4.0.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
export { createReactAdapter } from "./create-react-adapter.js";
|
||||
export type { ToolEntry, ToolHandler } from "./tools/index.js";
|
||||
export { defaultToolHandler, defaultTools } from "./tools/index.js";
|
||||
export type { ReactAdapterConfig, ReactToolHandler } from "./types.js";
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { ToolDefinition } from "@uncaged/workflow-reactor";
|
||||
import { patchFileTool } from "./patch-file.js";
|
||||
import { readFileTool } from "./read-file.js";
|
||||
import { shellExecTool } from "./shell-exec.js";
|
||||
import type { ToolEntry } from "./types.js";
|
||||
import { writeFileTool } from "./write-file.js";
|
||||
|
||||
const ALL_TOOLS: ToolEntry[] = [readFileTool, writeFileTool, patchFileTool, shellExecTool];
|
||||
|
||||
export const defaultTools: readonly ToolDefinition[] = ALL_TOOLS.map((t) => t.definition);
|
||||
|
||||
export async function defaultToolHandler(name: string, args: string): Promise<string> {
|
||||
const entry = ALL_TOOLS.find((t) => t.definition.function.name === name);
|
||||
if (!entry) return `Unknown tool: ${name}`;
|
||||
return entry.handler(args);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export { defaultToolHandler, defaultTools } from "./defaults.js";
|
||||
export { patchFileTool } from "./patch-file.js";
|
||||
export { readFileTool } from "./read-file.js";
|
||||
export { shellExecTool } from "./shell-exec.js";
|
||||
export type { ToolEntry, ToolHandler } from "./types.js";
|
||||
export { writeFileTool } from "./write-file.js";
|
||||
@@ -0,0 +1,43 @@
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import type { ToolEntry } from "./types.js";
|
||||
|
||||
export const patchFileTool: ToolEntry = {
|
||||
definition: {
|
||||
type: "function",
|
||||
function: {
|
||||
name: "patch_file",
|
||||
description: "Find and replace a string in a file (first occurrence only).",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
path: { type: "string", description: "Path to the file" },
|
||||
old_string: { type: "string", description: "Text to find" },
|
||||
new_string: { type: "string", description: "Replacement text" },
|
||||
},
|
||||
required: ["path", "old_string", "new_string"],
|
||||
},
|
||||
},
|
||||
},
|
||||
handler: async (args: string): Promise<string> => {
|
||||
try {
|
||||
const parsed = JSON.parse(args) as { path: string; old_string: string; new_string: string };
|
||||
const content = await readFile(parsed.path, "utf-8");
|
||||
const firstIdx = content.indexOf(parsed.old_string);
|
||||
if (firstIdx === -1) {
|
||||
return `Error: old_string not found in ${parsed.path}`;
|
||||
}
|
||||
const secondIdx = content.indexOf(parsed.old_string, firstIdx + 1);
|
||||
if (secondIdx !== -1) {
|
||||
return `Error: old_string is not unique in ${parsed.path} (found multiple occurrences)`;
|
||||
}
|
||||
const updated =
|
||||
content.slice(0, firstIdx) +
|
||||
parsed.new_string +
|
||||
content.slice(firstIdx + parsed.old_string.length);
|
||||
await writeFile(parsed.path, updated);
|
||||
return `Successfully patched ${parsed.path}`;
|
||||
} catch (err) {
|
||||
return `Error: ${err instanceof Error ? err.message : String(err)}`;
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import type { ToolEntry } from "./types.js";
|
||||
|
||||
export const readFileTool: ToolEntry = {
|
||||
definition: {
|
||||
type: "function",
|
||||
function: {
|
||||
name: "read_file",
|
||||
description: "Read a text file and return lines with line numbers.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
path: { type: "string", description: "Path to the file to read" },
|
||||
offset: {
|
||||
type: ["number", "null"],
|
||||
description: "Start line number (1-indexed, default: 1)",
|
||||
},
|
||||
limit: { type: ["number", "null"], description: "Max lines to read (default: all)" },
|
||||
},
|
||||
required: ["path"],
|
||||
},
|
||||
},
|
||||
},
|
||||
handler: async (args: string): Promise<string> => {
|
||||
try {
|
||||
const parsed = JSON.parse(args) as {
|
||||
path: string;
|
||||
offset: number | null;
|
||||
limit: number | null;
|
||||
};
|
||||
const content = await readFile(parsed.path, "utf-8");
|
||||
const allLines = content.split("\n");
|
||||
const offset = parsed.offset ?? 1;
|
||||
const start = Math.max(0, offset - 1);
|
||||
const end =
|
||||
parsed.limit != null ? Math.min(allLines.length, start + parsed.limit) : allLines.length;
|
||||
const lines = allLines.slice(start, end);
|
||||
return lines.map((line, i) => `${start + i + 1}|${line}`).join("\n");
|
||||
} catch (err) {
|
||||
return `Error: ${err instanceof Error ? err.message : String(err)}`;
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import type { ToolEntry } from "./types.js";
|
||||
|
||||
const MAX_OUTPUT = 10000;
|
||||
|
||||
function truncate(text: string): string {
|
||||
return text.length > MAX_OUTPUT ? `${text.slice(0, MAX_OUTPUT)}\n...(truncated)` : text;
|
||||
}
|
||||
|
||||
function classifyExecError(err: unknown): string {
|
||||
if (
|
||||
err &&
|
||||
typeof err === "object" &&
|
||||
"status" in err &&
|
||||
(err as { status: unknown }).status === null
|
||||
) {
|
||||
return "Error: command timed out";
|
||||
}
|
||||
if (err && typeof err === "object" && "stderr" in err) {
|
||||
const e = err as { stderr: string; stdout: string; status: number };
|
||||
const combined = `${e.stdout ?? ""}${e.stderr ?? ""}`;
|
||||
return truncate(combined) || `Error: command exited with status ${e.status}`;
|
||||
}
|
||||
return `Error: ${err instanceof Error ? err.message : String(err)}`;
|
||||
}
|
||||
|
||||
export const shellExecTool: ToolEntry = {
|
||||
definition: {
|
||||
type: "function",
|
||||
function: {
|
||||
name: "shell_exec",
|
||||
description: "Execute a shell command and return stdout + stderr.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
command: { type: "string", description: "Shell command to run" },
|
||||
timeout: { type: ["number", "null"], description: "Timeout in seconds (default: 30)" },
|
||||
},
|
||||
required: ["command"],
|
||||
},
|
||||
},
|
||||
},
|
||||
handler: async (args: string): Promise<string> => {
|
||||
try {
|
||||
const parsed = JSON.parse(args) as { command: string; timeout: number | null };
|
||||
const timeoutMs = (parsed.timeout ?? 30) * 1000;
|
||||
const output = execSync(parsed.command, {
|
||||
encoding: "utf-8",
|
||||
timeout: timeoutMs,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
maxBuffer: MAX_OUTPUT * 2,
|
||||
});
|
||||
return truncate(output);
|
||||
} catch (err: unknown) {
|
||||
return classifyExecError(err);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { ToolDefinition } from "@uncaged/workflow-reactor";
|
||||
|
||||
export type ToolHandler = (args: string) => Promise<string>;
|
||||
|
||||
export type ToolEntry = {
|
||||
definition: ToolDefinition;
|
||||
handler: ToolHandler;
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { dirname } from "node:path";
|
||||
import type { ToolEntry } from "./types.js";
|
||||
|
||||
export const writeFileTool: ToolEntry = {
|
||||
definition: {
|
||||
type: "function",
|
||||
function: {
|
||||
name: "write_file",
|
||||
description: "Write content to a file, creating parent directories as needed.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
path: { type: "string", description: "Path to write" },
|
||||
content: { type: "string", description: "File content" },
|
||||
},
|
||||
required: ["path", "content"],
|
||||
},
|
||||
},
|
||||
},
|
||||
handler: async (args: string): Promise<string> => {
|
||||
try {
|
||||
const parsed = JSON.parse(args) as { path: string; content: string };
|
||||
await mkdir(dirname(parsed.path), { recursive: true });
|
||||
const buf = Buffer.from(parsed.content, "utf-8");
|
||||
await writeFile(parsed.path, buf);
|
||||
return `Successfully wrote ${buf.length} bytes to ${parsed.path}`;
|
||||
} catch (err) {
|
||||
return `Error: ${err instanceof Error ? err.message : String(err)}`;
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
# @uncaged/workflow-cas
|
||||
|
||||
## 0.4.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-protocol@0.4.5
|
||||
- @uncaged/workflow-util@0.4.5
|
||||
|
||||
## 0.4.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-protocol@0.4.4
|
||||
- @uncaged/workflow-util@0.4.4
|
||||
|
||||
## 0.4.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Include src/ in published packages so bun runtime can resolve the 'bun' exports condition.
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-protocol@0.4.3
|
||||
- @uncaged/workflow-util@0.4.3
|
||||
|
||||
## 0.4.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fix workspace dependency resolution: use workspace:^ so published packages resolve to compatible versions instead of exact (non-existent) versions.
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-protocol@0.4.2
|
||||
- @uncaged/workflow-util@0.4.2
|
||||
|
||||
## 0.4.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Fix package exports for published packages and adopt changesets for version management.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-protocol@0.4.0
|
||||
- @uncaged/workflow-util@0.4.0
|
||||
@@ -1,23 +1,32 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-cas",
|
||||
"version": "0.3.5",
|
||||
"version": "0.4.5",
|
||||
"files": [
|
||||
"src",
|
||||
"dist",
|
||||
"package.json"
|
||||
],
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "bun test"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"bun": "./src/index.ts",
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./src/index.ts"
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/workflow-protocol": "workspace:*",
|
||||
"@uncaged/workflow-util": "workspace:*",
|
||||
"@uncaged/workflow-protocol": "workspace:^",
|
||||
"@uncaged/workflow-util": "workspace:^",
|
||||
"xxhashjs": "^0.2.2",
|
||||
"yaml": "^2.7.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-dashboard",
|
||||
"version": "0.1.0",
|
||||
"files": [
|
||||
"dist",
|
||||
"package.json"
|
||||
],
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -9,7 +13,6 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dagrejs/dagre": "^3.0.0",
|
||||
"@xyflow/react": "^12.10.2",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
|
||||
@@ -26,11 +26,11 @@ function authHeaders(): Record<string, string> {
|
||||
return {};
|
||||
}
|
||||
|
||||
function agentBase(agent: string): string {
|
||||
function clientBase(client: string): string {
|
||||
if (GATEWAY_URL) {
|
||||
return `${GATEWAY_URL}/api/agents/${agent}`;
|
||||
return `${GATEWAY_URL}/api/clients/${client}`;
|
||||
}
|
||||
// Local dev: proxy via vite, no agent prefix
|
||||
// Local dev: proxy via vite, no client prefix
|
||||
return "/api";
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ async function fetchJson<T>(base: string, path: string): Promise<T> {
|
||||
|
||||
// ── Endpoint types ──────────────────────────────────────────────────
|
||||
|
||||
export type AgentEndpoint = {
|
||||
export type ClientEndpoint = {
|
||||
name: string;
|
||||
url: string;
|
||||
status: string;
|
||||
@@ -141,61 +141,61 @@ export type WorkflowDetail = {
|
||||
|
||||
// ── Gateway endpoints ───────────────────────────────────────────────
|
||||
|
||||
export function listAgents(): Promise<AgentEndpoint[]> {
|
||||
export function listClients(): Promise<ClientEndpoint[]> {
|
||||
const url = GATEWAY_URL || "";
|
||||
return fetchJson(url, "/api/gateway/endpoints");
|
||||
}
|
||||
|
||||
// ── Agent-scoped endpoints ──────────────────────────────────────────
|
||||
// ── Client-scoped endpoints ──────────────────────────────────────────
|
||||
|
||||
export function listWorkflows(agent: string): Promise<{ workflows: WorkflowSummary[] }> {
|
||||
return fetchJson(agentBase(agent), "/workflows");
|
||||
export function listWorkflows(client: string): Promise<{ workflows: WorkflowSummary[] }> {
|
||||
return fetchJson(clientBase(client), "/workflows");
|
||||
}
|
||||
|
||||
export async function getWorkflowDetail(agent: string, name: string): Promise<WorkflowDetail> {
|
||||
return fetchJson<WorkflowDetail>(agentBase(agent), `/workflows/${encodeURIComponent(name)}`);
|
||||
export async function getWorkflowDetail(client: string, name: string): Promise<WorkflowDetail> {
|
||||
return fetchJson<WorkflowDetail>(clientBase(client), `/workflows/${encodeURIComponent(name)}`);
|
||||
}
|
||||
|
||||
export async function getWorkflowDescriptor(
|
||||
agent: string,
|
||||
client: string,
|
||||
name: string,
|
||||
): Promise<WorkflowDescriptor | null> {
|
||||
const res = await getWorkflowDetail(agent, name);
|
||||
const res = await getWorkflowDetail(client, name);
|
||||
return res.descriptor;
|
||||
}
|
||||
|
||||
export function listThreads(agent: string): Promise<{ threads: ThreadSummary[] }> {
|
||||
return fetchJson(agentBase(agent), "/threads");
|
||||
export function listThreads(client: string): Promise<{ threads: ThreadSummary[] }> {
|
||||
return fetchJson(clientBase(client), "/threads");
|
||||
}
|
||||
|
||||
export function listRunningThreads(agent: string): Promise<{ threads: ThreadSummary[] }> {
|
||||
return fetchJson(agentBase(agent), "/threads/running");
|
||||
export function listRunningThreads(client: string): Promise<{ threads: ThreadSummary[] }> {
|
||||
return fetchJson(clientBase(client), "/threads/running");
|
||||
}
|
||||
|
||||
export function getThread(agent: string, id: string): Promise<{ records: ThreadRecord[] }> {
|
||||
return fetchJson(agentBase(agent), `/threads/${id}`);
|
||||
export function getThread(client: string, id: string): Promise<{ records: ThreadRecord[] }> {
|
||||
return fetchJson(clientBase(client), `/threads/${id}`);
|
||||
}
|
||||
|
||||
export function runThread(
|
||||
agent: string,
|
||||
client: string,
|
||||
workflow: string,
|
||||
prompt: string,
|
||||
): Promise<{ threadId: string }> {
|
||||
return postJson(agentBase(agent), "/threads", { workflow, prompt });
|
||||
return postJson(clientBase(client), "/threads", { workflow, prompt });
|
||||
}
|
||||
|
||||
export function killThread(agent: string, threadId: string): Promise<{ ok: boolean }> {
|
||||
return postJson(agentBase(agent), `/threads/${threadId}/kill`, {});
|
||||
export function killThread(client: string, threadId: string): Promise<{ ok: boolean }> {
|
||||
return postJson(clientBase(client), `/threads/${threadId}/kill`, {});
|
||||
}
|
||||
|
||||
export function pauseThread(agent: string, threadId: string): Promise<{ ok: boolean }> {
|
||||
return postJson(agentBase(agent), `/threads/${threadId}/pause`, {});
|
||||
export function pauseThread(client: string, threadId: string): Promise<{ ok: boolean }> {
|
||||
return postJson(clientBase(client), `/threads/${threadId}/pause`, {});
|
||||
}
|
||||
|
||||
export function resumeThread(agent: string, threadId: string): Promise<{ ok: boolean }> {
|
||||
return postJson(agentBase(agent), `/threads/${threadId}/resume`, {});
|
||||
export function resumeThread(client: string, threadId: string): Promise<{ ok: boolean }> {
|
||||
return postJson(clientBase(client), `/threads/${threadId}/resume`, {});
|
||||
}
|
||||
|
||||
export function getAgentHealth(agent: string): Promise<{ ok: boolean }> {
|
||||
return fetchJson(agentBase(agent), "/healthz");
|
||||
export function getClientHealth(client: string): Promise<{ ok: boolean }> {
|
||||
return fetchJson(clientBase(client), "/healthz");
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import { useHashRoute } from "./use-hash-route.ts";
|
||||
|
||||
export function App() {
|
||||
const [authed, setAuthed] = useState(hasApiKey());
|
||||
const { view, agent, threadId, setView, setAgent, setThreadId } = useHashRoute();
|
||||
const { view, client, threadId, setView, setClient, setThreadId } = useHashRoute();
|
||||
const [showRun, setShowRun] = useState(false);
|
||||
|
||||
if (!authed) {
|
||||
@@ -22,36 +22,36 @@ export function App() {
|
||||
<div className="flex h-screen">
|
||||
<Sidebar
|
||||
view={view}
|
||||
agent={agent}
|
||||
client={client}
|
||||
onViewChange={setView}
|
||||
onAgentChange={setAgent}
|
||||
onClientChange={setClient}
|
||||
onLogout={() => {
|
||||
clearApiKey();
|
||||
setAuthed(false);
|
||||
}}
|
||||
/>
|
||||
<main className="flex-1 overflow-hidden flex flex-col">
|
||||
<StatusBar agent={agent} onRun={() => setShowRun(true)} />
|
||||
<StatusBar client={client} onRun={() => setShowRun(true)} />
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
{!agent && (
|
||||
{!client && (
|
||||
<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.
|
||||
Select an client from the sidebar to get started.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{agent && view === "threads" && threadId === null && (
|
||||
<ThreadList agent={agent} onSelect={setThreadId} />
|
||||
{client && view === "threads" && threadId === null && (
|
||||
<ThreadList client={client} onSelect={setThreadId} />
|
||||
)}
|
||||
{agent && view === "threads" && threadId !== null && (
|
||||
<ThreadDetail agent={agent} threadId={threadId} onBack={() => setThreadId(null)} />
|
||||
{client && view === "threads" && threadId !== null && (
|
||||
<ThreadDetail client={client} threadId={threadId} onBack={() => setThreadId(null)} />
|
||||
)}
|
||||
{agent && view === "workflows" && <WorkflowList agent={agent} />}
|
||||
{client && view === "workflows" && <WorkflowList client={client} />}
|
||||
</div>
|
||||
</main>
|
||||
{showRun && agent && (
|
||||
{showRun && client && (
|
||||
<RunDialog
|
||||
agent={agent}
|
||||
client={client}
|
||||
onClose={() => setShowRun(false)}
|
||||
onCreated={(id) => {
|
||||
setShowRun(false);
|
||||
|
||||
@@ -70,7 +70,6 @@ export function LoginPage({ onLogin }: Props) {
|
||||
borderColor: "var(--color-border)",
|
||||
color: "var(--color-text)",
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
{error && (
|
||||
<p className="text-xs mb-3" style={{ color: "var(--color-error)" }}>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Markdown } from "./markdown.tsx";
|
||||
|
||||
const ROLE_COLORS: Record<string, string> = {
|
||||
preparer: "#8b5cf6",
|
||||
agent: "#3b82f6",
|
||||
client: "#3b82f6",
|
||||
extractor: "#f59e0b",
|
||||
};
|
||||
|
||||
|
||||
@@ -3,13 +3,13 @@ import { listWorkflows, runThread } from "../api.ts";
|
||||
import { useFetch } from "../hooks.ts";
|
||||
|
||||
type Props = {
|
||||
agent: string;
|
||||
client: string;
|
||||
onClose: () => void;
|
||||
onCreated: (threadId: string) => void;
|
||||
};
|
||||
|
||||
export function RunDialog({ agent, onClose, onCreated }: Props) {
|
||||
const workflows = useFetch(() => listWorkflows(agent), [agent]);
|
||||
export function RunDialog({ client, onClose, onCreated }: Props) {
|
||||
const workflows = useFetch(() => listWorkflows(client), [client]);
|
||||
const [workflow, setWorkflow] = useState("");
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
@@ -21,7 +21,7 @@ export function RunDialog({ agent, onClose, onCreated }: Props) {
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await runThread(agent, workflow, prompt);
|
||||
const result = await runThread(client, workflow, prompt);
|
||||
onCreated(result.threadId);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
@@ -38,7 +38,7 @@ export function RunDialog({ agent, 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 on {agent}</h3>
|
||||
<h3 className="text-lg font-semibold mb-4">Run Thread on {client}</h3>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
import { useEffect } from "react";
|
||||
import type { AgentEndpoint } from "../api.ts";
|
||||
import { listAgents } from "../api.ts";
|
||||
import type { ClientEndpoint } from "../api.ts";
|
||||
import { listClients } from "../api.ts";
|
||||
import { useFetch } from "../hooks.ts";
|
||||
|
||||
type Props = {
|
||||
view: "threads" | "workflows";
|
||||
agent: string | null;
|
||||
client: string | null;
|
||||
onViewChange: (v: "threads" | "workflows") => void;
|
||||
onAgentChange: (a: string | null) => void;
|
||||
onClientChange: (a: string | null) => void;
|
||||
onLogout: () => void;
|
||||
};
|
||||
|
||||
export function Sidebar({ view, agent, onViewChange, onAgentChange, onLogout }: Props) {
|
||||
const { status, data } = useFetch(() => listAgents(), []);
|
||||
export function Sidebar({ view, client, onViewChange, onClientChange, onLogout }: Props) {
|
||||
const { status, data } = useFetch(() => listClients(), []);
|
||||
|
||||
const agents: AgentEndpoint[] = status === "ok" ? data : [];
|
||||
const clients: ClientEndpoint[] = status === "ok" ? data : [];
|
||||
|
||||
// Auto-select first agent when none is selected
|
||||
// Auto-select first client when none is selected
|
||||
useEffect(() => {
|
||||
if (agent === null && agents.length > 0) {
|
||||
onAgentChange(agents[0].name);
|
||||
if (client === null && clients.length > 0) {
|
||||
onClientChange(clients[0].name);
|
||||
}
|
||||
}, [agent, agents, onAgentChange]);
|
||||
}, [client, clients, onClientChange]);
|
||||
|
||||
const viewItems = [
|
||||
{ key: "threads" as const, label: "Threads", icon: "⚡" },
|
||||
@@ -42,33 +42,33 @@ export function Sidebar({ view, agent, onViewChange, onAgentChange, onLogout }:
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Agent selector */}
|
||||
{/* Client 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"
|
||||
htmlFor="client-select"
|
||||
>
|
||||
Agent
|
||||
Client
|
||||
</label>
|
||||
<select
|
||||
id="agent-select"
|
||||
id="client-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)}
|
||||
value={client ?? ""}
|
||||
onChange={(e) => onClientChange(e.target.value || null)}
|
||||
disabled={status === "loading"}
|
||||
>
|
||||
{status === "loading" ? (
|
||||
<option value="">Loading…</option>
|
||||
) : agents.length === 0 ? (
|
||||
<option value="">No agents online</option>
|
||||
) : clients.length === 0 ? (
|
||||
<option value="">No clients online</option>
|
||||
) : (
|
||||
agents.map((a) => (
|
||||
clients.map((a) => (
|
||||
<option key={a.name} value={a.name}>
|
||||
{a.status === "online" ? "🟢" : "🔴"} {a.name}
|
||||
</option>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { getAgentHealth } from "../api.ts";
|
||||
import { getClientHealth } from "../api.ts";
|
||||
|
||||
type HealthStatus = "connected" | "disconnected" | "reconnecting";
|
||||
|
||||
type Props = {
|
||||
agent: string | null;
|
||||
client: string | null;
|
||||
onRun: () => void;
|
||||
};
|
||||
|
||||
@@ -18,17 +18,17 @@ function statusLabel(status: HealthStatus): { text: string; color: string } {
|
||||
return { text: "● Offline", color: "var(--color-error)" };
|
||||
}
|
||||
|
||||
export function StatusBar({ agent, onRun }: Props) {
|
||||
export function StatusBar({ client, onRun }: Props) {
|
||||
const [status, setStatus] = useState<HealthStatus>("disconnected");
|
||||
const wasConnectedRef = useRef(false);
|
||||
|
||||
const checkHealth = useCallback(async () => {
|
||||
if (!agent) {
|
||||
if (!client) {
|
||||
setStatus("disconnected");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await getAgentHealth(agent);
|
||||
await getClientHealth(client);
|
||||
wasConnectedRef.current = true;
|
||||
setStatus("connected");
|
||||
} catch {
|
||||
@@ -38,7 +38,7 @@ export function StatusBar({ agent, onRun }: Props) {
|
||||
setStatus("disconnected");
|
||||
}
|
||||
}
|
||||
}, [agent]);
|
||||
}, [client]);
|
||||
|
||||
useEffect(() => {
|
||||
wasConnectedRef.current = false;
|
||||
@@ -57,17 +57,17 @@ export function StatusBar({ agent, onRun }: Props) {
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<span style={{ color: "var(--color-text-muted)" }}>
|
||||
{agent ? `Agent: ${agent}` : "No agent selected"}
|
||||
{client ? `Client: ${client}` : "No client selected"}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRun}
|
||||
disabled={!agent}
|
||||
disabled={!client}
|
||||
className="px-3 py-1 rounded text-xs font-medium"
|
||||
style={{
|
||||
background: agent ? "var(--color-accent)" : "var(--color-border)",
|
||||
background: client ? "var(--color-accent)" : "var(--color-border)",
|
||||
color: "#fff",
|
||||
opacity: agent ? 1 : 0.5,
|
||||
opacity: client ? 1 : 0.5,
|
||||
}}
|
||||
>
|
||||
▶ Run Thread
|
||||
|
||||
@@ -14,7 +14,7 @@ import { RecordCard } from "./record-card.tsx";
|
||||
import { type NodeState, WorkflowGraph } from "./workflow-graph/index.ts";
|
||||
|
||||
type Props = {
|
||||
agent: string;
|
||||
client: string;
|
||||
threadId: string;
|
||||
onBack: () => void;
|
||||
};
|
||||
@@ -26,53 +26,6 @@ function extractWorkflowName(records: readonly ThreadRecord[]): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
type GraphPanelProps = {
|
||||
descriptor: WorkflowDescriptor;
|
||||
workflowName: string | null;
|
||||
nodeStates: Map<string, NodeState>;
|
||||
onNodeClick: ((roleName: string) => void) | null;
|
||||
};
|
||||
|
||||
function GraphPanel({ descriptor, workflowName, nodeStates, onNodeClick }: GraphPanelProps) {
|
||||
const [open, setOpen] = useState(true);
|
||||
const edgeCount = descriptor.graph.edges.length;
|
||||
return (
|
||||
<div
|
||||
className="mb-4 rounded-lg border overflow-hidden"
|
||||
style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="w-full flex items-center justify-between px-3 py-2 text-xs"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
<span className="font-mono">
|
||||
{open ? "▼" : "▶"} Workflow graph
|
||||
{workflowName !== null && (
|
||||
<span className="ml-2" style={{ color: "var(--color-text)" }}>
|
||||
{workflowName}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span>
|
||||
{edgeCount} edge{edgeCount === 1 ? "" : "s"}
|
||||
</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div style={{ height: 300, width: "100%" }}>
|
||||
<WorkflowGraph
|
||||
graph={descriptor.graph}
|
||||
roles={descriptor.roles}
|
||||
nodeStates={nodeStates}
|
||||
onNodeClick={onNodeClick}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function computeNodeStates(records: readonly ThreadRecord[]): Map<string, NodeState> {
|
||||
const states = new Map<string, NodeState>();
|
||||
const roleRecords = records.filter(
|
||||
@@ -99,9 +52,9 @@ function computeNodeStates(records: readonly ThreadRecord[]): Map<string, NodeSt
|
||||
return states;
|
||||
}
|
||||
|
||||
export function ThreadDetail({ agent, threadId, onBack }: Props) {
|
||||
const sse = useSSE(agent, threadId);
|
||||
const { status, data, error } = useFetch(() => getThread(agent, threadId), [agent, threadId]);
|
||||
export function ThreadDetail({ client, threadId, onBack }: Props) {
|
||||
const sse = useSSE(client, threadId);
|
||||
const { status, data, error } = useFetch(() => getThread(client, threadId), [client, threadId]);
|
||||
const [actionStatus, setActionStatus] = useState<string | null>(null);
|
||||
const recordsEndRef = useRef<HTMLDivElement>(null);
|
||||
const firstCardByRoleRef = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||
@@ -119,8 +72,8 @@ export function ThreadDetail({ agent, threadId, onBack }: Props) {
|
||||
|
||||
const descriptorFetch = useFetch<WorkflowDescriptor | null>(
|
||||
() =>
|
||||
workflowName === null ? Promise.resolve(null) : getWorkflowDescriptor(agent, workflowName),
|
||||
[agent, workflowName],
|
||||
workflowName === null ? Promise.resolve(null) : getWorkflowDescriptor(client, workflowName),
|
||||
[client, workflowName],
|
||||
);
|
||||
|
||||
const descriptor = descriptorFetch.status === "ok" ? descriptorFetch.data : null;
|
||||
@@ -164,7 +117,7 @@ export function ThreadDetail({ agent, threadId, onBack }: Props) {
|
||||
setActionStatus(`${action}ing...`);
|
||||
try {
|
||||
const fn = action === "kill" ? killThread : action === "pause" ? pauseThread : resumeThread;
|
||||
await fn(agent, threadId);
|
||||
await fn(client, threadId);
|
||||
setActionStatus(`${action} sent ✓`);
|
||||
} catch (e) {
|
||||
setActionStatus(`${action} failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||
@@ -227,46 +180,85 @@ export function ThreadDetail({ agent, threadId, onBack }: Props) {
|
||||
</p>
|
||||
)}
|
||||
|
||||
{descriptor !== null && descriptor.graph.edges.length > 0 && (
|
||||
<GraphPanel
|
||||
descriptor={descriptor}
|
||||
workflowName={workflowName}
|
||||
nodeStates={nodeStates}
|
||||
onNodeClick={handleGraphNodeClick}
|
||||
/>
|
||||
)}
|
||||
<div className="flex gap-4" style={{ minHeight: "calc(100vh - 120px)" }}>
|
||||
{descriptor !== null && descriptor.graph.edges.length > 0 && (
|
||||
<div
|
||||
className="shrink-0"
|
||||
style={{
|
||||
width: 280,
|
||||
position: "sticky",
|
||||
top: 16,
|
||||
height: "calc(100vh - 120px)",
|
||||
alignSelf: "flex-start",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="rounded-lg border h-full flex flex-col overflow-hidden"
|
||||
style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }}
|
||||
>
|
||||
<div
|
||||
className="flex items-center justify-between px-3 py-2 text-xs"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
<span className="font-mono">
|
||||
Workflow graph
|
||||
{workflowName !== null && (
|
||||
<span className="ml-2" style={{ color: "var(--color-text)" }}>
|
||||
{workflowName}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span>
|
||||
{descriptor.graph.edges.length} edge
|
||||
{descriptor.graph.edges.length === 1 ? "" : "s"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<WorkflowGraph
|
||||
graph={descriptor.graph}
|
||||
roles={descriptor.roles}
|
||||
nodeStates={nodeStates}
|
||||
onNodeClick={handleGraphNodeClick}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === "loading" && !liveActive && records.length === 0 && (
|
||||
<p style={{ color: "var(--color-text-muted)" }}>Loading...</p>
|
||||
)}
|
||||
{status === "error" && !liveActive && (
|
||||
<p style={{ color: "var(--color-error)" }}>Error: {error}</p>
|
||||
)}
|
||||
{(status === "ok" || liveActive || records.length > 0) && (
|
||||
<div className="space-y-3">
|
||||
{records.map((r, i) => {
|
||||
const key = `${threadId}-${i}`;
|
||||
if (r.type === "role") {
|
||||
const isFirstForRole = firstIndexByRole.get(r.role) === i;
|
||||
const flash = highlightedRole === r.role;
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
ref={(el) => {
|
||||
if (!isFirstForRole) return;
|
||||
if (el !== null) firstCardByRoleRef.current.set(r.role, el);
|
||||
else firstCardByRoleRef.current.delete(r.role);
|
||||
}}
|
||||
>
|
||||
<RecordCard record={r} highlighted={flash} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <RecordCard key={key} record={r} highlighted={false} />;
|
||||
})}
|
||||
<div ref={recordsEndRef} aria-hidden />
|
||||
<div className="flex-1 min-w-0">
|
||||
{status === "loading" && !liveActive && records.length === 0 && (
|
||||
<p style={{ color: "var(--color-text-muted)" }}>Loading...</p>
|
||||
)}
|
||||
{status === "error" && !liveActive && (
|
||||
<p style={{ color: "var(--color-error)" }}>Error: {error}</p>
|
||||
)}
|
||||
{(status === "ok" || liveActive || records.length > 0) && (
|
||||
<div className="space-y-3">
|
||||
{records.map((r, i) => {
|
||||
const key = `${threadId}-${i}`;
|
||||
if (r.type === "role") {
|
||||
const isFirstForRole = firstIndexByRole.get(r.role) === i;
|
||||
const flash = highlightedRole === r.role;
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
ref={(el) => {
|
||||
if (!isFirstForRole) return;
|
||||
if (el !== null) firstCardByRoleRef.current.set(r.role, el);
|
||||
else firstCardByRoleRef.current.delete(r.role);
|
||||
}}
|
||||
>
|
||||
<RecordCard record={r} highlighted={flash} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <RecordCard key={key} record={r} highlighted={false} />;
|
||||
})}
|
||||
<div ref={recordsEndRef} aria-hidden />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,12 +2,12 @@ import { listThreads } from "../api.ts";
|
||||
import { useFetch } from "../hooks.ts";
|
||||
|
||||
type Props = {
|
||||
agent: string;
|
||||
client: string;
|
||||
onSelect: (id: string) => void;
|
||||
};
|
||||
|
||||
export function ThreadList({ agent, onSelect }: Props) {
|
||||
const { status, data, error } = useFetch(() => listThreads(agent), [agent]);
|
||||
export function ThreadList({ client, onSelect }: Props) {
|
||||
const { status, data, error } = useFetch(() => listThreads(client), [client]);
|
||||
|
||||
if (status === "loading")
|
||||
return <p style={{ color: "var(--color-text-muted)" }}>Loading threads...</p>;
|
||||
|
||||
@@ -1,12 +1,36 @@
|
||||
import {
|
||||
BaseEdge,
|
||||
EdgeLabelRenderer,
|
||||
type EdgeProps,
|
||||
getBezierPath,
|
||||
getSmoothStepPath,
|
||||
} from "@xyflow/react";
|
||||
import { BaseEdge, EdgeLabelRenderer, type EdgeProps, getSmoothStepPath } from "@xyflow/react";
|
||||
import type { ConditionEdgeData } from "./types.ts";
|
||||
|
||||
// Must match the FEEDBACK_OFFSET_X in use-layout.ts
|
||||
const FEEDBACK_OFFSET_X = 140;
|
||||
// Radius for feedback edge corners
|
||||
const FEEDBACK_RADIUS = 16;
|
||||
|
||||
/**
|
||||
* Build an SVG path for a feedback (back) edge that routes to the given side of the nodes.
|
||||
* The path goes: source → arc → vertical up → arc → target
|
||||
*/
|
||||
function feedbackPath(sourceX: number, sourceY: number, targetX: number, targetY: number, side: "right" | "left"): string {
|
||||
const d = side === "right" ? 1 : -1;
|
||||
const offsetX =
|
||||
side === "right"
|
||||
? Math.max(sourceX, targetX) + FEEDBACK_OFFSET_X
|
||||
: Math.min(sourceX, targetX) - FEEDBACK_OFFSET_X;
|
||||
const r = FEEDBACK_RADIUS;
|
||||
|
||||
const segments = [
|
||||
`M ${sourceX} ${sourceY}`,
|
||||
`L ${offsetX - d * r} ${sourceY}`,
|
||||
`Q ${offsetX} ${sourceY} ${offsetX} ${sourceY - r}`,
|
||||
`L ${offsetX} ${targetY + r}`,
|
||||
`Q ${offsetX} ${targetY} ${offsetX - d * r} ${targetY}`,
|
||||
`L ${targetX} ${targetY}`,
|
||||
];
|
||||
|
||||
return segments.join(" ");
|
||||
}
|
||||
|
||||
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: edge routing logic is inherently branchy
|
||||
export function ConditionEdge(props: EdgeProps) {
|
||||
const {
|
||||
id,
|
||||
@@ -24,28 +48,43 @@ export function ConditionEdge(props: EdgeProps) {
|
||||
const edgeData = data as ConditionEdgeData | undefined;
|
||||
const isFallback = edgeData?.isFallback ?? false;
|
||||
const isSelfLoop = source === target;
|
||||
const isFeedback = edgeData?.isFeedback ?? false;
|
||||
|
||||
const [path, labelX, labelY] = isSelfLoop
|
||||
? getSmoothStepPath({
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
sourcePosition,
|
||||
targetPosition,
|
||||
borderRadius: 20,
|
||||
})
|
||||
: getBezierPath({
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
sourcePosition,
|
||||
targetPosition,
|
||||
});
|
||||
let path: string;
|
||||
let defaultLabelX: number;
|
||||
let defaultLabelY: number;
|
||||
|
||||
const stroke = isFallback ? "var(--color-text-muted)" : "var(--color-text)";
|
||||
const strokeDasharray = isFallback ? "5 4" : undefined;
|
||||
if (isFeedback) {
|
||||
const side = edgeData?.feedbackSide ?? "right";
|
||||
path = feedbackPath(sourceX, sourceY, targetX, targetY, side);
|
||||
const offsetX =
|
||||
side === "right"
|
||||
? Math.max(sourceX, targetX) + FEEDBACK_OFFSET_X
|
||||
: Math.min(sourceX, targetX) - FEEDBACK_OFFSET_X;
|
||||
defaultLabelX = offsetX;
|
||||
defaultLabelY = (sourceY + targetY) / 2;
|
||||
} else {
|
||||
const result = getSmoothStepPath({
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
sourcePosition,
|
||||
targetPosition,
|
||||
borderRadius: isSelfLoop ? 20 : 8,
|
||||
offset: isSelfLoop ? 50 : undefined,
|
||||
});
|
||||
path = result[0];
|
||||
defaultLabelX = result[1];
|
||||
defaultLabelY = result[2];
|
||||
}
|
||||
|
||||
const stroke = "var(--color-accent)";
|
||||
const label = isFallback ? "" : (edgeData?.condition ?? "");
|
||||
|
||||
// Use pre-computed label position if available, otherwise fall back to default
|
||||
const labelX = edgeData?.labelX ?? defaultLabelX;
|
||||
const labelY = edgeData?.labelY ?? defaultLabelY;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -53,21 +92,23 @@ export function ConditionEdge(props: EdgeProps) {
|
||||
id={id}
|
||||
path={path}
|
||||
markerEnd={markerEnd}
|
||||
style={{ stroke, strokeWidth: 1.5, strokeDasharray }}
|
||||
style={{ stroke, strokeWidth: 1.5 }}
|
||||
/>
|
||||
{edgeData && !isFallback && edgeData.condition !== "" && (
|
||||
{label !== "" && (
|
||||
<EdgeLabelRenderer>
|
||||
<div
|
||||
className="absolute px-1.5 py-0.5 rounded text-[10px] font-mono pointer-events-auto"
|
||||
style={{
|
||||
transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`,
|
||||
background: "var(--color-bg)",
|
||||
background: "var(--color-surface)",
|
||||
border: "1px solid var(--color-border)",
|
||||
color: "var(--color-text)",
|
||||
whiteSpace: "nowrap",
|
||||
zIndex: 10,
|
||||
}}
|
||||
title={edgeData.conditionDescription ?? undefined}
|
||||
title={edgeData?.conditionDescription ?? undefined}
|
||||
>
|
||||
{edgeData.condition}
|
||||
{label}
|
||||
</div>
|
||||
</EdgeLabelRenderer>
|
||||
)}
|
||||
|
||||
@@ -31,7 +31,7 @@ export function RoleNode(props: NodeProps) {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`px-3 py-2 rounded-md border-2 text-xs font-medium cursor-pointer ${isActive ? "wf-node-pulse" : ""}`}
|
||||
className={`px-3 py-2 rounded-md border-2 text-xs font-medium ${data.state !== "default" ? "cursor-pointer" : ""} ${isActive ? "wf-node-pulse" : ""}`}
|
||||
style={{
|
||||
width: 180,
|
||||
height: 60,
|
||||
@@ -45,7 +45,11 @@ export function RoleNode(props: NodeProps) {
|
||||
}}
|
||||
title={data.description}
|
||||
>
|
||||
<Handle type="target" position={Position.Top} style={handleStyle} isConnectable={false} />
|
||||
<Handle type="target" position={Position.Top} id="top-in" style={handleStyle} isConnectable={false} />
|
||||
<Handle type="target" position={Position.Left} id="left-in" style={handleStyle} isConnectable={false} />
|
||||
<Handle type="target" position={Position.Right} id="right-in" style={handleStyle} isConnectable={false} />
|
||||
<Handle type="source" position={Position.Left} id="left-out" style={handleStyle} isConnectable={false} />
|
||||
<Handle type="source" position={Position.Right} id="right-out" style={handleStyle} isConnectable={false} />
|
||||
<div className="flex items-center gap-1.5 font-mono">
|
||||
{icon !== null && (
|
||||
<span
|
||||
@@ -63,7 +67,7 @@ export function RoleNode(props: NodeProps) {
|
||||
{data.description}
|
||||
</div>
|
||||
)}
|
||||
<Handle type="source" position={Position.Bottom} style={handleStyle} isConnectable={false} />
|
||||
<Handle type="source" position={Position.Bottom} id="bottom-out" style={handleStyle} isConnectable={false} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -45,11 +45,12 @@ export function TerminalNode(props: NodeProps) {
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
id="bottom-out"
|
||||
style={handleStyle}
|
||||
isConnectable={false}
|
||||
/>
|
||||
) : (
|
||||
<Handle type="target" position={Position.Top} style={handleStyle} isConnectable={false} />
|
||||
<Handle type="target" position={Position.Top} id="top-in" style={handleStyle} isConnectable={false} />
|
||||
)}
|
||||
{isStart ? "▶" : "■"}
|
||||
</div>
|
||||
|
||||
@@ -21,6 +21,11 @@ export type ConditionEdgeData = {
|
||||
condition: string;
|
||||
conditionDescription: string | null;
|
||||
isFallback: boolean;
|
||||
isFeedback: boolean;
|
||||
isSelfLoop: boolean;
|
||||
feedbackSide: "right" | "left" | null;
|
||||
labelX: number | null;
|
||||
labelY: number | null;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import Dagre from "@dagrejs/dagre";
|
||||
import type { Edge, Node } from "@xyflow/react";
|
||||
import { useMemo } from "react";
|
||||
import type { WorkflowGraphEdge } from "../../api.ts";
|
||||
import type { ConditionEdgeData, NodeState, RoleNodeData, TerminalNodeData } from "./types.ts";
|
||||
import type { NodeState, RoleNodeData, TerminalNodeData } from "./types.ts";
|
||||
|
||||
const START_ID = "__start__";
|
||||
const END_ID = "__end__";
|
||||
@@ -10,6 +9,11 @@ const ROLE_NODE_WIDTH = 180;
|
||||
const ROLE_NODE_HEIGHT = 60;
|
||||
const TERMINAL_NODE_SIZE = 40;
|
||||
|
||||
// Vertical gap between nodes in the spine
|
||||
const LAYER_GAP = 80;
|
||||
// Horizontal offset for feedback (back) edges routed on the right side
|
||||
const FEEDBACK_OFFSET_X = 140;
|
||||
|
||||
type LayoutInput = {
|
||||
edges: readonly WorkflowGraphEdge[];
|
||||
roles: Record<string, { description: string }>;
|
||||
@@ -21,15 +25,6 @@ type LayoutResult = {
|
||||
edges: Edge[];
|
||||
};
|
||||
|
||||
function collectNodeIds(edges: readonly WorkflowGraphEdge[]): Set<string> {
|
||||
const ids = new Set<string>();
|
||||
for (const e of edges) {
|
||||
ids.add(e.from);
|
||||
ids.add(e.to);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
function nodeSize(id: string): { width: number; height: number } {
|
||||
if (id === START_ID || id === END_ID) {
|
||||
return { width: TERMINAL_NODE_SIZE, height: TERMINAL_NODE_SIZE };
|
||||
@@ -37,6 +32,80 @@ function nodeSize(id: string): { width: number; height: number } {
|
||||
return { width: ROLE_NODE_WIDTH, height: ROLE_NODE_HEIGHT };
|
||||
}
|
||||
|
||||
function edgeKey(e: WorkflowGraphEdge): string {
|
||||
return `${e.from}->${e.to}::${e.condition}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the linear spine from the graph using topological ordering.
|
||||
* Forward edges go from lower rank to higher rank; feedback edges go backwards.
|
||||
* Self-loops are neither forward nor feedback — they're handled separately.
|
||||
*/
|
||||
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: topological sort is inherently branchy
|
||||
function extractSpine(edges: readonly WorkflowGraphEdge[]): string[] {
|
||||
// Collect all node IDs
|
||||
const ids = new Set<string>();
|
||||
for (const e of edges) {
|
||||
ids.add(e.from);
|
||||
ids.add(e.to);
|
||||
}
|
||||
|
||||
// Build adjacency for forward edges only (non-self-loop, non-FALLBACK-back)
|
||||
// Strategy: BFS from __start__, picking the first non-FALLBACK forward edge,
|
||||
// or FALLBACK if no other option.
|
||||
const forwardAdj = new Map<string, string[]>();
|
||||
for (const e of edges) {
|
||||
if (e.from === e.to) continue;
|
||||
const existing = forwardAdj.get(e.from) ?? [];
|
||||
existing.push(e.to);
|
||||
forwardAdj.set(e.from, existing);
|
||||
}
|
||||
|
||||
// Walk the main path: prefer non-FALLBACK edges for the spine ordering
|
||||
const visited = new Set<string>();
|
||||
const spine: string[] = [];
|
||||
|
||||
// Build a set of "primary" next targets per node (non-FALLBACK first)
|
||||
const primaryNext = new Map<string, string>();
|
||||
const edgesByFrom = new Map<string, WorkflowGraphEdge[]>();
|
||||
for (const e of edges) {
|
||||
if (e.from === e.to) continue;
|
||||
const list = edgesByFrom.get(e.from) ?? [];
|
||||
list.push(e);
|
||||
edgesByFrom.set(e.from, list);
|
||||
}
|
||||
|
||||
// For each node, the "primary" next is the first non-FALLBACK target,
|
||||
// or the FALLBACK target if all edges are FALLBACK
|
||||
for (const [from, edgeList] of edgesByFrom) {
|
||||
const nonFallback = edgeList.find((e) => e.condition !== "FALLBACK");
|
||||
const fallback = edgeList.find((e) => e.condition === "FALLBACK");
|
||||
primaryNext.set(from, nonFallback?.to ?? fallback?.to ?? "");
|
||||
}
|
||||
|
||||
// Walk the spine from __start__
|
||||
let current: string | null = START_ID;
|
||||
while (current !== null && !visited.has(current)) {
|
||||
visited.add(current);
|
||||
spine.push(current);
|
||||
const next = primaryNext.get(current);
|
||||
if (next !== undefined && next !== "" && !visited.has(next)) {
|
||||
current = next;
|
||||
} else {
|
||||
current = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Add any remaining nodes not on the main path (shouldn't normally happen)
|
||||
for (const id of ids) {
|
||||
if (!visited.has(id)) {
|
||||
spine.push(id);
|
||||
}
|
||||
}
|
||||
|
||||
return spine;
|
||||
}
|
||||
|
||||
function buildRoleNode(
|
||||
id: string,
|
||||
pos: { x: number; y: number },
|
||||
@@ -68,60 +137,104 @@ function buildTerminalNode(
|
||||
};
|
||||
}
|
||||
|
||||
function edgeKey(e: WorkflowGraphEdge): string {
|
||||
return `${e.from}->${e.to}::${e.condition}`;
|
||||
}
|
||||
function computeLayout(input: LayoutInput): LayoutResult {
|
||||
const spine = extractSpine(input.edges);
|
||||
const rank = new Map<string, number>();
|
||||
for (let i = 0; i < spine.length; i++) {
|
||||
rank.set(spine[i], i);
|
||||
}
|
||||
|
||||
function buildEdge(e: WorkflowGraphEdge): Edge<ConditionEdgeData> {
|
||||
const isFallback = e.condition === "FALLBACK";
|
||||
return {
|
||||
id: edgeKey(e),
|
||||
source: e.from,
|
||||
target: e.to,
|
||||
type: "condition",
|
||||
data: {
|
||||
condition: e.condition,
|
||||
conditionDescription: e.conditionDescription,
|
||||
isFallback,
|
||||
},
|
||||
};
|
||||
// Position nodes along a vertical spine, centered horizontally
|
||||
const centerX = ROLE_NODE_WIDTH / 2; // left edge at x=0, center at width/2
|
||||
const nodePositions = new Map<string, { x: number; y: number; w: number; h: number }>();
|
||||
|
||||
let y = 0;
|
||||
for (const id of spine) {
|
||||
const size = nodeSize(id);
|
||||
// Center-align all nodes on the spine
|
||||
const x = centerX - size.width / 2;
|
||||
nodePositions.set(id, { x, y, w: size.width, h: size.height });
|
||||
y += size.height + LAYER_GAP;
|
||||
}
|
||||
|
||||
// Build nodes
|
||||
const nodes: Node[] = [];
|
||||
for (const id of spine) {
|
||||
const pos = nodePositions.get(id);
|
||||
if (pos === undefined) continue;
|
||||
const state = input.nodeStates.get(id) ?? "default";
|
||||
if (id === START_ID || id === END_ID) {
|
||||
nodes.push(buildTerminalNode(id, { x: pos.x, y: pos.y }, state));
|
||||
} else {
|
||||
nodes.push(buildRoleNode(id, { x: pos.x, y: pos.y }, input.roles, state));
|
||||
}
|
||||
}
|
||||
|
||||
// Build edges with label positions
|
||||
// For feedback edges (target rank < source rank), we'll compute label at midpoint
|
||||
// of the right-side arc. The actual SVG path is drawn by ConditionEdge component.
|
||||
// Track feedback edge count per target node for alternating sides
|
||||
const feedbackCountByTarget = new Map<string, number>();
|
||||
const edges: Edge[] = input.edges.map((e) => {
|
||||
const isFallback = e.condition === "FALLBACK";
|
||||
const isSelfLoop = e.from === e.to;
|
||||
const sourceRank = rank.get(e.from) ?? 0;
|
||||
const targetRank = rank.get(e.to) ?? 0;
|
||||
const isFeedback = !isSelfLoop && targetRank <= sourceRank;
|
||||
|
||||
const sourcePos = nodePositions.get(e.from);
|
||||
const targetPos = nodePositions.get(e.to);
|
||||
|
||||
let labelX: number | null = null;
|
||||
let labelY: number | null = null;
|
||||
let feedbackSide: "right" | "left" | null = null;
|
||||
|
||||
if (sourcePos !== undefined && targetPos !== undefined) {
|
||||
if (isFeedback) {
|
||||
// Alternate feedback edges left/right per target node
|
||||
const count = feedbackCountByTarget.get(e.to) ?? 0;
|
||||
feedbackCountByTarget.set(e.to, count + 1);
|
||||
feedbackSide = count % 2 === 0 ? "right" : "left";
|
||||
const offsetX =
|
||||
feedbackSide === "right"
|
||||
? centerX + ROLE_NODE_WIDTH / 2 + FEEDBACK_OFFSET_X
|
||||
: centerX - ROLE_NODE_WIDTH / 2 - FEEDBACK_OFFSET_X;
|
||||
const midY = (sourcePos.y + sourcePos.h / 2 + targetPos.y + targetPos.h / 2) / 2;
|
||||
labelX = offsetX;
|
||||
labelY = midY;
|
||||
} else if (!isSelfLoop) {
|
||||
// Forward edge: label between source bottom and target top
|
||||
const midX = centerX;
|
||||
const midY = (sourcePos.y + sourcePos.h + targetPos.y) / 2;
|
||||
labelX = midX;
|
||||
labelY = midY;
|
||||
}
|
||||
// Self-loop: let ReactFlow default handle it
|
||||
}
|
||||
|
||||
return {
|
||||
id: edgeKey(e),
|
||||
source: e.from,
|
||||
target: e.to,
|
||||
sourceHandle: isFeedback ? (feedbackSide === "left" ? "left-out" : "right-out") : "bottom-out",
|
||||
targetHandle: isFeedback ? (feedbackSide === "left" ? "left-in" : "right-in") : "top-in",
|
||||
type: "condition",
|
||||
data: {
|
||||
condition: e.condition,
|
||||
conditionDescription: e.conditionDescription,
|
||||
isFallback,
|
||||
isFeedback,
|
||||
isSelfLoop,
|
||||
feedbackSide,
|
||||
labelX,
|
||||
labelY,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return { nodes, edges };
|
||||
}
|
||||
|
||||
export function useLayout(input: LayoutInput): LayoutResult {
|
||||
return useMemo(() => {
|
||||
const ids = collectNodeIds(input.edges);
|
||||
|
||||
const g = new Dagre.graphlib.Graph({ multigraph: true }).setDefaultEdgeLabel(() => ({}));
|
||||
g.setGraph({ rankdir: "TB", nodesep: 60, ranksep: 80 });
|
||||
|
||||
for (const id of ids) {
|
||||
const size = nodeSize(id);
|
||||
g.setNode(id, { width: size.width, height: size.height });
|
||||
}
|
||||
for (const e of input.edges) {
|
||||
if (e.from === e.to) {
|
||||
continue;
|
||||
}
|
||||
g.setEdge(e.from, e.to, {}, edgeKey(e));
|
||||
}
|
||||
|
||||
Dagre.layout(g);
|
||||
|
||||
const nodes: Node[] = [];
|
||||
for (const id of ids) {
|
||||
const dagNode = g.node(id);
|
||||
const size = nodeSize(id);
|
||||
const pos = { x: dagNode.x - size.width / 2, y: dagNode.y - size.height / 2 };
|
||||
const state = input.nodeStates.get(id) ?? "default";
|
||||
if (id === START_ID || id === END_ID) {
|
||||
nodes.push(buildTerminalNode(id, pos, state));
|
||||
} else {
|
||||
nodes.push(buildRoleNode(id, pos, input.roles, state));
|
||||
}
|
||||
}
|
||||
|
||||
const edges: Edge[] = input.edges.map(buildEdge);
|
||||
|
||||
return { nodes, edges };
|
||||
}, [input.edges, input.roles, input.nodeStates]);
|
||||
return useMemo(() => computeLayout(input), [input]);
|
||||
}
|
||||
|
||||
@@ -66,6 +66,8 @@ export function WorkflowGraph({ graph, roles, nodeStates, onNodeClick }: Props)
|
||||
onNodeClick={onNodeClickHandler}
|
||||
fitView
|
||||
fitViewOptions={{ padding: 0.15 }}
|
||||
minZoom={0.3}
|
||||
maxZoom={2}
|
||||
nodesDraggable={false}
|
||||
nodesConnectable={false}
|
||||
elementsSelectable={false}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useFetch } from "../hooks.ts";
|
||||
import { type NodeState, WorkflowGraph } from "./workflow-graph/index.ts";
|
||||
|
||||
type Props = {
|
||||
agent: string;
|
||||
client: string;
|
||||
};
|
||||
|
||||
type DetailCacheEntry =
|
||||
@@ -45,38 +45,46 @@ function ExpandedWorkflowBody({
|
||||
const edgeCount = descriptor !== null ? descriptor.graph.edges.length : 0;
|
||||
const vc = versionCount(detail);
|
||||
|
||||
const hasGraph = descriptor !== null && edgeCount > 0;
|
||||
|
||||
return (
|
||||
<div className="pt-3 space-y-3 border-t" style={{ borderColor: "var(--color-border)" }}>
|
||||
<div>
|
||||
<p className="text-sm font-medium" style={{ color: "var(--color-text)" }}>
|
||||
{detail.name}
|
||||
<div className="pt-3 border-t flex gap-4" style={{ borderColor: "var(--color-border)" }}>
|
||||
<div className="space-y-3 shrink-0" style={{ minWidth: 200, maxWidth: 280 }}>
|
||||
<div>
|
||||
<p className="text-sm font-medium" style={{ color: "var(--color-text)" }}>
|
||||
{detail.name}
|
||||
</p>
|
||||
<p className="text-xs mt-1 mb-1" style={{ color: "var(--color-text-muted)" }}>
|
||||
Hash
|
||||
</p>
|
||||
<code className="text-xs font-mono block" style={{ color: "var(--color-accent)" }}>
|
||||
{detail.hash}
|
||||
</code>
|
||||
</div>
|
||||
<p className="text-xs" style={{ color: "var(--color-text-muted)" }}>
|
||||
{vc} version{vc !== 1 ? "s" : ""}
|
||||
</p>
|
||||
<p className="text-xs mt-1 mb-1" style={{ color: "var(--color-text-muted)" }}>
|
||||
Hash
|
||||
</p>
|
||||
<code className="text-xs font-mono block" style={{ color: "var(--color-accent)" }}>
|
||||
{detail.hash}
|
||||
</code>
|
||||
<div>
|
||||
<p className="text-xs mb-1 font-medium" style={{ color: "var(--color-text-muted)" }}>
|
||||
Description
|
||||
</p>
|
||||
<p className="text-sm whitespace-pre-wrap" style={{ color: "var(--color-text)" }}>
|
||||
{descriptor !== null && descriptor.description !== ""
|
||||
? descriptor.description
|
||||
: descriptor !== null
|
||||
? "—"
|
||||
: "No descriptor available for this workflow version."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs" style={{ color: "var(--color-text-muted)" }}>
|
||||
{vc} version{vc !== 1 ? "s" : ""}
|
||||
</p>
|
||||
<div>
|
||||
<p className="text-xs mb-1 font-medium" style={{ color: "var(--color-text-muted)" }}>
|
||||
Description
|
||||
</p>
|
||||
<p className="text-sm whitespace-pre-wrap" style={{ color: "var(--color-text)" }}>
|
||||
{descriptor !== null && descriptor.description !== ""
|
||||
? descriptor.description
|
||||
: descriptor !== null
|
||||
? "—"
|
||||
: "No descriptor available for this workflow version."}
|
||||
</p>
|
||||
</div>
|
||||
{descriptor !== null && edgeCount > 0 ? (
|
||||
{hasGraph ? (
|
||||
<div
|
||||
className="rounded-lg border overflow-hidden"
|
||||
style={{ borderColor: "var(--color-border)", background: "var(--color-bg)" }}
|
||||
className="rounded-lg border overflow-hidden flex-1"
|
||||
style={{
|
||||
borderColor: "var(--color-border)",
|
||||
background: "var(--color-bg)",
|
||||
minHeight: 500,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="px-3 py-2 text-xs flex justify-between items-center"
|
||||
@@ -87,7 +95,7 @@ function ExpandedWorkflowBody({
|
||||
{edgeCount} edge{edgeCount === 1 ? "" : "s"}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ height: 300, width: "100%" }}>
|
||||
<div style={{ height: 600, width: "100%" }}>
|
||||
<WorkflowGraph
|
||||
graph={descriptor.graph}
|
||||
roles={descriptor.roles}
|
||||
@@ -101,8 +109,8 @@ function ExpandedWorkflowBody({
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkflowList({ agent }: Props) {
|
||||
const { status, data, error } = useFetch(() => listWorkflows(agent), [agent]);
|
||||
export function WorkflowList({ client }: Props) {
|
||||
const { status, data, error } = useFetch(() => listWorkflows(client), [client]);
|
||||
const [expanded, setExpanded] = useState<Set<string>>(() => new Set());
|
||||
const [detailsByName, setDetailsByName] = useState<Map<string, DetailCacheEntry>>(
|
||||
() => new Map(),
|
||||
@@ -110,11 +118,11 @@ export function WorkflowList({ agent }: Props) {
|
||||
|
||||
const staticNodeStates = useMemo(() => new Map<string, NodeState>(), []);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: reset expansion when switching agents
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: reset expansion when switching clients
|
||||
useEffect(() => {
|
||||
setExpanded(new Set());
|
||||
setDetailsByName(new Map());
|
||||
}, [agent]);
|
||||
}, [client]);
|
||||
|
||||
const ensureDetailLoaded = useCallback(
|
||||
(name: string) => {
|
||||
@@ -128,7 +136,7 @@ export function WorkflowList({ agent }: Props) {
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const detail = await getWorkflowDetail(agent, name);
|
||||
const detail = await getWorkflowDetail(client, name);
|
||||
setDetailsByName((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(name, { status: "ok", detail });
|
||||
@@ -144,22 +152,21 @@ export function WorkflowList({ agent }: Props) {
|
||||
}
|
||||
})();
|
||||
},
|
||||
[agent],
|
||||
[client],
|
||||
);
|
||||
|
||||
function toggleExpanded(name: string) {
|
||||
let shouldLoad = false;
|
||||
const wasExpanded = expanded.has(name);
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(name)) {
|
||||
next.delete(name);
|
||||
return next;
|
||||
} else {
|
||||
next.add(name);
|
||||
}
|
||||
next.add(name);
|
||||
shouldLoad = true;
|
||||
return next;
|
||||
});
|
||||
if (shouldLoad) {
|
||||
if (!wasExpanded) {
|
||||
ensureDetailLoaded(name);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,35 +4,35 @@ type View = "threads" | "workflows";
|
||||
|
||||
type HashRoute = {
|
||||
view: View;
|
||||
agent: string | null;
|
||||
client: string | null;
|
||||
threadId: string | null;
|
||||
};
|
||||
|
||||
function parseHash(hash: string): HashRoute {
|
||||
const raw = hash.replace(/^#\/?/, "");
|
||||
// Format: #agent/threads/id or #agent/workflows or #threads or #workflows
|
||||
// Format: #client/threads/id or #client/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,
|
||||
client: null,
|
||||
threadId: parts[0] === "threads" && parts.length > 1 ? parts.slice(1).join("/") : null,
|
||||
};
|
||||
}
|
||||
|
||||
// First part is agent name
|
||||
const agent = parts[0] || null;
|
||||
// First part is client name
|
||||
const client = 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 };
|
||||
return { view, client, threadId };
|
||||
}
|
||||
|
||||
function buildHash(route: HashRoute): string {
|
||||
const prefix = route.agent ? `${route.agent}/` : "";
|
||||
const prefix = route.client ? `${route.client}/` : "";
|
||||
if (route.view === "workflows") {
|
||||
return `#${prefix}workflows`;
|
||||
}
|
||||
@@ -44,10 +44,10 @@ function buildHash(route: HashRoute): string {
|
||||
|
||||
export function useHashRoute(): {
|
||||
view: View;
|
||||
agent: string | null;
|
||||
client: string | null;
|
||||
threadId: string | null;
|
||||
setView: (v: View) => void;
|
||||
setAgent: (a: string | null) => void;
|
||||
setClient: (a: string | null) => void;
|
||||
setThreadId: (id: string | null) => void;
|
||||
} {
|
||||
const [route, setRoute] = useState<HashRoute>(() => parseHash(window.location.hash));
|
||||
@@ -67,26 +67,26 @@ export function useHashRoute(): {
|
||||
}, []);
|
||||
|
||||
const setView = useCallback(
|
||||
(v: View) => navigate({ view: v, agent: route.agent, threadId: null }),
|
||||
[navigate, route.agent],
|
||||
(v: View) => navigate({ view: v, client: route.client, threadId: null }),
|
||||
[navigate, route.client],
|
||||
);
|
||||
|
||||
const setAgent = useCallback(
|
||||
(a: string | null) => navigate({ view: route.view, agent: a, threadId: null }),
|
||||
const setClient = useCallback(
|
||||
(a: string | null) => navigate({ view: route.view, client: a, threadId: null }),
|
||||
[navigate, route.view],
|
||||
);
|
||||
|
||||
const setThreadId = useCallback(
|
||||
(id: string | null) => navigate({ view: "threads", agent: route.agent, threadId: id }),
|
||||
[navigate, route.agent],
|
||||
(id: string | null) => navigate({ view: "threads", client: route.client, threadId: id }),
|
||||
[navigate, route.client],
|
||||
);
|
||||
|
||||
return {
|
||||
view: route.view,
|
||||
agent: route.agent,
|
||||
client: route.client,
|
||||
threadId: route.threadId,
|
||||
setView,
|
||||
setAgent,
|
||||
setClient,
|
||||
setThreadId,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -57,17 +57,17 @@ function handleRecordEvent(ev: Event, ctx: RecordEventContext): void {
|
||||
ctx.cleanupEs();
|
||||
}
|
||||
|
||||
function sseUrl(agent: string, threadId: string): string {
|
||||
function sseUrl(client: 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 `${gatewayUrl}/api/${client}/threads/${encodeURIComponent(threadId)}/live${keyParam}`;
|
||||
}
|
||||
return `/api/threads/${encodeURIComponent(threadId)}/live`;
|
||||
}
|
||||
|
||||
export function useSSE(agent: string | null, threadId: string | null): UseSSEReturn {
|
||||
export function useSSE(client: string | null, threadId: string | null): UseSSEReturn {
|
||||
const [records, setRecords] = useState<ThreadRecord[]>([]);
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [completed, setCompleted] = useState(false);
|
||||
@@ -76,7 +76,7 @@ export function useSSE(agent: string | null, threadId: string | null): UseSSERet
|
||||
const reconnectAttemptsRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (threadId === null || agent === null) {
|
||||
if (threadId === null || client === null) {
|
||||
completedRef.current = false;
|
||||
reconnectAttemptsRef.current = 0;
|
||||
setRecords([]);
|
||||
@@ -86,7 +86,7 @@ export function useSSE(agent: string | null, threadId: string | null): UseSSERet
|
||||
}
|
||||
|
||||
const tid = threadId;
|
||||
const agentName = agent;
|
||||
const clientName = client;
|
||||
|
||||
completedRef.current = false;
|
||||
reconnectAttemptsRef.current = 0;
|
||||
@@ -125,7 +125,7 @@ export function useSSE(agent: string | null, threadId: string | null): UseSSERet
|
||||
}
|
||||
|
||||
cleanupEs();
|
||||
const url = sseUrl(agentName, tid);
|
||||
const url = sseUrl(clientName, tid);
|
||||
es = new EventSource(url);
|
||||
|
||||
es.onopen = () => {
|
||||
@@ -177,7 +177,7 @@ export function useSSE(agent: string | null, threadId: string | null): UseSSERet
|
||||
}
|
||||
cleanupEs();
|
||||
};
|
||||
}, [agent, threadId]);
|
||||
}, [client, threadId]);
|
||||
|
||||
return { records, connected, completed };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
# @uncaged/workflow-execute
|
||||
|
||||
## 0.4.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-protocol@0.4.5
|
||||
- @uncaged/workflow-cas@0.4.5
|
||||
- @uncaged/workflow-reactor@0.4.5
|
||||
- @uncaged/workflow-register@0.4.5
|
||||
- @uncaged/workflow-runtime@0.4.5
|
||||
- @uncaged/workflow-util@0.4.5
|
||||
|
||||
## 0.4.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-protocol@0.4.4
|
||||
- @uncaged/workflow-cas@0.4.4
|
||||
- @uncaged/workflow-reactor@0.4.4
|
||||
- @uncaged/workflow-register@0.4.4
|
||||
- @uncaged/workflow-runtime@0.4.4
|
||||
- @uncaged/workflow-util@0.4.4
|
||||
|
||||
## 0.4.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Include src/ in published packages so bun runtime can resolve the 'bun' exports condition.
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-cas@0.4.3
|
||||
- @uncaged/workflow-protocol@0.4.3
|
||||
- @uncaged/workflow-reactor@0.4.3
|
||||
- @uncaged/workflow-register@0.4.3
|
||||
- @uncaged/workflow-runtime@0.4.3
|
||||
- @uncaged/workflow-util@0.4.3
|
||||
|
||||
## 0.4.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fix workspace dependency resolution: use workspace:^ so published packages resolve to compatible versions instead of exact (non-existent) versions.
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-cas@0.4.2
|
||||
- @uncaged/workflow-protocol@0.4.2
|
||||
- @uncaged/workflow-reactor@0.4.2
|
||||
- @uncaged/workflow-register@0.4.2
|
||||
- @uncaged/workflow-runtime@0.4.2
|
||||
- @uncaged/workflow-util@0.4.2
|
||||
|
||||
## 0.4.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Fix package exports for published packages and adopt changesets for version management.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-cas@0.4.0
|
||||
- @uncaged/workflow-protocol@0.4.0
|
||||
- @uncaged/workflow-reactor@0.4.0
|
||||
- @uncaged/workflow-register@0.4.0
|
||||
- @uncaged/workflow-runtime@0.4.0
|
||||
- @uncaged/workflow-util@0.4.0
|
||||
@@ -14,7 +14,7 @@ import type {
|
||||
} from "@uncaged/workflow-runtime";
|
||||
|
||||
import { executeThread } from "../src/engine/engine.js";
|
||||
import type { ExecuteThreadIo, ExecuteThreadOptions } from "../src/engine/types.js";
|
||||
import type { ExecuteThreadOptions } from "../src/engine/types.js";
|
||||
|
||||
const TEST_REGISTRY_YAML = `config:
|
||||
maxDepth: 3
|
||||
|
||||
@@ -1,23 +1,29 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-execute",
|
||||
"version": "0.3.5",
|
||||
"version": "0.4.5",
|
||||
"files": [
|
||||
"src",
|
||||
"dist",
|
||||
"package.json"
|
||||
],
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"bun": "./src/index.ts",
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./src/index.ts"
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"test": "bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/workflow-protocol": "workspace:*",
|
||||
"@uncaged/workflow-runtime": "workspace:*",
|
||||
"@uncaged/workflow-util": "workspace:*",
|
||||
"@uncaged/workflow-cas": "workspace:*",
|
||||
"@uncaged/workflow-reactor": "workspace:*",
|
||||
"@uncaged/workflow-register": "workspace:*",
|
||||
"@uncaged/workflow-protocol": "workspace:^",
|
||||
"@uncaged/workflow-runtime": "workspace:^",
|
||||
"@uncaged/workflow-util": "workspace:^",
|
||||
"@uncaged/workflow-cas": "workspace:^",
|
||||
"@uncaged/workflow-reactor": "workspace:^",
|
||||
"@uncaged/workflow-register": "workspace:^",
|
||||
"yaml": "^2.7.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
@@ -25,5 +31,8 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"zod": "^4.0.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -299,7 +299,37 @@ async function driveWorkflowGenerator(params: {
|
||||
});
|
||||
}
|
||||
|
||||
const iterResult = await gen.next();
|
||||
const iterResult = await Promise.race([
|
||||
gen.next(),
|
||||
new Promise<never>((_, reject) => {
|
||||
if (executeOptions.signal.aborted) {
|
||||
reject(new DOMException("The operation was aborted", "AbortError"));
|
||||
return;
|
||||
}
|
||||
executeOptions.signal.addEventListener(
|
||||
"abort",
|
||||
() => reject(new DOMException("The operation was aborted", "AbortError")),
|
||||
{ once: true },
|
||||
);
|
||||
}),
|
||||
]).catch((e) => {
|
||||
if (e instanceof DOMException && e.name === "AbortError") {
|
||||
return { done: true as const, value: { returnCode: 130, summary: "thread aborted" } };
|
||||
}
|
||||
throw e;
|
||||
});
|
||||
|
||||
if (executeOptions.signal.aborted || (iterResult.done && iterResult.value.returnCode === 130)) {
|
||||
return await finalizeAbortedThread({
|
||||
cas,
|
||||
bundleDir,
|
||||
threadId,
|
||||
startHash,
|
||||
chain,
|
||||
logger,
|
||||
abortLogTag: "H4KQ7RW3",
|
||||
});
|
||||
}
|
||||
|
||||
if (iterResult.done) {
|
||||
logger("F3HN8QKP", `thread ${threadId} generator finished`);
|
||||
|
||||
@@ -42,4 +42,7 @@ export {
|
||||
llmErrorToCause,
|
||||
llmExtract,
|
||||
} from "./extract/index.js";
|
||||
export { type WorkflowAdapterOptions, workflowAdapter } from "./workflow-adapter.js";
|
||||
|
||||
/** @deprecated Use {@link workflowAdapter} instead. */
|
||||
export { type WorkflowAsAgentOptions, workflowAsAgent } from "./workflow-as-agent.js";
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import { join } from "node:path";
|
||||
import { createCasStore, putContentNodeWithRefs } from "@uncaged/workflow-cas";
|
||||
import type { WorkflowConfig } from "@uncaged/workflow-register";
|
||||
import {
|
||||
extractBundleExports,
|
||||
getRegisteredWorkflow,
|
||||
readWorkflowRegistry,
|
||||
} from "@uncaged/workflow-register";
|
||||
import type {
|
||||
AdapterFn,
|
||||
RoleResult,
|
||||
ThreadContext,
|
||||
WorkflowFn,
|
||||
WorkflowRuntime,
|
||||
} from "@uncaged/workflow-runtime";
|
||||
import {
|
||||
createLogger,
|
||||
generateUlid,
|
||||
getDefaultWorkflowStorageRoot,
|
||||
getGlobalCasDir,
|
||||
} from "@uncaged/workflow-util";
|
||||
import type * as z from "zod/v4";
|
||||
import type { ExecuteThreadIo } from "./engine/index.js";
|
||||
import { executeThread, getBundleDir, readThreadsIndex } from "./engine/index.js";
|
||||
|
||||
const DEFAULT_WORKFLOW_ADAPTER_MAX_DEPTH = 3;
|
||||
|
||||
function workflowAdapterMaxDepth(config: WorkflowConfig | null): number {
|
||||
return config === null ? DEFAULT_WORKFLOW_ADAPTER_MAX_DEPTH : config.maxDepth;
|
||||
}
|
||||
|
||||
export type WorkflowAdapterOptions = {
|
||||
/** When `null`, uses `getDefaultWorkflowStorageRoot()`. */
|
||||
storageRoot: string | null;
|
||||
};
|
||||
|
||||
function resolveStorageRoot(options: WorkflowAdapterOptions | null): string {
|
||||
if (options !== null && options.storageRoot !== null) {
|
||||
return options.storageRoot;
|
||||
}
|
||||
return getDefaultWorkflowStorageRoot();
|
||||
}
|
||||
|
||||
async function readParentHeadState(
|
||||
storageRoot: string,
|
||||
ctx: ThreadContext,
|
||||
): Promise<string | null> {
|
||||
const bundleDir = getBundleDir(storageRoot, ctx.bundleHash);
|
||||
const index = await readThreadsIndex(bundleDir);
|
||||
const entry = index[ctx.threadId] ?? null;
|
||||
return entry !== null ? entry.head : null;
|
||||
}
|
||||
|
||||
/** Resolve the workflow bundle and validate depth limits. */
|
||||
async function resolveWorkflowBundle(workflowName: string, storageRoot: string, nextDepth: number) {
|
||||
const registryResult = await readWorkflowRegistry(storageRoot);
|
||||
if (!registryResult.ok) {
|
||||
throw new Error(`failed to read workflow registry: ${registryResult.error.message}`);
|
||||
}
|
||||
|
||||
const maxDepth = workflowAdapterMaxDepth(registryResult.value.config);
|
||||
if (nextDepth > maxDepth) {
|
||||
throw new Error(`workflow adapter depth limit exceeded (max ${maxDepth})`);
|
||||
}
|
||||
|
||||
const entry = getRegisteredWorkflow(registryResult.value, workflowName);
|
||||
if (entry === null) {
|
||||
throw new Error(`workflow "${workflowName}" not found in registry`);
|
||||
}
|
||||
|
||||
const bundlePath = join(storageRoot, "bundles", `${entry.hash}.esm.js`);
|
||||
const bundleExportsResult = await extractBundleExports(bundlePath, { storageRoot });
|
||||
if (!bundleExportsResult.ok) {
|
||||
throw new Error(String(bundleExportsResult.error));
|
||||
}
|
||||
|
||||
return { entry, run: bundleExportsResult.value.run };
|
||||
}
|
||||
|
||||
/** Execute the child workflow thread and return a summary + root hash. */
|
||||
async function runChildThread(params: {
|
||||
workflowName: string;
|
||||
storageRoot: string;
|
||||
ctx: ThreadContext;
|
||||
run: WorkflowFn;
|
||||
bundleHash: string;
|
||||
nextDepth: number;
|
||||
}) {
|
||||
const { workflowName, storageRoot, ctx, run, bundleHash, nextDepth } = params;
|
||||
const childThreadId = generateUlid(Date.now());
|
||||
const infoJsonlPath = join(storageRoot, "logs", bundleHash, `${childThreadId}.info.jsonl`);
|
||||
|
||||
const io: ExecuteThreadIo = {
|
||||
threadId: childThreadId,
|
||||
hash: bundleHash,
|
||||
infoJsonlPath,
|
||||
cas: createCasStore(getGlobalCasDir(storageRoot)),
|
||||
};
|
||||
|
||||
const logger = createLogger({ sink: { kind: "file", path: infoJsonlPath } });
|
||||
const parentHeadState = await readParentHeadState(storageRoot, ctx);
|
||||
|
||||
const result = await executeThread(
|
||||
run,
|
||||
workflowName,
|
||||
{ prompt: ctx.start.content, steps: [] },
|
||||
{
|
||||
depth: nextDepth,
|
||||
parentStateHash: parentHeadState,
|
||||
signal: new AbortController().signal,
|
||||
awaitAfterEachYield: async () => {},
|
||||
forkSourceThreadId: ctx.threadId,
|
||||
prefilledDiskSteps: null,
|
||||
forkContinuation: null,
|
||||
replayTimestamps: null,
|
||||
storageRoot,
|
||||
},
|
||||
io,
|
||||
logger,
|
||||
);
|
||||
|
||||
return {
|
||||
summary: `Child workflow "${workflowName}" completed (returnCode=${result.returnCode}).\n\nSummary: ${result.summary}\n\nChild thread root hash: ${result.rootHash}`,
|
||||
rootHash: result.rootHash,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an {@link AdapterFn} that runs another registered workflow in a new child thread,
|
||||
* using the parent thread's initial prompt (`ctx.start.content`) as the child prompt.
|
||||
*
|
||||
* The child thread's root hash is returned as `childThread` in the result,
|
||||
* enabling parent→child tracking in the CAS Merkle tree.
|
||||
*/
|
||||
export function workflowAdapter(
|
||||
workflowName: string,
|
||||
options: WorkflowAdapterOptions | null = null,
|
||||
): AdapterFn {
|
||||
return <T>(_prompt: string, schema: z.ZodType<T>) => {
|
||||
return async (ctx: ThreadContext, runtime: WorkflowRuntime): Promise<RoleResult<T>> => {
|
||||
const storageRoot = resolveStorageRoot(options);
|
||||
const { entry, run } = await resolveWorkflowBundle(workflowName, storageRoot, ctx.depth + 1);
|
||||
|
||||
try {
|
||||
const { summary, rootHash } = await runChildThread({
|
||||
workflowName,
|
||||
storageRoot,
|
||||
ctx,
|
||||
run,
|
||||
bundleHash: entry.hash,
|
||||
nextDepth: ctx.depth + 1,
|
||||
});
|
||||
const contentHash = await putContentNodeWithRefs(runtime.cas, summary, []);
|
||||
const extracted = await runtime.extract(
|
||||
schema as z.ZodType<Record<string, unknown>>,
|
||||
contentHash,
|
||||
);
|
||||
return { meta: extracted.meta as T, childThread: rootHash };
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
throw new Error(`child workflow "${workflowName}" failed: ${message}`);
|
||||
}
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -1,127 +1,8 @@
|
||||
import { join } from "node:path";
|
||||
import { createCasStore } from "@uncaged/workflow-cas";
|
||||
import type { WorkflowConfig } from "@uncaged/workflow-register";
|
||||
import {
|
||||
extractBundleExports,
|
||||
getRegisteredWorkflow,
|
||||
readWorkflowRegistry,
|
||||
} from "@uncaged/workflow-register";
|
||||
import type { AgentContext, AgentFn, AgentFnResult } from "@uncaged/workflow-runtime";
|
||||
import {
|
||||
createLogger,
|
||||
generateUlid,
|
||||
getDefaultWorkflowStorageRoot,
|
||||
getGlobalCasDir,
|
||||
} from "@uncaged/workflow-util";
|
||||
import type { ExecuteThreadIo } from "./engine/index.js";
|
||||
import { executeThread, getBundleDir, readThreadsIndex } from "./engine/index.js";
|
||||
|
||||
const DEFAULT_WORKFLOW_AS_AGENT_MAX_DEPTH = 3;
|
||||
|
||||
function workflowAsAgentMaxDepth(config: WorkflowConfig | null): number {
|
||||
if (config === null) {
|
||||
return DEFAULT_WORKFLOW_AS_AGENT_MAX_DEPTH;
|
||||
}
|
||||
return config.maxDepth;
|
||||
}
|
||||
|
||||
export type WorkflowAsAgentOptions = {
|
||||
/** When `null`, uses `getDefaultWorkflowStorageRoot()`. */
|
||||
storageRoot: string | null;
|
||||
};
|
||||
|
||||
function resolveWorkflowAsAgentStorageRoot(options: WorkflowAsAgentOptions | null): string {
|
||||
if (options !== null && options.storageRoot !== null) {
|
||||
return options.storageRoot;
|
||||
}
|
||||
return getDefaultWorkflowStorageRoot();
|
||||
}
|
||||
|
||||
async function readParentHeadState(storageRoot: string, ctx: AgentContext): Promise<string | null> {
|
||||
const bundleDir = getBundleDir(storageRoot, ctx.bundleHash);
|
||||
const index = await readThreadsIndex(bundleDir);
|
||||
const entry = index[ctx.threadId] ?? null;
|
||||
return entry !== null ? entry.head : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an {@link AgentFn} that runs another registered workflow in a new thread,
|
||||
* using the parent thread's initial prompt (`ctx.start.content`) as the child prompt.
|
||||
* @deprecated Use `workflowAdapter` from `./workflow-adapter.js` instead.
|
||||
* This module is kept for backward compatibility and will be removed in a future release.
|
||||
*/
|
||||
export function workflowAsAgent(
|
||||
workflowName: string,
|
||||
options: WorkflowAsAgentOptions | null = null,
|
||||
): AgentFn {
|
||||
return async (ctx: AgentContext): Promise<AgentFnResult> => {
|
||||
const nextDepth = ctx.depth + 1;
|
||||
|
||||
const storageRoot = resolveWorkflowAsAgentStorageRoot(options);
|
||||
|
||||
const registryResult = await readWorkflowRegistry(storageRoot);
|
||||
if (!registryResult.ok) {
|
||||
return { output: `ERROR: failed to read workflow registry: ${registryResult.error.message}`, childThread: null };
|
||||
}
|
||||
|
||||
const maxDepth = workflowAsAgentMaxDepth(registryResult.value.config);
|
||||
if (nextDepth > maxDepth) {
|
||||
return { output: `ERROR: workflow-as-agent depth limit exceeded (max ${maxDepth})`, childThread: null };
|
||||
}
|
||||
|
||||
const entry = getRegisteredWorkflow(registryResult.value, workflowName);
|
||||
if (entry === null) {
|
||||
return { output: `ERROR: workflow "${workflowName}" not found in registry`, childThread: null };
|
||||
}
|
||||
|
||||
const bundlePath = join(storageRoot, "bundles", `${entry.hash}.esm.js`);
|
||||
const bundleExportsResult = await extractBundleExports(bundlePath, { storageRoot });
|
||||
if (!bundleExportsResult.ok) {
|
||||
return { output: `ERROR: ${bundleExportsResult.error}`, childThread: null };
|
||||
}
|
||||
|
||||
const input = {
|
||||
prompt: ctx.start.content,
|
||||
steps: [],
|
||||
};
|
||||
|
||||
const childThreadId = generateUlid(Date.now());
|
||||
const infoJsonlPath = join(storageRoot, "logs", entry.hash, `${childThreadId}.info.jsonl`);
|
||||
|
||||
const io: ExecuteThreadIo = {
|
||||
threadId: childThreadId,
|
||||
hash: entry.hash,
|
||||
infoJsonlPath,
|
||||
cas: createCasStore(getGlobalCasDir(storageRoot)),
|
||||
};
|
||||
|
||||
const logger = createLogger({ sink: { kind: "file", path: infoJsonlPath } });
|
||||
const signalNever = new AbortController();
|
||||
|
||||
const parentHeadState = await readParentHeadState(storageRoot, ctx);
|
||||
|
||||
try {
|
||||
const result = await executeThread(
|
||||
bundleExportsResult.value.run,
|
||||
workflowName,
|
||||
input,
|
||||
{
|
||||
depth: nextDepth,
|
||||
parentStateHash: parentHeadState,
|
||||
signal: signalNever.signal,
|
||||
awaitAfterEachYield: async () => {},
|
||||
forkSourceThreadId: ctx.threadId,
|
||||
prefilledDiskSteps: null,
|
||||
forkContinuation: null,
|
||||
replayTimestamps: null,
|
||||
storageRoot,
|
||||
},
|
||||
io,
|
||||
logger,
|
||||
);
|
||||
const summary = `Child workflow "${workflowName}" completed (returnCode=${result.returnCode}).\n\nSummary: ${result.summary}\n\nChild thread root hash: ${result.rootHash}`;
|
||||
return { output: summary, childThread: result.rootHash };
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
return { output: `ERROR: ${message}`, childThread: null };
|
||||
}
|
||||
};
|
||||
}
|
||||
export {
|
||||
type WorkflowAdapterOptions as WorkflowAsAgentOptions,
|
||||
workflowAdapter as workflowAsAgent,
|
||||
} from "./workflow-adapter.js";
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# @uncaged/workflow-gateway
|
||||
|
||||
## 0.4.5
|
||||
|
||||
## 0.4.4
|
||||
|
||||
## 0.4.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Include src/ in published packages so bun runtime can resolve the 'bun' exports condition.
|
||||
|
||||
## 0.4.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fix workspace dependency resolution: use workspace:^ so published packages resolve to compatible versions instead of exact (non-existent) versions.
|
||||
|
||||
## 0.4.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Fix package exports for published packages and adopt changesets for version management.
|
||||
@@ -1,8 +1,16 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-gateway",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"version": "0.4.5",
|
||||
"files": [
|
||||
"src",
|
||||
"dist",
|
||||
"package.json"
|
||||
],
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./ws-protocol": "./src/ws-protocol.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "wrangler dev",
|
||||
"deploy": "wrangler deploy"
|
||||
@@ -13,5 +21,8 @@
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^4.20260425.1",
|
||||
"wrangler": "^4.20.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
/** One Durable Object instance per client name; holds the reverse WebSocket from the client CLI. */
|
||||
import { DurableObject } from "cloudflare:workers";
|
||||
|
||||
import { parseWsRequestJson, parseWsResponseJson, type WsResponse } from "./ws-protocol.js";
|
||||
|
||||
type ClientSocketEnv = {
|
||||
GATEWAY_SECRET: string;
|
||||
};
|
||||
|
||||
export const CLIENT_SOCKET_INTERNAL_STATUS_PATH = "/internal/client-socket/status";
|
||||
export const CLIENT_SOCKET_INTERNAL_PROXY_PATH = "/internal/client-socket/proxy";
|
||||
|
||||
const PROXY_TIMEOUT_MS = 30_000;
|
||||
|
||||
type PendingEntry = {
|
||||
resolve: (r: Response) => void;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
};
|
||||
|
||||
function jsonResponse(status: number, body: unknown): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
function wsResponseToHttp(wr: WsResponse): Response {
|
||||
const headers = new Headers();
|
||||
for (const [k, v] of Object.entries(wr.headers)) {
|
||||
headers.set(k, v);
|
||||
}
|
||||
return new Response(wr.body, { status: wr.status, headers });
|
||||
}
|
||||
|
||||
export class ClientSocket extends DurableObject<ClientSocketEnv> {
|
||||
private readonly pending = new Map<string, PendingEntry>();
|
||||
|
||||
private requireAuth(request: Request): Response | null {
|
||||
const auth = request.headers.get("Authorization");
|
||||
if (auth !== `Bearer ${this.env.GATEWAY_SECRET}`) {
|
||||
return jsonResponse(401, { error: "unauthorized" });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private handleStatusGet(request: Request): Response {
|
||||
const denied = this.requireAuth(request);
|
||||
if (denied !== null) {
|
||||
return denied;
|
||||
}
|
||||
const sockets = this.ctx.getWebSockets();
|
||||
const connected = sockets.length > 0;
|
||||
return new Response(JSON.stringify({ connected, connectedCount: sockets.length }), {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
private async handleProxyPost(request: Request): Promise<Response> {
|
||||
const denied = this.requireAuth(request);
|
||||
if (denied !== null) {
|
||||
return denied;
|
||||
}
|
||||
const raw = await request.text();
|
||||
const wsRequest = parseWsRequestJson(raw);
|
||||
if (wsRequest === null) {
|
||||
return jsonResponse(400, { error: "invalid proxy body" });
|
||||
}
|
||||
|
||||
const sockets = this.ctx.getWebSockets();
|
||||
const ws = sockets[0];
|
||||
if (ws === undefined) {
|
||||
return jsonResponse(503, { error: "no active websocket" });
|
||||
}
|
||||
|
||||
return await new Promise<Response>((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.pending.delete(wsRequest.id);
|
||||
resolve(jsonResponse(504, { error: "gateway timeout" }));
|
||||
}, PROXY_TIMEOUT_MS);
|
||||
|
||||
this.pending.set(wsRequest.id, {
|
||||
resolve: (r: Response) => {
|
||||
clearTimeout(timer);
|
||||
this.pending.delete(wsRequest.id);
|
||||
resolve(r);
|
||||
},
|
||||
timer,
|
||||
});
|
||||
|
||||
try {
|
||||
ws.send(JSON.stringify(wsRequest));
|
||||
} catch {
|
||||
clearTimeout(timer);
|
||||
this.pending.delete(wsRequest.id);
|
||||
resolve(jsonResponse(502, { error: "websocket send failed" }));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async fetch(request: Request): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
|
||||
if (url.pathname === CLIENT_SOCKET_INTERNAL_STATUS_PATH && request.method === "GET") {
|
||||
return this.handleStatusGet(request);
|
||||
}
|
||||
|
||||
if (url.pathname === CLIENT_SOCKET_INTERNAL_PROXY_PATH && request.method === "POST") {
|
||||
return this.handleProxyPost(request);
|
||||
}
|
||||
|
||||
if (request.headers.get("Upgrade") !== "websocket") {
|
||||
return new Response("expected WebSocket upgrade", { status: 426 });
|
||||
}
|
||||
|
||||
for (const ws of this.ctx.getWebSockets()) {
|
||||
ws.close(1000, "replaced by new connection");
|
||||
}
|
||||
|
||||
const pair = new WebSocketPair();
|
||||
const client = pair[0];
|
||||
const server = pair[1];
|
||||
this.ctx.acceptWebSocket(server);
|
||||
return new Response(null, { status: 101, webSocket: client });
|
||||
}
|
||||
|
||||
async webSocketMessage(_ws: WebSocket, message: string | ArrayBuffer): Promise<void> {
|
||||
const text = typeof message === "string" ? message : new TextDecoder().decode(message);
|
||||
const wr = parseWsResponseJson(text);
|
||||
if (wr === null) {
|
||||
return;
|
||||
}
|
||||
const entry = this.pending.get(wr.id);
|
||||
if (entry === undefined) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(entry.timer);
|
||||
this.pending.delete(wr.id);
|
||||
entry.resolve(wsResponseToHttp(wr));
|
||||
}
|
||||
|
||||
async webSocketClose(
|
||||
_ws: WebSocket,
|
||||
_code: number,
|
||||
_reason: string,
|
||||
_wasClean: boolean,
|
||||
): Promise<void> {
|
||||
this.rejectAllPending("client websocket closed");
|
||||
}
|
||||
|
||||
async webSocketError(_ws: WebSocket, _error: unknown): Promise<void> {
|
||||
this.rejectAllPending("client websocket error");
|
||||
}
|
||||
|
||||
private rejectAllPending(message: string): void {
|
||||
const entries = [...this.pending.values()];
|
||||
this.pending.clear();
|
||||
for (const entry of entries) {
|
||||
clearTimeout(entry.timer);
|
||||
entry.resolve(jsonResponse(502, { error: message }));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,28 @@
|
||||
import { Hono } from "hono";
|
||||
import { cors } from "hono/cors";
|
||||
|
||||
import {
|
||||
CLIENT_SOCKET_INTERNAL_PROXY_PATH,
|
||||
CLIENT_SOCKET_INTERNAL_STATUS_PATH,
|
||||
ClientSocket,
|
||||
} from "./client-socket.js";
|
||||
import type { WsRequest } from "./ws-protocol.js";
|
||||
|
||||
export { ClientSocket };
|
||||
|
||||
type Env = {
|
||||
Bindings: {
|
||||
ENDPOINTS: KVNamespace;
|
||||
GATEWAY_SECRET: string;
|
||||
DASHBOARD_API_KEY: string;
|
||||
CLIENT_SOCKET: DurableObjectNamespace<ClientSocket>;
|
||||
};
|
||||
};
|
||||
|
||||
type EndpointRecord = {
|
||||
name: string;
|
||||
url: string;
|
||||
agentToken: string;
|
||||
clientToken: string;
|
||||
registeredAt: number;
|
||||
lastHeartbeat: number;
|
||||
};
|
||||
@@ -33,9 +43,165 @@ function checkDashboardAuth(c: {
|
||||
return key === c.env.DASHBOARD_API_KEY;
|
||||
}
|
||||
|
||||
function isLocalClientUrl(url: string): boolean {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
return u.hostname === "localhost" || u.hostname === "127.0.0.1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function buildForwardHeaders(raw: Headers, clientToken: string): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const [key, value] of raw) {
|
||||
const lower = key.toLowerCase();
|
||||
if (lower === "host" || lower === "authorization") {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
lower === "connection" ||
|
||||
lower === "keep-alive" ||
|
||||
lower === "proxy-connection" ||
|
||||
lower === "transfer-encoding" ||
|
||||
lower === "upgrade"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
out[key] = value;
|
||||
}
|
||||
if (clientToken !== "") {
|
||||
out["X-Client-Token"] = clientToken;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function buildDashboardProxyHeaders(raw: Headers, token: string): Headers {
|
||||
const headers = new Headers(raw);
|
||||
headers.delete("host");
|
||||
headers.delete("Authorization");
|
||||
if (token !== "") {
|
||||
headers.set("X-Client-Token", token);
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
async function readBodyForWsProxy(method: string, req: Request): Promise<string | null> {
|
||||
if (method === "GET" || method === "HEAD") {
|
||||
return null;
|
||||
}
|
||||
const buf = await req.arrayBuffer();
|
||||
return buf.byteLength === 0 ? null : new TextDecoder().decode(buf);
|
||||
}
|
||||
|
||||
async function fetchThroughClientSocket(
|
||||
bindings: Env["Bindings"],
|
||||
client: string,
|
||||
gateSecret: string,
|
||||
wsRequest: WsRequest,
|
||||
): Promise<Response> {
|
||||
const stub = bindings.CLIENT_SOCKET.get(bindings.CLIENT_SOCKET.idFromName(client));
|
||||
return stub.fetch(
|
||||
new Request(`https://do.internal${CLIENT_SOCKET_INTERNAL_PROXY_PATH}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${gateSecret}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(wsRequest),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchClientWithRecordHeaders(
|
||||
targetUrl: string,
|
||||
method: string,
|
||||
forwardRecord: Record<string, string>,
|
||||
bodyStr: string | null,
|
||||
): Promise<Response> {
|
||||
const headers = new Headers();
|
||||
for (const [k, v] of Object.entries(forwardRecord)) {
|
||||
headers.set(k, v);
|
||||
}
|
||||
return fetch(targetUrl, {
|
||||
method,
|
||||
headers,
|
||||
body: method !== "GET" && method !== "HEAD" ? (bodyStr ?? undefined) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchClientWithDashboardHeaders(
|
||||
targetUrl: string,
|
||||
method: string,
|
||||
headers: Headers,
|
||||
rawBody: BodyInit | null | undefined,
|
||||
): Promise<Response> {
|
||||
return fetch(targetUrl, {
|
||||
method,
|
||||
headers,
|
||||
body: method !== "GET" && method !== "HEAD" ? rawBody : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchClientSocketStatus(
|
||||
env: Env["Bindings"],
|
||||
name: string,
|
||||
): Promise<{ ok: true; connected: boolean } | { ok: false }> {
|
||||
try {
|
||||
const id = env.CLIENT_SOCKET.idFromName(name);
|
||||
const stub = env.CLIENT_SOCKET.get(id);
|
||||
const resp = await stub.fetch(
|
||||
new Request(`https://do${CLIENT_SOCKET_INTERNAL_STATUS_PATH}`, {
|
||||
method: "GET",
|
||||
headers: { Authorization: `Bearer ${env.GATEWAY_SECRET}` },
|
||||
}),
|
||||
);
|
||||
if (!resp.ok) {
|
||||
return { ok: false };
|
||||
}
|
||||
const body = (await resp.json()) as { connected: boolean };
|
||||
return { ok: true, connected: body.connected };
|
||||
} catch {
|
||||
return { ok: false };
|
||||
}
|
||||
}
|
||||
|
||||
function endpointStatusFromKvAndDo(record: EndpointRecord, doConnected: boolean | null): string {
|
||||
if (doConnected === true) {
|
||||
return "online";
|
||||
}
|
||||
if (doConnected === false) {
|
||||
if (isLocalClientUrl(record.url)) {
|
||||
return "offline";
|
||||
}
|
||||
const age = Date.now() - record.lastHeartbeat;
|
||||
return age < TTL_SECONDS * 1000 ? "online" : "offline";
|
||||
}
|
||||
const age = Date.now() - record.lastHeartbeat;
|
||||
return age < TTL_SECONDS * 1000 ? "online" : "offline";
|
||||
}
|
||||
|
||||
// ── Health ──────────────────────────────────────────────────────────
|
||||
app.get("/healthz", (c) => c.json({ ok: true }));
|
||||
|
||||
// ── Client reverse WebSocket (GATEWAY_SECRET query param) ────────────
|
||||
app.get("/ws/connect", async (c) => {
|
||||
const secret = c.req.query("secret");
|
||||
const name = c.req.query("name");
|
||||
if (name === undefined || name === "") {
|
||||
return c.json({ error: "name required" }, 400);
|
||||
}
|
||||
if (secret !== c.env.GATEWAY_SECRET) {
|
||||
return c.json({ error: "unauthorized" }, 401);
|
||||
}
|
||||
if (c.req.header("Upgrade") !== "websocket") {
|
||||
return c.text("expected WebSocket upgrade", 426);
|
||||
}
|
||||
const id = c.env.CLIENT_SOCKET.idFromName(name);
|
||||
const stub = c.env.CLIENT_SOCKET.get(id);
|
||||
return stub.fetch(c.req.raw);
|
||||
});
|
||||
|
||||
// ── Gateway management (GATEWAY_SECRET auth) ────────────────────────
|
||||
const gateway = new Hono<Env>();
|
||||
|
||||
@@ -44,9 +210,9 @@ gateway.post("/register", async (c) => {
|
||||
name?: string;
|
||||
url?: string;
|
||||
secret?: string;
|
||||
agentToken?: string;
|
||||
clientToken?: string;
|
||||
}>();
|
||||
const { name, url, secret, agentToken } = body;
|
||||
const { name, url, secret, clientToken } = body;
|
||||
|
||||
if (!name || !url) {
|
||||
return c.json({ error: "name and url required" }, 400);
|
||||
@@ -61,7 +227,7 @@ gateway.post("/register", async (c) => {
|
||||
const record: EndpointRecord = {
|
||||
name,
|
||||
url: url.replace(/\/+$/, ""), // strip trailing slash
|
||||
agentToken: agentToken ?? existing?.agentToken ?? "",
|
||||
clientToken: clientToken ?? existing?.clientToken ?? "",
|
||||
registeredAt: existing?.registeredAt ?? now,
|
||||
lastHeartbeat: now,
|
||||
};
|
||||
@@ -95,11 +261,12 @@ gateway.get("/endpoints", async (c) => {
|
||||
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;
|
||||
const doStatus = await fetchClientSocketStatus(c.env, record.name);
|
||||
const doConnected = doStatus.ok ? doStatus.connected : null;
|
||||
endpoints.push({
|
||||
name: record.name,
|
||||
url: record.url,
|
||||
status: age < TTL_SECONDS * 1000 ? "online" : "offline",
|
||||
status: endpointStatusFromKvAndDo(record, doConnected),
|
||||
lastHeartbeat: record.lastHeartbeat,
|
||||
});
|
||||
}
|
||||
@@ -110,43 +277,63 @@ gateway.get("/endpoints", async (c) => {
|
||||
|
||||
app.route("/api/gateway", gateway);
|
||||
|
||||
// ── API proxy: /api/agents/:agent/* → agent's tunnel URL (dashboard auth) ──
|
||||
app.all("/api/agents/:agent/*", async (c) => {
|
||||
// ── API proxy: /api/clients/:client/* → WebSocket (preferred) or client tunnel URL (dashboard auth) ──
|
||||
app.all("/api/clients/:client/*", 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");
|
||||
const client = c.req.param("client");
|
||||
const record = await c.env.ENDPOINTS.get<EndpointRecord>(client, "json");
|
||||
|
||||
if (!record) {
|
||||
return c.json({ error: "agent not found" }, 404);
|
||||
return c.json({ error: "client 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 pathAfterClient = url.pathname.replace(`/api/clients/${client}`, "");
|
||||
const targetUrl = `${record.url}/api${pathAfterClient}${url.search}`;
|
||||
const proxyPath = `/api${pathAfterClient}${url.search}`;
|
||||
const method = c.req.method;
|
||||
const token = record.clientToken ?? "";
|
||||
const forwardRecord = buildForwardHeaders(c.req.raw.headers, token);
|
||||
|
||||
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);
|
||||
const doStatus = await fetchClientSocketStatus(c.env, client);
|
||||
if (doStatus.ok && doStatus.connected) {
|
||||
const bodyStr = await readBodyForWsProxy(method, c.req.raw);
|
||||
const wsRequest: WsRequest = {
|
||||
id: crypto.randomUUID(),
|
||||
method,
|
||||
path: proxyPath,
|
||||
headers: forwardRecord,
|
||||
body: bodyStr,
|
||||
};
|
||||
const proxyResp = await fetchThroughClientSocket(c.env, client, c.env.GATEWAY_SECRET, wsRequest);
|
||||
if (proxyResp.status !== 503) {
|
||||
return new Response(proxyResp.body, {
|
||||
status: proxyResp.status,
|
||||
headers: proxyResp.headers,
|
||||
});
|
||||
}
|
||||
try {
|
||||
const resp = await fetchClientWithRecordHeaders(targetUrl, method, forwardRecord, bodyStr);
|
||||
return new Response(resp.body, {
|
||||
status: resp.status,
|
||||
headers: resp.headers,
|
||||
});
|
||||
} catch (err) {
|
||||
return c.json({ error: "client unreachable", detail: String(err) }, 502);
|
||||
}
|
||||
}
|
||||
|
||||
const headers = buildDashboardProxyHeaders(c.req.raw.headers, token);
|
||||
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
|
||||
const resp = await fetchClientWithDashboardHeaders(targetUrl, method, headers, c.req.raw.body);
|
||||
return new Response(resp.body, {
|
||||
status: resp.status,
|
||||
headers: resp.headers,
|
||||
});
|
||||
} catch (err) {
|
||||
return c.json({ error: "agent unreachable", detail: String(err) }, 502);
|
||||
return c.json({ error: "client unreachable", detail: String(err) }, 502);
|
||||
}
|
||||
});
|
||||
|
||||
// biome-ignore lint/style/noDefaultExport: Cloudflare Workers entry expects default export
|
||||
export default app;
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
/** Wire format for HTTP-over-WebSocket proxy between gateway Durable Object and local serve. */
|
||||
|
||||
export type WsRequest = {
|
||||
id: string;
|
||||
method: string;
|
||||
path: string;
|
||||
headers: Record<string, string>;
|
||||
body: string | null;
|
||||
};
|
||||
|
||||
export type WsResponse = {
|
||||
id: string;
|
||||
status: number;
|
||||
headers: Record<string, string>;
|
||||
body: string;
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isNonEmptyString(value: unknown): value is string {
|
||||
return typeof value === "string" && value.length > 0;
|
||||
}
|
||||
|
||||
/** Parse and validate a JSON payload as {@link WsRequest}. */
|
||||
export function parseWsRequestJson(raw: string): WsRequest | null {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw) as unknown;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!isRecord(parsed)) {
|
||||
return null;
|
||||
}
|
||||
const id = parsed.id;
|
||||
const method = parsed.method;
|
||||
const path = parsed.path;
|
||||
const headers = parsed.headers;
|
||||
const body = parsed.body;
|
||||
if (!isNonEmptyString(id) || !isNonEmptyString(method) || !isNonEmptyString(path)) {
|
||||
return null;
|
||||
}
|
||||
if (!isRecord(headers)) {
|
||||
return null;
|
||||
}
|
||||
const headerRecord: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(headers)) {
|
||||
if (typeof v !== "string") {
|
||||
return null;
|
||||
}
|
||||
headerRecord[k] = v;
|
||||
}
|
||||
if (body !== null && typeof body !== "string") {
|
||||
return null;
|
||||
}
|
||||
return { id, method, path, headers: headerRecord, body: body === null ? null : body };
|
||||
}
|
||||
|
||||
/** Parse and validate a JSON payload as {@link WsResponse}. */
|
||||
export function parseWsResponseJson(raw: string): WsResponse | null {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw) as unknown;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!isRecord(parsed)) {
|
||||
return null;
|
||||
}
|
||||
const id = parsed.id;
|
||||
const status = parsed.status;
|
||||
const headers = parsed.headers;
|
||||
const respBody = parsed.body;
|
||||
if (!isNonEmptyString(id) || typeof status !== "number" || !Number.isFinite(status)) {
|
||||
return null;
|
||||
}
|
||||
if (!isRecord(headers)) {
|
||||
return null;
|
||||
}
|
||||
const headerRecord: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(headers)) {
|
||||
if (typeof v !== "string") {
|
||||
return null;
|
||||
}
|
||||
headerRecord[k] = v;
|
||||
}
|
||||
if (typeof respBody !== "string") {
|
||||
return null;
|
||||
}
|
||||
return { id, status: Math.trunc(status), headers: headerRecord, body: respBody };
|
||||
}
|
||||
@@ -6,4 +6,15 @@ compatibility_date = "2025-04-01"
|
||||
binding = "ENDPOINTS"
|
||||
id = "88b118d1cfab4c049f9c1684848811a3"
|
||||
|
||||
[durable_objects]
|
||||
bindings = [{ name = "CLIENT_SOCKET", class_name = "ClientSocket" }]
|
||||
|
||||
[[migrations]]
|
||||
tag = "add-agent-socket"
|
||||
new_sqlite_classes = ["AgentSocket"]
|
||||
|
||||
[[migrations]]
|
||||
tag = "rename-agent-to-client"
|
||||
renamed_classes = [{ from = "AgentSocket", to = "ClientSocket" }]
|
||||
|
||||
# GATEWAY_SECRET is set via `wrangler secret put`
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# @uncaged/workflow-protocol
|
||||
|
||||
## 0.4.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Add publishConfig to all packages for Gitea registry compatibility with changeset publish.
|
||||
|
||||
## 0.4.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Test changeset publish with Gitea registry.
|
||||
|
||||
## 0.4.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Include src/ in published packages so bun runtime can resolve the 'bun' exports condition.
|
||||
|
||||
## 0.4.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fix workspace dependency resolution: use workspace:^ so published packages resolve to compatible versions instead of exact (non-existent) versions.
|
||||
|
||||
## 0.4.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Fix package exports for published packages and adopt changesets for version management.
|
||||
@@ -1,15 +1,22 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-protocol",
|
||||
"version": "0.3.5",
|
||||
"version": "0.4.5",
|
||||
"files": [
|
||||
"src",
|
||||
"dist",
|
||||
"package.json"
|
||||
],
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"bun": "./src/index.ts",
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./src/index.ts"
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./moderator-table.js": {
|
||||
"bun": "./src/moderator-table.ts",
|
||||
"types": "./dist/moderator-table.d.ts",
|
||||
"import": "./src/moderator-table.ts"
|
||||
"import": "./dist/moderator-table.js"
|
||||
}
|
||||
},
|
||||
"peerDependencies": {
|
||||
@@ -18,5 +25,8 @@
|
||||
"devDependencies": {
|
||||
"zod": "^4.0.0",
|
||||
"typescript": "^5.8.3"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,10 +12,7 @@ export type {
|
||||
AdapterBinding,
|
||||
AdapterFn,
|
||||
AdvanceOutcome,
|
||||
AgentBinding,
|
||||
AgentContext,
|
||||
AgentFn,
|
||||
AgentFnResult,
|
||||
CasStore,
|
||||
ExtractFn,
|
||||
ExtractResult,
|
||||
@@ -30,9 +27,9 @@ export type {
|
||||
Result,
|
||||
RoleDefinition,
|
||||
RoleFn,
|
||||
RoleResult,
|
||||
RoleMeta,
|
||||
RoleOutput,
|
||||
RoleResult,
|
||||
RoleStep,
|
||||
StartStep,
|
||||
ThreadContext,
|
||||
|
||||
@@ -143,18 +143,6 @@ export type ExtractFn = <T extends Record<string, unknown>>(
|
||||
contentHash: string,
|
||||
) => Promise<ExtractResult<T>>;
|
||||
|
||||
/** @deprecated Use {@link AdapterFn} instead. Will be removed in a future release. */
|
||||
export type AgentFnResult = string | { output: string; childThread: string | null };
|
||||
|
||||
/** @deprecated Use {@link AdapterFn} instead. Will be removed in a future release. */
|
||||
export type AgentFn = (ctx: AgentContext) => Promise<AgentFnResult>;
|
||||
|
||||
/** @deprecated Use {@link AdapterBinding} instead. Will be removed in a future release. */
|
||||
export type AgentBinding = {
|
||||
agent: AgentFn;
|
||||
overrides: Partial<Record<string, AgentFn>> | null;
|
||||
};
|
||||
|
||||
// ── Adapter (replaces Agent) ────────────────────────────────────────
|
||||
|
||||
export type RoleResult<T> = { meta: T; childThread: string | null };
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user