Compare commits
56 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b370d96504 | |||
| 8cae114c7e | |||
| 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 | |||
| 497f03c747 | |||
| cfe4543d39 | |||
| 399b967c59 | |||
| 061926b86a | |||
| acb0ebed97 | |||
| d5d7be6100 | |||
| 1566a43395 | |||
| afbde4573a | |||
| 63e447fc3d | |||
| 34fcbf29cb | |||
| 256799fcfd | |||
| 21cf3db111 | |||
| ed38543db4 | |||
| 78771fbebc | |||
| c15f58bdeb | |||
| 6d4bf108bb |
@@ -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,17 @@
|
||||
{
|
||||
"$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,2 +0,0 @@
|
||||
[test]
|
||||
pathIgnorePatterns = ["dist/**"]
|
||||
+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",
|
||||
]) {
|
||||
|
||||
@@ -1,22 +1,30 @@
|
||||
{
|
||||
"name": "@uncaged/cli-workflow",
|
||||
"version": "0.3.11",
|
||||
"version": "0.4.5",
|
||||
"files": [
|
||||
"src",
|
||||
"dist",
|
||||
"package.json"
|
||||
],
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"uncaged-workflow": "src/cli.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@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:*",
|
||||
"@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;
|
||||
};
|
||||
+9
-12
@@ -5,7 +5,7 @@ export type GatewayWsClientParams = {
|
||||
gatewayUrl: string;
|
||||
name: string;
|
||||
secret: string;
|
||||
localPort: number;
|
||||
appFetch: (request: Request) => Response | Promise<Response>;
|
||||
log: LogFn;
|
||||
};
|
||||
|
||||
@@ -44,20 +44,17 @@ async function handleGatewayMessage(
|
||||
params.log("ZM8K2PQ1", "gateway WebSocket dropped non-request message");
|
||||
return;
|
||||
}
|
||||
const localUrl = `http://127.0.0.1:${String(params.localPort)}${req.path}`;
|
||||
const initHeaders = new Headers();
|
||||
for (const [k, v] of Object.entries(req.headers)) {
|
||||
initHeaders.set(k, v);
|
||||
}
|
||||
const localUrl = `http://localhost${req.path}`;
|
||||
const headers = new Headers(req.headers);
|
||||
let resp: Response;
|
||||
try {
|
||||
resp = await fetch(localUrl, {
|
||||
resp = await params.appFetch(new Request(localUrl, {
|
||||
method: req.method,
|
||||
headers: initHeaders,
|
||||
headers,
|
||||
body: req.body === null ? undefined : req.body,
|
||||
});
|
||||
}));
|
||||
} catch (e) {
|
||||
params.log("R4N7BQ3C", `local proxy fetch failed: ${String(e)}`);
|
||||
params.log("R4N7BQ3C", `app.fetch failed: ${String(e)}`);
|
||||
const errBody: WsResponse = {
|
||||
id: req.id,
|
||||
status: 502,
|
||||
@@ -100,7 +97,7 @@ export function startGatewayWsClient(params: GatewayWsClientParams): () => void
|
||||
clearReconnectTimer();
|
||||
const delayMs = Math.min(INITIAL_BACKOFF_MS * 2 ** attempt, MAX_BACKOFF_MS);
|
||||
attempt++;
|
||||
params.log("6CJX2RLP", `gateway WebSocket reconnect in ${delayMs}ms (attempt ${attempt})`);
|
||||
params.log("6CJX2R8P", `gateway WebSocket reconnect in ${delayMs}ms (attempt ${attempt})`);
|
||||
reconnectTimer = setTimeout(connect, delayMs);
|
||||
};
|
||||
|
||||
@@ -143,7 +140,7 @@ export function startGatewayWsClient(params: GatewayWsClientParams): () => void
|
||||
ws.addEventListener("message", (ev) => {
|
||||
const data = ev.data;
|
||||
if (typeof data !== "string") {
|
||||
params.log("T9W2KL5H", "gateway WebSocket non-text frame ignored");
|
||||
params.log("T9W2K35H", "gateway WebSocket non-text frame ignored");
|
||||
return;
|
||||
}
|
||||
void handleGatewayMessage(ws, data, params).catch((e: unknown) => {
|
||||
@@ -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,180 +0,0 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { hostname as osHostname } from "node:os";
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
import { createLogger } from "@uncaged/workflow-util";
|
||||
import { serve } from "bun";
|
||||
|
||||
import { printCliLine } from "../../cli-output.js";
|
||||
import { createApp } from "./app.js";
|
||||
import { registerWithGateway, startHeartbeat, unregisterFromGateway } from "./tunnel.js";
|
||||
import type { ServeOptions } 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;
|
||||
|
||||
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 tunnelUrl: string | null = null;
|
||||
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;
|
||||
},
|
||||
"--tunnel-url": (v) => {
|
||||
tunnelUrl = 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, tunnelUrl, 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;
|
||||
}
|
||||
|
||||
let resolvedTunnelUrl: string;
|
||||
let stopWsClient: (() => void) | null = null;
|
||||
|
||||
if (options.tunnelUrl !== null) {
|
||||
resolvedTunnelUrl = options.tunnelUrl;
|
||||
printCliLine(`using tunnel URL: ${resolvedTunnelUrl}`);
|
||||
} else {
|
||||
if (options.gatewaySecret === "") {
|
||||
printCliLine(
|
||||
"WORKFLOW_GATEWAY_SECRET not set — cannot use WebSocket gateway connection (set env or pass --tunnel-url)",
|
||||
);
|
||||
await new Promise(() => {});
|
||||
return 0;
|
||||
}
|
||||
resolvedTunnelUrl = `http://127.0.0.1:${options.port}`;
|
||||
const log = createLogger({ sink: { kind: "stderr" } });
|
||||
stopWsClient = startGatewayWsClient({
|
||||
gatewayUrl: options.gatewayUrl,
|
||||
name: options.name,
|
||||
secret: options.gatewaySecret,
|
||||
localPort: options.port,
|
||||
log,
|
||||
});
|
||||
printCliLine("gateway WebSocket reverse connection (no cloudflared)");
|
||||
}
|
||||
|
||||
if (options.gatewaySecret) {
|
||||
if (agentToken === null) {
|
||||
printCliLine("internal error: agent token missing");
|
||||
await new Promise(() => {});
|
||||
return 1;
|
||||
}
|
||||
const token = agentToken;
|
||||
const registered = await registerWithGateway(
|
||||
options.gatewayUrl,
|
||||
options.name,
|
||||
resolvedTunnelUrl,
|
||||
options.gatewaySecret,
|
||||
token,
|
||||
);
|
||||
if (registered) {
|
||||
printCliLine(`registered with gateway as "${options.name}"`);
|
||||
}
|
||||
|
||||
const heartbeatTimer = startHeartbeat(
|
||||
options.gatewayUrl,
|
||||
options.name,
|
||||
resolvedTunnelUrl,
|
||||
options.gatewaySecret,
|
||||
token,
|
||||
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);
|
||||
} else {
|
||||
printCliLine("WORKFLOW_GATEWAY_SECRET not set — skipping gateway registration");
|
||||
}
|
||||
|
||||
await new Promise(() => {});
|
||||
return 0;
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
export type ServeOptions = {
|
||||
port: number;
|
||||
hostname: string;
|
||||
name: string;
|
||||
noTunnel: boolean;
|
||||
tunnelUrl: string | null;
|
||||
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
|
||||
@@ -79,7 +79,7 @@ describe("validateCursorAgentConfig", () => {
|
||||
});
|
||||
|
||||
describe("createCursorAgent", () => {
|
||||
test("returns an AgentFn with explicit workspace", () => {
|
||||
test("returns an AdapterFn with explicit workspace", () => {
|
||||
const agent = createCursorAgent({
|
||||
command: "/usr/local/bin/cursor-agent",
|
||||
model: null,
|
||||
@@ -90,7 +90,7 @@ describe("createCursorAgent", () => {
|
||||
expect(typeof agent).toBe("function");
|
||||
});
|
||||
|
||||
test("returns an AgentFn with null workspace and llmProvider", () => {
|
||||
test("returns an AdapterFn with null workspace and llmProvider", () => {
|
||||
const agent = createCursorAgent({
|
||||
command: "/usr/local/bin/cursor-agent",
|
||||
model: null,
|
||||
|
||||
@@ -1,18 +1,32 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-agent-cursor",
|
||||
"version": "0.3.11",
|
||||
"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-protocol": "workspace:^",
|
||||
"@uncaged/workflow-reactor": "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,6 +1,11 @@
|
||||
import type { AgentFn } from "@uncaged/workflow-runtime";
|
||||
import type { AdapterFn } 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";
|
||||
@@ -29,12 +34,12 @@ function resolveCursorModel(model: string | null): string {
|
||||
}
|
||||
|
||||
/** Runs `cursor-agent` with workspace from config or extracted from context via LLM. */
|
||||
export function createCursorAgent(config: CursorAgentConfig): AgentFn {
|
||||
export function createCursorAgent(config: CursorAgentConfig): AdapterFn {
|
||||
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) => {
|
||||
const validated = validateCursorAgentConfig(config);
|
||||
if (!validated.ok) {
|
||||
throw new Error(validated.error);
|
||||
@@ -48,7 +53,8 @@ export function createCursorAgent(config: CursorAgentConfig): AgentFn {
|
||||
if (config.llmProvider === null) {
|
||||
throw new Error("cursor-agent: llmProvider is required when workspace is null");
|
||||
}
|
||||
const extracted = await extractWorkspacePath(ctx, config.llmProvider, logger);
|
||||
const agentCtx = { ...ctx, currentRole: { name: "cursor", systemPrompt: prompt } };
|
||||
const extracted = await extractWorkspacePath(agentCtx, 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.",
|
||||
@@ -58,7 +64,8 @@ export function createCursorAgent(config: CursorAgentConfig): AgentFn {
|
||||
}
|
||||
|
||||
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 +86,5 @@ export function createCursorAgent(config: CursorAgentConfig): AgentFn {
|
||||
throwCursorSpawnError(run.error);
|
||||
}
|
||||
return run.value;
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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.11",
|
||||
"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) => {
|
||||
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.11",
|
||||
"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) => {
|
||||
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"));
|
||||
};
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { describe, test, expect, afterAll } from "bun:test";
|
||||
import { readFileTool, writeFileTool, patchFileTool, shellExecTool } from "../src/tools/index.js";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { readFileSync, unlinkSync, mkdirSync } from "node:fs";
|
||||
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 });
|
||||
@@ -14,9 +14,17 @@ const cleanupFiles: string[] = [];
|
||||
|
||||
afterAll(() => {
|
||||
for (const f of cleanupFiles) {
|
||||
try { unlinkSync(f); } catch { /* ignore */ }
|
||||
try {
|
||||
unlinkSync(f);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
try {
|
||||
unlinkSync(TMP_DIR);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try { unlinkSync(TMP_DIR); } catch { /* ignore */ }
|
||||
});
|
||||
|
||||
describe("read_file", () => {
|
||||
@@ -26,7 +34,9 @@ describe("read_file", () => {
|
||||
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 }));
|
||||
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");
|
||||
@@ -42,7 +52,9 @@ describe("read_file", () => {
|
||||
});
|
||||
|
||||
test("returns error for missing file", async () => {
|
||||
const result = await readFileTool.handler(JSON.stringify({ path: "/nonexistent/file.txt", offset: null, limit: null }));
|
||||
const result = await readFileTool.handler(
|
||||
JSON.stringify({ path: "/nonexistent/file.txt", offset: null, limit: null }),
|
||||
);
|
||||
expect(result).toContain("Error:");
|
||||
});
|
||||
});
|
||||
@@ -64,7 +76,9 @@ describe("patch_file", () => {
|
||||
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" }));
|
||||
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");
|
||||
});
|
||||
@@ -74,7 +88,9 @@ describe("patch_file", () => {
|
||||
cleanupFiles.push(p);
|
||||
require("node:fs").writeFileSync(p, "foo");
|
||||
|
||||
const result = await patchFileTool.handler(JSON.stringify({ path: p, old_string: "xyz", new_string: "abc" }));
|
||||
const result = await patchFileTool.handler(
|
||||
JSON.stringify({ path: p, old_string: "xyz", new_string: "abc" }),
|
||||
);
|
||||
expect(result).toContain("not found");
|
||||
});
|
||||
|
||||
@@ -83,14 +99,18 @@ describe("patch_file", () => {
|
||||
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" }));
|
||||
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 }));
|
||||
const result = await shellExecTool.handler(
|
||||
JSON.stringify({ command: "echo hello", timeout: null }),
|
||||
);
|
||||
expect(result.trim()).toBe("hello");
|
||||
});
|
||||
|
||||
|
||||
@@ -1,27 +1,35 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-agent-react",
|
||||
"version": "0.3.11",
|
||||
"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,4 +1,4 @@
|
||||
export { createReactAdapter } from "./create-react-adapter.js";
|
||||
export type { ReactAdapterConfig, ReactToolHandler } from "./types.js";
|
||||
export { defaultTools, defaultToolHandler } from "./tools/index.js";
|
||||
export type { ToolEntry, ToolHandler } from "./tools/index.js";
|
||||
export { defaultToolHandler, defaultTools } from "./tools/index.js";
|
||||
export type { ReactAdapterConfig, ReactToolHandler } from "./types.js";
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { ToolDefinition } from "@uncaged/workflow-reactor";
|
||||
import type { ToolEntry } from "./types.js";
|
||||
import { readFileTool } from "./read-file.js";
|
||||
import { writeFileTool } from "./write-file.js";
|
||||
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];
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export { readFileTool } from "./read-file.js";
|
||||
export { writeFileTool } from "./write-file.js";
|
||||
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 { defaultTools, defaultToolHandler } from "./defaults.js";
|
||||
export type { ToolEntry, ToolHandler } from "./types.js";
|
||||
export { writeFileTool } from "./write-file.js";
|
||||
|
||||
@@ -30,7 +30,10 @@ export const patchFileTool: ToolEntry = {
|
||||
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);
|
||||
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) {
|
||||
|
||||
@@ -11,7 +11,10 @@ export const readFileTool: ToolEntry = {
|
||||
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)" },
|
||||
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"],
|
||||
@@ -20,12 +23,17 @@ export const readFileTool: ToolEntry = {
|
||||
},
|
||||
handler: async (args: string): Promise<string> => {
|
||||
try {
|
||||
const parsed = JSON.parse(args) as { path: string; offset: number | null; limit: number | null };
|
||||
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 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) {
|
||||
|
||||
@@ -3,6 +3,27 @@ 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",
|
||||
@@ -29,17 +50,9 @@ export const shellExecTool: ToolEntry = {
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
maxBuffer: MAX_OUTPUT * 2,
|
||||
});
|
||||
return output.length > MAX_OUTPUT ? `${output.slice(0, MAX_OUTPUT)}\n...(truncated)` : output;
|
||||
return truncate(output);
|
||||
} catch (err: unknown) {
|
||||
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 combined.length > MAX_OUTPUT ? `${combined.slice(0, MAX_OUTPUT)}\n...(truncated)` : combined || `Error: command exited with status ${e.status}`;
|
||||
}
|
||||
return `Error: ${err instanceof Error ? err.message : String(err)}`;
|
||||
return classifyExecError(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.11",
|
||||
"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,9 +13,7 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dagrejs/dagre": "^3.0.0",
|
||||
"@xyflow/react": "^12.10.2",
|
||||
"elkjs": "^0.11.1",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-markdown": "^10.1.0",
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -52,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());
|
||||
@@ -72,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;
|
||||
@@ -117,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)}`);
|
||||
|
||||
@@ -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,11 +1,36 @@
|
||||
import {
|
||||
BaseEdge,
|
||||
EdgeLabelRenderer,
|
||||
type EdgeProps,
|
||||
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 = 100;
|
||||
// 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,
|
||||
@@ -20,28 +45,46 @@ export function ConditionEdge(props: EdgeProps) {
|
||||
data,
|
||||
markerEnd,
|
||||
} = props;
|
||||
const edgeData = data as (ConditionEdgeData & { elkLabelX?: number | null; elkLabelY?: number | null }) | undefined;
|
||||
const edgeData = data as ConditionEdgeData | undefined;
|
||||
const isFallback = edgeData?.isFallback ?? false;
|
||||
const isSelfLoop = source === target;
|
||||
const isFeedback = edgeData?.isFeedback ?? false;
|
||||
|
||||
const [path, defaultLabelX, defaultLabelY] = getSmoothStepPath({
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
sourcePosition,
|
||||
targetPosition,
|
||||
borderRadius: isSelfLoop ? 20 : 8,
|
||||
offset: isSelfLoop ? 50 : undefined,
|
||||
});
|
||||
let path: string;
|
||||
let defaultLabelX: number;
|
||||
let defaultLabelY: number;
|
||||
|
||||
const stroke = isFallback ? "var(--color-text-muted)" : "var(--color-accent)";
|
||||
const strokeDasharray = isFallback ? "5 4" : undefined;
|
||||
const label = edgeData?.condition ?? "";
|
||||
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];
|
||||
}
|
||||
|
||||
// Use ELK-computed label position if available, otherwise fall back to ReactFlow default
|
||||
const labelX = edgeData?.elkLabelX ?? defaultLabelX;
|
||||
const labelY = edgeData?.elkLabelY ?? defaultLabelY;
|
||||
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 (
|
||||
<>
|
||||
@@ -49,7 +92,7 @@ export function ConditionEdge(props: EdgeProps) {
|
||||
id={id}
|
||||
path={path}
|
||||
markerEnd={markerEnd}
|
||||
style={{ stroke, strokeWidth: 1.5, strokeDasharray }}
|
||||
style={{ stroke, strokeWidth: 1.5 }}
|
||||
/>
|
||||
{label !== "" && (
|
||||
<EdgeLabelRenderer>
|
||||
@@ -59,7 +102,7 @@ export function ConditionEdge(props: EdgeProps) {
|
||||
transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`,
|
||||
background: "var(--color-surface)",
|
||||
border: "1px solid var(--color-border)",
|
||||
color: isFallback ? "var(--color-text-muted)" : "var(--color-text)",
|
||||
color: "var(--color-text)",
|
||||
whiteSpace: "nowrap",
|
||||
zIndex: 10,
|
||||
}}
|
||||
|
||||
@@ -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 ${t.state !== "default" ? "cursor-pointer" : ""} ${isActive ? "wf-node-pulse" : ""}`}
|
||||
style={{
|
||||
width: 180,
|
||||
height: 60,
|
||||
|
||||
@@ -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 type { Edge, Node } from "@xyflow/react";
|
||||
import ELK, { type ElkExtendedEdge, type ElkNode } from "elkjs/lib/elk.bundled.js";
|
||||
import { useEffect, useState } from "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 = 100;
|
||||
|
||||
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 };
|
||||
@@ -41,6 +36,76 @@ 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 },
|
||||
@@ -72,136 +137,102 @@ function buildTerminalNode(
|
||||
};
|
||||
}
|
||||
|
||||
function buildEdge(e: WorkflowGraphEdge, elkEdgeMap: Map<string, ElkExtendedEdge>): Edge<ConditionEdgeData> {
|
||||
const isFallback = e.condition === "FALLBACK";
|
||||
const key = edgeKey(e);
|
||||
const elkEdge = elkEdgeMap.get(key);
|
||||
|
||||
// Extract ELK's computed label position
|
||||
let labelX: number | null = null;
|
||||
let labelY: number | null = null;
|
||||
if (elkEdge?.labels && elkEdge.labels.length > 0) {
|
||||
const label = elkEdge.labels[0];
|
||||
if (label.x !== undefined && label.y !== undefined) {
|
||||
labelX = label.x + (label.width ?? 0) / 2;
|
||||
labelY = label.y + (label.height ?? 0) / 2;
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
return {
|
||||
id: key,
|
||||
source: e.from,
|
||||
target: e.to,
|
||||
type: "condition",
|
||||
data: {
|
||||
condition: e.condition,
|
||||
conditionDescription: e.conditionDescription,
|
||||
isFallback,
|
||||
elkLabelX: labelX,
|
||||
elkLabelY: labelY,
|
||||
} as ConditionEdgeData,
|
||||
};
|
||||
}
|
||||
// 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 }>();
|
||||
|
||||
const elk = new ELK();
|
||||
|
||||
async function computeLayout(input: LayoutInput): Promise<LayoutResult> {
|
||||
const ids = collectNodeIds(input.edges);
|
||||
|
||||
const elkNodes: ElkNode[] = [];
|
||||
for (const id of ids) {
|
||||
let y = 0;
|
||||
for (const id of spine) {
|
||||
const size = nodeSize(id);
|
||||
elkNodes.push({ id, width: size.width, height: size.height });
|
||||
}
|
||||
|
||||
const elkEdges: ElkExtendedEdge[] = input.edges
|
||||
.filter((e) => e.from !== e.to)
|
||||
.map((e) => ({
|
||||
id: edgeKey(e),
|
||||
sources: [e.from],
|
||||
targets: [e.to],
|
||||
labels: e.condition !== ""
|
||||
? [{ text: e.condition, width: Math.max(e.condition.length * 7 + 16, 60), height: 22 }]
|
||||
: [],
|
||||
}));
|
||||
|
||||
const graph: ElkNode = {
|
||||
id: "root",
|
||||
layoutOptions: {
|
||||
"elk.algorithm": "layered",
|
||||
"elk.direction": "DOWN",
|
||||
// Node spacing
|
||||
"elk.spacing.nodeNode": "30",
|
||||
"elk.layered.spacing.nodeNodeBetweenLayers": "50",
|
||||
// Edge spacing — keep edges apart from each other and from nodes
|
||||
"elk.spacing.edgeNode": "25",
|
||||
"elk.spacing.edgeEdge": "15",
|
||||
"elk.layered.spacing.edgeNodeBetweenLayers": "25",
|
||||
"elk.layered.spacing.edgeEdgeBetweenLayers": "15",
|
||||
// Edge routing
|
||||
"elk.edgeRouting": "ORTHOGONAL",
|
||||
"elk.layered.mergeEdges": "false",
|
||||
// Node placement
|
||||
"elk.layered.nodePlacement.strategy": "NETWORK_SIMPLEX",
|
||||
// Edge label placement
|
||||
"elk.edgeLabels.placement": "CENTER",
|
||||
// Crossing minimization
|
||||
"elk.layered.crossingMinimization.strategy": "LAYER_SWEEP",
|
||||
// Compaction
|
||||
"elk.layered.compaction.postCompaction.strategy": "EDGE_LENGTH",
|
||||
// Cycle breaking — keep main flow top-to-bottom
|
||||
"elk.layered.cycleBreaking.strategy": "DEPTH_FIRST",
|
||||
},
|
||||
children: elkNodes,
|
||||
edges: elkEdges,
|
||||
};
|
||||
|
||||
const laid = await elk.layout(graph);
|
||||
|
||||
// Build map of ELK edge results for label positions
|
||||
const elkEdgeMap = new Map<string, ElkExtendedEdge>();
|
||||
for (const e of laid.edges ?? []) {
|
||||
elkEdgeMap.set(e.id, e);
|
||||
// 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 child of laid.children ?? []) {
|
||||
const pos = { x: child.x ?? 0, y: child.y ?? 0 };
|
||||
const state = input.nodeStates.get(child.id) ?? "default";
|
||||
if (child.id === START_ID || child.id === END_ID) {
|
||||
nodes.push(buildTerminalNode(child.id, pos, state));
|
||||
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(child.id, pos, input.roles, state));
|
||||
nodes.push(buildRoleNode(id, { x: pos.x, y: pos.y }, input.roles, state));
|
||||
}
|
||||
}
|
||||
|
||||
const edges: Edge[] = input.edges.map((e) => buildEdge(e, elkEdgeMap));
|
||||
// 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,
|
||||
type: "condition",
|
||||
data: {
|
||||
condition: e.condition,
|
||||
conditionDescription: e.conditionDescription,
|
||||
isFallback,
|
||||
isFeedback,
|
||||
isSelfLoop,
|
||||
feedbackSide,
|
||||
labelX,
|
||||
labelY,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return { nodes, edges };
|
||||
}
|
||||
|
||||
const EMPTY_LAYOUT: LayoutResult = { nodes: [], edges: [] };
|
||||
|
||||
export function useLayout(input: LayoutInput): LayoutResult {
|
||||
const [layout, setLayout] = useState<LayoutResult>(EMPTY_LAYOUT);
|
||||
|
||||
const edgeJson = JSON.stringify(input.edges);
|
||||
const roleJson = JSON.stringify(input.roles);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const parsed = {
|
||||
edges: JSON.parse(edgeJson) as readonly WorkflowGraphEdge[],
|
||||
roles: JSON.parse(roleJson) as Record<string, { description: string }>,
|
||||
nodeStates: input.nodeStates,
|
||||
};
|
||||
computeLayout(parsed).then((result) => {
|
||||
if (!cancelled) setLayout(result);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [edgeJson, roleJson, input.nodeStates]);
|
||||
|
||||
return layout;
|
||||
return useMemo(() => computeLayout(input), [input]);
|
||||
}
|
||||
|
||||
@@ -6,11 +6,9 @@ import {
|
||||
type NodeTypes,
|
||||
type OnNodeClick,
|
||||
ReactFlow,
|
||||
ReactFlowProvider,
|
||||
useReactFlow,
|
||||
} from "@xyflow/react";
|
||||
import "@xyflow/react/dist/style.css";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useMemo } from "react";
|
||||
import type { WorkflowGraph as WorkflowGraphData } from "../../api.ts";
|
||||
import { ConditionEdge } from "./condition-edge.tsx";
|
||||
import { RoleNode } from "./role-node.tsx";
|
||||
@@ -39,30 +37,12 @@ function handleRoleNodeClick(onRoleClick: (roleName: string) => void, node: Node
|
||||
onRoleClick(node.id);
|
||||
}
|
||||
|
||||
function WorkflowGraphInner({ graph, roles, nodeStates, onNodeClick }: Props) {
|
||||
export function WorkflowGraph({ graph, roles, nodeStates, onNodeClick }: Props) {
|
||||
const layout = useLayout({ edges: graph.edges, roles, nodeStates });
|
||||
const { fitView } = useReactFlow();
|
||||
|
||||
const onNodeClickHandler: OnNodeClick | undefined =
|
||||
onNodeClick !== null ? (_e, node) => handleRoleNodeClick(onNodeClick, node) : undefined;
|
||||
|
||||
// Re-fit when layout changes (ELK is async)
|
||||
// Use requestAnimationFrame + setTimeout to ensure ReactFlow has processed nodes
|
||||
useEffect(() => {
|
||||
if (layout.nodes.length > 0) {
|
||||
let cancelled = false;
|
||||
requestAnimationFrame(() => {
|
||||
if (cancelled) return;
|
||||
setTimeout(() => {
|
||||
if (!cancelled) fitView({ padding: 0.1, duration: 300 });
|
||||
}, 300);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
}, [layout.nodes, layout.edges, fitView]);
|
||||
|
||||
const styledEdges = useMemo(
|
||||
() =>
|
||||
layout.edges.map((e) => ({
|
||||
@@ -77,25 +57,17 @@ function WorkflowGraphInner({ graph, roles, nodeStates, onNodeClick }: Props) {
|
||||
[layout.edges],
|
||||
);
|
||||
|
||||
// Generate a stable key that changes when layout changes, to force ReactFlow remount + fitView
|
||||
const layoutKey = useMemo(
|
||||
() => layout.nodes.map((n) => `${n.id}:${n.position.x}:${n.position.y}`).join(","),
|
||||
[layout.nodes],
|
||||
);
|
||||
|
||||
return (
|
||||
<ReactFlow
|
||||
key={layoutKey}
|
||||
nodes={layout.nodes}
|
||||
edges={styledEdges}
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
onNodeClick={onNodeClickHandler}
|
||||
fitView
|
||||
fitViewOptions={{ padding: 0.1, minZoom: 0.1, maxZoom: 1.5 }}
|
||||
minZoom={0.1}
|
||||
maxZoom={1.5}
|
||||
defaultViewport={{ x: 0, y: 0, zoom: 0.5 }}
|
||||
fitViewOptions={{ padding: 0.15 }}
|
||||
minZoom={0.3}
|
||||
maxZoom={2}
|
||||
nodesDraggable={false}
|
||||
nodesConnectable={false}
|
||||
elementsSelectable={false}
|
||||
@@ -107,11 +79,3 @@ function WorkflowGraphInner({ graph, roles, nodeStates, onNodeClick }: Props) {
|
||||
</ReactFlow>
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkflowGraph(props: Props) {
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
<WorkflowGraphInner {...props} />
|
||||
</ReactFlowProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 =
|
||||
@@ -108,8 +108,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(),
|
||||
@@ -117,11 +117,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) => {
|
||||
@@ -135,7 +135,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 });
|
||||
@@ -151,7 +151,7 @@ export function WorkflowList({ agent }: Props) {
|
||||
}
|
||||
})();
|
||||
},
|
||||
[agent],
|
||||
[client],
|
||||
);
|
||||
|
||||
function toggleExpanded(name: string) {
|
||||
|
||||
@@ -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.11",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,6 +1,11 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-gateway",
|
||||
"version": "0.3.11",
|
||||
"version": "0.4.5",
|
||||
"files": [
|
||||
"src",
|
||||
"dist",
|
||||
"package.json"
|
||||
],
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
@@ -16,5 +21,8 @@
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^4.20260425.1",
|
||||
"wrangler": "^4.20.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
|
||||
+9
-9
@@ -1,14 +1,14 @@
|
||||
/** One Durable Object instance per agent name; holds the reverse WebSocket from the agent CLI. */
|
||||
/** 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 AgentSocketEnv = {
|
||||
type ClientSocketEnv = {
|
||||
GATEWAY_SECRET: string;
|
||||
};
|
||||
|
||||
export const AGENT_SOCKET_INTERNAL_STATUS_PATH = "/internal/agent-socket/status";
|
||||
export const AGENT_SOCKET_INTERNAL_PROXY_PATH = "/internal/agent-socket/proxy";
|
||||
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;
|
||||
|
||||
@@ -32,7 +32,7 @@ function wsResponseToHttp(wr: WsResponse): Response {
|
||||
return new Response(wr.body, { status: wr.status, headers });
|
||||
}
|
||||
|
||||
export class AgentSocket extends DurableObject<AgentSocketEnv> {
|
||||
export class ClientSocket extends DurableObject<ClientSocketEnv> {
|
||||
private readonly pending = new Map<string, PendingEntry>();
|
||||
|
||||
private requireAuth(request: Request): Response | null {
|
||||
@@ -100,11 +100,11 @@ export class AgentSocket extends DurableObject<AgentSocketEnv> {
|
||||
async fetch(request: Request): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
|
||||
if (url.pathname === AGENT_SOCKET_INTERNAL_STATUS_PATH && request.method === "GET") {
|
||||
if (url.pathname === CLIENT_SOCKET_INTERNAL_STATUS_PATH && request.method === "GET") {
|
||||
return this.handleStatusGet(request);
|
||||
}
|
||||
|
||||
if (url.pathname === AGENT_SOCKET_INTERNAL_PROXY_PATH && request.method === "POST") {
|
||||
if (url.pathname === CLIENT_SOCKET_INTERNAL_PROXY_PATH && request.method === "POST") {
|
||||
return this.handleProxyPost(request);
|
||||
}
|
||||
|
||||
@@ -144,11 +144,11 @@ export class AgentSocket extends DurableObject<AgentSocketEnv> {
|
||||
_reason: string,
|
||||
_wasClean: boolean,
|
||||
): Promise<void> {
|
||||
this.rejectAllPending("agent websocket closed");
|
||||
this.rejectAllPending("client websocket closed");
|
||||
}
|
||||
|
||||
async webSocketError(_ws: WebSocket, _error: unknown): Promise<void> {
|
||||
this.rejectAllPending("agent websocket error");
|
||||
this.rejectAllPending("client websocket error");
|
||||
}
|
||||
|
||||
private rejectAllPending(message: string): void {
|
||||
@@ -2,27 +2,27 @@ import { Hono } from "hono";
|
||||
import { cors } from "hono/cors";
|
||||
|
||||
import {
|
||||
AGENT_SOCKET_INTERNAL_PROXY_PATH,
|
||||
AGENT_SOCKET_INTERNAL_STATUS_PATH,
|
||||
AgentSocket,
|
||||
} from "./agent-socket.js";
|
||||
CLIENT_SOCKET_INTERNAL_PROXY_PATH,
|
||||
CLIENT_SOCKET_INTERNAL_STATUS_PATH,
|
||||
ClientSocket,
|
||||
} from "./client-socket.js";
|
||||
import type { WsRequest } from "./ws-protocol.js";
|
||||
|
||||
export { AgentSocket };
|
||||
export { ClientSocket };
|
||||
|
||||
type Env = {
|
||||
Bindings: {
|
||||
ENDPOINTS: KVNamespace;
|
||||
GATEWAY_SECRET: string;
|
||||
DASHBOARD_API_KEY: string;
|
||||
AGENT_SOCKET: DurableObjectNamespace<AgentSocket>;
|
||||
CLIENT_SOCKET: DurableObjectNamespace<ClientSocket>;
|
||||
};
|
||||
};
|
||||
|
||||
type EndpointRecord = {
|
||||
name: string;
|
||||
url: string;
|
||||
agentToken: string;
|
||||
clientToken: string;
|
||||
registeredAt: number;
|
||||
lastHeartbeat: number;
|
||||
};
|
||||
@@ -43,7 +43,7 @@ function checkDashboardAuth(c: {
|
||||
return key === c.env.DASHBOARD_API_KEY;
|
||||
}
|
||||
|
||||
function isLocalAgentUrl(url: string): boolean {
|
||||
function isLocalClientUrl(url: string): boolean {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
return u.hostname === "localhost" || u.hostname === "127.0.0.1";
|
||||
@@ -52,7 +52,7 @@ function isLocalAgentUrl(url: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
function buildForwardHeaders(raw: Headers, agentToken: string): Record<string, string> {
|
||||
function buildForwardHeaders(raw: Headers, clientToken: string): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const [key, value] of raw) {
|
||||
const lower = key.toLowerCase();
|
||||
@@ -70,8 +70,8 @@ function buildForwardHeaders(raw: Headers, agentToken: string): Record<string, s
|
||||
}
|
||||
out[key] = value;
|
||||
}
|
||||
if (agentToken !== "") {
|
||||
out["X-Agent-Token"] = agentToken;
|
||||
if (clientToken !== "") {
|
||||
out["X-Client-Token"] = clientToken;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -81,7 +81,7 @@ function buildDashboardProxyHeaders(raw: Headers, token: string): Headers {
|
||||
headers.delete("host");
|
||||
headers.delete("Authorization");
|
||||
if (token !== "") {
|
||||
headers.set("X-Agent-Token", token);
|
||||
headers.set("X-Client-Token", token);
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
@@ -94,15 +94,15 @@ async function readBodyForWsProxy(method: string, req: Request): Promise<string
|
||||
return buf.byteLength === 0 ? null : new TextDecoder().decode(buf);
|
||||
}
|
||||
|
||||
async function fetchThroughAgentSocket(
|
||||
async function fetchThroughClientSocket(
|
||||
bindings: Env["Bindings"],
|
||||
agent: string,
|
||||
client: string,
|
||||
gateSecret: string,
|
||||
wsRequest: WsRequest,
|
||||
): Promise<Response> {
|
||||
const stub = bindings.AGENT_SOCKET.get(bindings.AGENT_SOCKET.idFromName(agent));
|
||||
const stub = bindings.CLIENT_SOCKET.get(bindings.CLIENT_SOCKET.idFromName(client));
|
||||
return stub.fetch(
|
||||
new Request(`https://do.internal${AGENT_SOCKET_INTERNAL_PROXY_PATH}`, {
|
||||
new Request(`https://do.internal${CLIENT_SOCKET_INTERNAL_PROXY_PATH}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${gateSecret}`,
|
||||
@@ -113,7 +113,7 @@ async function fetchThroughAgentSocket(
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchAgentWithRecordHeaders(
|
||||
async function fetchClientWithRecordHeaders(
|
||||
targetUrl: string,
|
||||
method: string,
|
||||
forwardRecord: Record<string, string>,
|
||||
@@ -130,7 +130,7 @@ async function fetchAgentWithRecordHeaders(
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchAgentWithDashboardHeaders(
|
||||
async function fetchClientWithDashboardHeaders(
|
||||
targetUrl: string,
|
||||
method: string,
|
||||
headers: Headers,
|
||||
@@ -143,15 +143,15 @@ async function fetchAgentWithDashboardHeaders(
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchAgentSocketStatus(
|
||||
async function fetchClientSocketStatus(
|
||||
env: Env["Bindings"],
|
||||
name: string,
|
||||
): Promise<{ ok: true; connected: boolean } | { ok: false }> {
|
||||
try {
|
||||
const id = env.AGENT_SOCKET.idFromName(name);
|
||||
const stub = env.AGENT_SOCKET.get(id);
|
||||
const id = env.CLIENT_SOCKET.idFromName(name);
|
||||
const stub = env.CLIENT_SOCKET.get(id);
|
||||
const resp = await stub.fetch(
|
||||
new Request(`https://do${AGENT_SOCKET_INTERNAL_STATUS_PATH}`, {
|
||||
new Request(`https://do${CLIENT_SOCKET_INTERNAL_STATUS_PATH}`, {
|
||||
method: "GET",
|
||||
headers: { Authorization: `Bearer ${env.GATEWAY_SECRET}` },
|
||||
}),
|
||||
@@ -171,7 +171,7 @@ function endpointStatusFromKvAndDo(record: EndpointRecord, doConnected: boolean
|
||||
return "online";
|
||||
}
|
||||
if (doConnected === false) {
|
||||
if (isLocalAgentUrl(record.url)) {
|
||||
if (isLocalClientUrl(record.url)) {
|
||||
return "offline";
|
||||
}
|
||||
const age = Date.now() - record.lastHeartbeat;
|
||||
@@ -184,7 +184,7 @@ function endpointStatusFromKvAndDo(record: EndpointRecord, doConnected: boolean
|
||||
// ── Health ──────────────────────────────────────────────────────────
|
||||
app.get("/healthz", (c) => c.json({ ok: true }));
|
||||
|
||||
// ── Agent reverse WebSocket (GATEWAY_SECRET query param) ────────────
|
||||
// ── 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");
|
||||
@@ -197,8 +197,8 @@ app.get("/ws/connect", async (c) => {
|
||||
if (c.req.header("Upgrade") !== "websocket") {
|
||||
return c.text("expected WebSocket upgrade", 426);
|
||||
}
|
||||
const id = c.env.AGENT_SOCKET.idFromName(name);
|
||||
const stub = c.env.AGENT_SOCKET.get(id);
|
||||
const id = c.env.CLIENT_SOCKET.idFromName(name);
|
||||
const stub = c.env.CLIENT_SOCKET.get(id);
|
||||
return stub.fetch(c.req.raw);
|
||||
});
|
||||
|
||||
@@ -210,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);
|
||||
@@ -227,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,
|
||||
};
|
||||
@@ -261,7 +261,7 @@ 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 doStatus = await fetchAgentSocketStatus(c.env, record.name);
|
||||
const doStatus = await fetchClientSocketStatus(c.env, record.name);
|
||||
const doConnected = doStatus.ok ? doStatus.connected : null;
|
||||
endpoints.push({
|
||||
name: record.name,
|
||||
@@ -277,25 +277,25 @@ gateway.get("/endpoints", async (c) => {
|
||||
|
||||
app.route("/api/gateway", gateway);
|
||||
|
||||
// ── API proxy: /api/agents/:agent/* → WebSocket (preferred) or agent 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);
|
||||
}
|
||||
|
||||
const url = new URL(c.req.url);
|
||||
const pathAfterAgent = url.pathname.replace(`/api/agents/${agent}`, "");
|
||||
const targetUrl = `${record.url}/api${pathAfterAgent}${url.search}`;
|
||||
const proxyPath = `/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.agentToken ?? "";
|
||||
const token = record.clientToken ?? "";
|
||||
const forwardRecord = buildForwardHeaders(c.req.raw.headers, token);
|
||||
|
||||
const doStatus = await fetchAgentSocketStatus(c.env, agent);
|
||||
const doStatus = await fetchClientSocketStatus(c.env, client);
|
||||
if (doStatus.ok && doStatus.connected) {
|
||||
const bodyStr = await readBodyForWsProxy(method, c.req.raw);
|
||||
const wsRequest: WsRequest = {
|
||||
@@ -305,7 +305,7 @@ app.all("/api/agents/:agent/*", async (c) => {
|
||||
headers: forwardRecord,
|
||||
body: bodyStr,
|
||||
};
|
||||
const proxyResp = await fetchThroughAgentSocket(c.env, agent, c.env.GATEWAY_SECRET, wsRequest);
|
||||
const proxyResp = await fetchThroughClientSocket(c.env, client, c.env.GATEWAY_SECRET, wsRequest);
|
||||
if (proxyResp.status !== 503) {
|
||||
return new Response(proxyResp.body, {
|
||||
status: proxyResp.status,
|
||||
@@ -313,25 +313,25 @@ app.all("/api/agents/:agent/*", async (c) => {
|
||||
});
|
||||
}
|
||||
try {
|
||||
const resp = await fetchAgentWithRecordHeaders(targetUrl, method, forwardRecord, bodyStr);
|
||||
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: "agent unreachable", detail: String(err) }, 502);
|
||||
return c.json({ error: "client unreachable", detail: String(err) }, 502);
|
||||
}
|
||||
}
|
||||
|
||||
const headers = buildDashboardProxyHeaders(c.req.raw.headers, token);
|
||||
try {
|
||||
const resp = await fetchAgentWithDashboardHeaders(targetUrl, method, headers, c.req.raw.body);
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -7,10 +7,14 @@ binding = "ENDPOINTS"
|
||||
id = "88b118d1cfab4c049f9c1684848811a3"
|
||||
|
||||
[durable_objects]
|
||||
bindings = [{ name = "AGENT_SOCKET", class_name = "AgentSocket" }]
|
||||
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.11",
|
||||
"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 };
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# @uncaged/workflow-reactor
|
||||
|
||||
## 0.4.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-protocol@0.4.5
|
||||
|
||||
## 0.4.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-protocol@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
|
||||
|
||||
## 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
|
||||
|
||||
## 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
|
||||
@@ -1,15 +1,21 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-reactor",
|
||||
"version": "0.3.11",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/workflow-protocol": "workspace:*"
|
||||
"@uncaged/workflow-protocol": "workspace:^"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "^4.0.0"
|
||||
@@ -17,5 +23,8 @@
|
||||
"devDependencies": {
|
||||
"zod": "^4.0.0",
|
||||
"typescript": "^5.8.3"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
# @uncaged/workflow-register
|
||||
|
||||
## 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,16 +1,22 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-register",
|
||||
"version": "0.3.11",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/workflow-protocol": "workspace:*",
|
||||
"@uncaged/workflow-util": "workspace:*"
|
||||
"@uncaged/workflow-protocol": "workspace:^",
|
||||
"@uncaged/workflow-util": "workspace:^"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"acorn": "^8.0.0",
|
||||
@@ -22,5 +28,8 @@
|
||||
"yaml": "^2.7.1",
|
||||
"zod": "^4.0.0",
|
||||
"typescript": "^5.8.3"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
# @uncaged/workflow-runtime
|
||||
|
||||
## 0.4.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-protocol@0.4.5
|
||||
- @uncaged/workflow-cas@0.4.5
|
||||
|
||||
## 0.4.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-protocol@0.4.4
|
||||
- @uncaged/workflow-cas@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
|
||||
|
||||
## 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
|
||||
|
||||
## 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
|
||||
@@ -1,20 +1,34 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-runtime",
|
||||
"version": "0.3.11",
|
||||
"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-cas": "workspace:*",
|
||||
"@uncaged/workflow-protocol": "workspace:*"
|
||||
"@uncaged/workflow-cas": "workspace:^",
|
||||
"@uncaged/workflow-protocol": "workspace:^"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"zod": "^4.0.0"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"bun": "./src/index.ts",
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ function resolveExtractedRefs(
|
||||
return extractRefsFn(meta as Record<string, unknown>);
|
||||
}
|
||||
|
||||
function mergeUniqueHashes(a: readonly string[], b: readonly string[]): string[] {
|
||||
function _mergeUniqueHashes(a: readonly string[], b: readonly string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const h of [...a, ...b]) {
|
||||
@@ -83,7 +83,10 @@ async function advanceOneRound<M extends RoleMeta>(
|
||||
}
|
||||
|
||||
const adapter = adapterForRole(binding, next);
|
||||
const roleFn = adapter(roleDef.systemPrompt, roleDef.schema as z.ZodType<Record<string, unknown>>);
|
||||
const roleFn = adapter(
|
||||
roleDef.systemPrompt,
|
||||
roleDef.schema as z.ZodType<Record<string, unknown>>,
|
||||
);
|
||||
const result = await roleFn(modCtx as unknown as ThreadContext, runtime);
|
||||
const meta = result.meta;
|
||||
|
||||
|
||||
@@ -4,10 +4,7 @@ export { err, ok } from "./result.js";
|
||||
export type {
|
||||
AdapterBinding,
|
||||
AdapterFn,
|
||||
AgentBinding,
|
||||
AgentContext,
|
||||
AgentFn,
|
||||
AgentFnResult,
|
||||
CasStore,
|
||||
ExtractFn,
|
||||
ExtractResult,
|
||||
@@ -20,9 +17,9 @@ export type {
|
||||
Result,
|
||||
RoleDefinition,
|
||||
RoleFn,
|
||||
RoleResult,
|
||||
RoleMeta,
|
||||
RoleOutput,
|
||||
RoleResult,
|
||||
RoleStep,
|
||||
StartStep,
|
||||
ThreadContext,
|
||||
|
||||
@@ -6,10 +6,7 @@ export type {
|
||||
AdapterBinding,
|
||||
AdapterFn,
|
||||
AdvanceOutcome,
|
||||
AgentBinding,
|
||||
AgentContext,
|
||||
AgentFn,
|
||||
AgentFnResult,
|
||||
CasStore,
|
||||
ExtractFn,
|
||||
ExtractResult,
|
||||
@@ -24,9 +21,9 @@ export type {
|
||||
Result,
|
||||
RoleDefinition,
|
||||
RoleFn,
|
||||
RoleResult,
|
||||
RoleMeta,
|
||||
RoleOutput,
|
||||
RoleResult,
|
||||
RoleStep,
|
||||
StartStep,
|
||||
ThreadContext,
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# @uncaged/workflow-template-develop
|
||||
|
||||
## 0.4.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @uncaged/workflow-register@0.4.5
|
||||
- @uncaged/workflow-runtime@0.4.5
|
||||
|
||||
## 0.4.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @uncaged/workflow-register@0.4.4
|
||||
- @uncaged/workflow-runtime@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-register@0.4.3
|
||||
- @uncaged/workflow-runtime@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-register@0.4.2
|
||||
- @uncaged/workflow-runtime@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-register@0.4.0
|
||||
- @uncaged/workflow-runtime@0.4.0
|
||||
@@ -9,7 +9,9 @@ import type { DevelopMeta } from "../src/roles.js";
|
||||
|
||||
const developModerator = tableToModerator(developTable);
|
||||
|
||||
const DEFAULT_PHASES: PlannerMeta["phases"] = [
|
||||
type PlannedMeta = Extract<PlannerMeta, { status: "planned" }>;
|
||||
|
||||
const DEFAULT_PHASES: PlannedMeta["phases"] = [
|
||||
{
|
||||
hash: "4KNMR2PX",
|
||||
title: "Do the work",
|
||||
@@ -36,11 +38,11 @@ function makeCtx(steps: ModeratorContext<DevelopMeta>["steps"]): ModeratorContex
|
||||
};
|
||||
}
|
||||
|
||||
function plannerStep(phases: PlannerMeta["phases"] = DEFAULT_PHASES): RoleStep<DevelopMeta> {
|
||||
function plannerStep(phases: PlannedMeta["phases"] = DEFAULT_PHASES): RoleStep<DevelopMeta> {
|
||||
return {
|
||||
role: "planner",
|
||||
contentHash: "STUBHASHPLANNER001",
|
||||
meta: { phases },
|
||||
meta: { status: "planned" as const, phases },
|
||||
refs: phases.map((p) => p.hash),
|
||||
timestamp: 1,
|
||||
};
|
||||
@@ -153,7 +155,7 @@ describe("developModerator", () => {
|
||||
});
|
||||
|
||||
test("multiple planner phases → coder until all complete, then reviewer", () => {
|
||||
const phases: PlannerMeta["phases"] = [
|
||||
const phases: PlannedMeta["phases"] = [
|
||||
{ hash: "AA000001", title: "first phase" },
|
||||
{ hash: "AA000002", title: "second phase" },
|
||||
];
|
||||
@@ -167,7 +169,7 @@ describe("developModerator", () => {
|
||||
});
|
||||
|
||||
test("one-shot coder reports only last phase hash → reviewer (moderator treats as all phases done)", () => {
|
||||
const phases: PlannerMeta["phases"] = [
|
||||
const phases: PlannedMeta["phases"] = [
|
||||
{ hash: "BB000001", title: "setup branch" },
|
||||
{ hash: "BB000002", title: "write tests" },
|
||||
{ hash: "BB000003", title: "verify" },
|
||||
@@ -179,7 +181,7 @@ describe("developModerator", () => {
|
||||
});
|
||||
|
||||
test("unrecognised completedPhase hash → coder retry when budget allows", () => {
|
||||
const phases: PlannerMeta["phases"] = [
|
||||
const phases: PlannedMeta["phases"] = [
|
||||
{ hash: "CC000001", title: "first phase" },
|
||||
{ hash: "CC000002", title: "second phase" },
|
||||
];
|
||||
@@ -187,7 +189,7 @@ describe("developModerator", () => {
|
||||
});
|
||||
|
||||
test("incomplete phases → coder retry (supervisor controls termination)", () => {
|
||||
const phases: PlannerMeta["phases"] = [
|
||||
const phases: PlannedMeta["phases"] = [
|
||||
{ hash: "DD000001", title: "first phase" },
|
||||
{ hash: "DD000002", title: "second phase" },
|
||||
];
|
||||
@@ -198,6 +200,17 @@ describe("developModerator", () => {
|
||||
expect(developModerator(makeCtx(steps))).toBe("coder");
|
||||
});
|
||||
|
||||
test("planner aborted → END", () => {
|
||||
const abortedStep: RoleStep<DevelopMeta> = {
|
||||
role: "planner",
|
||||
contentHash: "STUBHASHABORT001",
|
||||
meta: { status: "aborted", reason: "No workspace path provided" },
|
||||
refs: [],
|
||||
timestamp: 1,
|
||||
};
|
||||
expect(developModerator(makeCtx([abortedStep]))).toBe("__end__");
|
||||
});
|
||||
|
||||
test("committer → END for any committer meta status", () => {
|
||||
const committed = committerStep({ status: "committed", branch: "f", commitSha: "x" });
|
||||
const recoverable = committerStep({
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user