Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7907e24ad0 |
@@ -3,4 +3,3 @@ dist/
|
||||
bun.lock
|
||||
*.tgz
|
||||
tsconfig.tsbuildinfo
|
||||
.npmrc
|
||||
|
||||
@@ -97,36 +97,6 @@ type WorkflowEntry = {
|
||||
|
||||
Workflow bundles (`.esm.js`) follow the same rule: export `const run` and `const descriptor`, not `export default`.
|
||||
|
||||
### Folder Module Discipline
|
||||
|
||||
Every folder under `src/` is a **module boundary**. Four rules:
|
||||
|
||||
| # | Rule | Rationale |
|
||||
|---|------|-----------|
|
||||
| 1 | **Every folder exports via `index.ts`** | Single entry point for the module |
|
||||
| 2 | **Types live in `types.ts`** | Each folder's type definitions go in `<folder>/types.ts`, not scattered across files |
|
||||
| 3 | **Single export source** | Only `index.ts` may re-export. No file may re-export from another module's internals. Cross-module imports must go through `index.ts` — never reach past it to import a specific file |
|
||||
| 4 | **`index.ts` is pure re-exports** | No type definitions, no function implementations — only `export { ... } from` statements |
|
||||
|
||||
```typescript
|
||||
// ✅ Good — import through module boundary
|
||||
import { createCasStore } from "../cas/index.js";
|
||||
import type { CasStore } from "../cas/index.js";
|
||||
|
||||
// ❌ Bad — reaching past index.ts
|
||||
import { createCasStore } from "../cas/cas.js";
|
||||
|
||||
// ❌ Bad — re-exporting from non-index file
|
||||
// in engine/engine.ts:
|
||||
export { createCasStore } from "../cas/cas.js";
|
||||
|
||||
// ❌ Bad — types defined in index.ts
|
||||
// in cas/index.ts:
|
||||
export type CasStore = { ... }; // should be in cas/types.ts
|
||||
```
|
||||
|
||||
**Exception**: The package-level `src/index.ts` is the public API surface and re-exports from folder `index.ts` files. Files that remain at `src/` root (e.g. `types.ts`, `workflow-as-agent.ts`) are not inside a folder module and follow normal rules.
|
||||
|
||||
## Naming
|
||||
|
||||
| Type | Style | Example |
|
||||
@@ -227,8 +197,9 @@ Test files (`__tests__/**`) are exempt.
|
||||
### Commands
|
||||
|
||||
```bash
|
||||
bun run check # tsc --build + biome check
|
||||
bun run check # biome check (lint + format)
|
||||
bun run format # biome format --write
|
||||
bun run build # full build
|
||||
bun test # run tests
|
||||
```
|
||||
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
# @uncaged/workflow
|
||||
|
||||
A workflow engine that executes single-file ESM bundles. Each workflow is a self-contained `.esm.js` file identified by its XXH64 hash (Crockford Base32).
|
||||
|
||||
## Core Concepts
|
||||
|
||||
| Concept | Description |
|
||||
|---------|-------------|
|
||||
| **Workflow** | A single-file ESM module exporting `run` (workflow function) and `descriptor` (metadata). Identified by its XXH64 hash. |
|
||||
| **Bundle** | The physical `.esm.js` file stored in `~/.uncaged/workflow/bundles/`. |
|
||||
| **Thread** | A single execution of a workflow, identified by a ULID. Persisted as `.data.jsonl` + `.info.jsonl`. |
|
||||
| **Role** | A named actor within a workflow. Each role produces output with typed `meta`. Roles live inside template packages (`src/roles/`). |
|
||||
| **Registry** | `workflow.yaml` — maps workflow names to current/historical bundle hashes. |
|
||||
| **CAS** | Content-Addressed Storage — bundles are immutable and addressed by hash. |
|
||||
|
||||
## Monorepo Packages
|
||||
|
||||
```
|
||||
packages/
|
||||
workflow/ # @uncaged/workflow — core lib (types, engine, hash, ULID, registry)
|
||||
cli-workflow/ # @uncaged/cli-workflow — CLI (`uncaged-workflow` command)
|
||||
workflow-template-develop/ # @uncaged/workflow-template-develop — develop workflow template (includes roles)
|
||||
workflow-template-solve-issue/ # @uncaged/workflow-template-solve-issue — solve-issue workflow template (includes roles)
|
||||
workflow-agent-hermes/ # @uncaged/workflow-agent-hermes — Hermes agent adapter
|
||||
workflow-agent-cursor/ # @uncaged/workflow-agent-cursor — Cursor agent adapter
|
||||
workflow-agent-llm/ # @uncaged/workflow-agent-llm — LLM agent adapter
|
||||
workflow-util-agent/ # @uncaged/workflow-util-agent — agent utilities (buildAgentPrompt, spawnCli)
|
||||
```
|
||||
|
||||
Managed with **bun workspace** using the `workspace:*` protocol.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
bun install
|
||||
|
||||
# Build all packages
|
||||
bun run build
|
||||
|
||||
# Register a workflow bundle
|
||||
uncaged-workflow workflow add solve-issue dist/packages/workflow-template-solve-issue/solve-issue.esm.js
|
||||
|
||||
# Run a workflow
|
||||
uncaged-workflow run solve-issue --prompt "Fix bug #42"
|
||||
```
|
||||
|
||||
## CLI Usage
|
||||
|
||||
```bash
|
||||
uncaged-workflow # Print full command usage (exits with status 1)
|
||||
uncaged-workflow workflow list # List registered workflows
|
||||
uncaged-workflow run <name> # Start a workflow thread
|
||||
uncaged-workflow thread list # List all threads
|
||||
uncaged-workflow thread show <id> # Inspect a thread
|
||||
uncaged-workflow skill # Agent-consumable reference docs
|
||||
```
|
||||
|
||||
Run `uncaged-workflow` with no arguments to print usage, or `uncaged-workflow skill cli` for the full CLI skill reference.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
bun run check # Biome lint + format check
|
||||
bun run format # Auto-format with Biome
|
||||
bun test # Run tests
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
See [docs/architecture.md](docs/architecture.md) for the full design — three-phase engine loop, bundle contract, storage layout, and design decisions.
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.14/schema.json",
|
||||
"files": {
|
||||
"includes": ["**", "!**/dist", "!**/node_modules", "!packages/workflow/workflow"]
|
||||
"includes": ["**", "!**/dist", "!**/node_modules"]
|
||||
},
|
||||
"assist": { "actions": { "source": { "organizeImports": "on" } } },
|
||||
"formatter": {
|
||||
|
||||
@@ -17,8 +17,11 @@ A workflow engine that executes single-file ESM bundles. Each workflow is a self
|
||||
| `workflow-agent-cursor` | `@uncaged/workflow-agent-cursor` | Cursor CLI agent (extracts workspace from ctx) |
|
||||
| `workflow-agent-hermes` | `@uncaged/workflow-agent-hermes` | Hermes CLI agent |
|
||||
| `workflow-agent-llm` | `@uncaged/workflow-agent-llm` | OpenAI-compatible LLM agent |
|
||||
| `workflow-template-develop` | `@uncaged/workflow-template-develop` | Develop workflow template (roles in `src/roles/`) |
|
||||
| `workflow-template-solve-issue` | `@uncaged/workflow-template-solve-issue` | Solve-issue workflow template (roles in `src/roles/`) |
|
||||
| `workflow-role-planner` | `@uncaged/workflow-role-planner` | Pure data: phased planning prompt + schema |
|
||||
| `workflow-role-coder` | `@uncaged/workflow-role-coder` | Pure data: coding prompt + schema |
|
||||
| `workflow-role-reviewer` | `@uncaged/workflow-role-reviewer` | Pure data: code review prompt + schema |
|
||||
| `workflow-role-committer` | `@uncaged/workflow-role-committer` | Pure data: git commit prompt + schema |
|
||||
| `workflow-template-solve-issue` | `@uncaged/workflow-template-solve-issue` | Composes roles + moderator into a complete workflow |
|
||||
| `workflow-util-agent` | `@uncaged/workflow-util-agent` | `buildAgentPrompt` + `spawnCli` utilities |
|
||||
|
||||
Monorepo with **bun workspace**, `workspace:*` protocol.
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { createExtract, createWorkflow, END, type RoleDefinition } from "@uncaged/workflow";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
type Roles = {
|
||||
greeter: { greeting: string };
|
||||
};
|
||||
|
||||
const greeterMetaSchema = z.object({
|
||||
greeting: z.string(),
|
||||
});
|
||||
|
||||
export const descriptor = {
|
||||
description: "A simple hello world workflow",
|
||||
roles: {
|
||||
greeter: {
|
||||
description: "Generates a greeting",
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: { greeting: { type: "string" } },
|
||||
required: ["greeting"],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const greeter: RoleDefinition<Roles["greeter"]> = {
|
||||
description: "Generates a greeting",
|
||||
systemPrompt: "You greet the user briefly.",
|
||||
extractPrompt: "Extract the greeting string produced for the user.",
|
||||
schema: greeterMetaSchema,
|
||||
extractRefs: null,
|
||||
extractMode: "single",
|
||||
};
|
||||
|
||||
const extract = createExtract({
|
||||
baseUrl: "http://127.0.0.1:9",
|
||||
apiKey: "",
|
||||
model: "",
|
||||
});
|
||||
|
||||
export const run = createWorkflow<Roles>(
|
||||
{
|
||||
roles: { greeter },
|
||||
moderator(ctx) {
|
||||
return ctx.steps.length === 0 ? "greeter" : END;
|
||||
},
|
||||
},
|
||||
{
|
||||
agent: async (ctx) => `Hello, ${ctx.start.content}`,
|
||||
},
|
||||
extract,
|
||||
null,
|
||||
);
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-examples",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@uncaged/workflow": "workspace:*",
|
||||
"zod": "^4.0.0"
|
||||
}
|
||||
}
|
||||
+3
-2
@@ -2,10 +2,11 @@
|
||||
"name": "@uncaged/workflow-monorepo",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
"packages/*",
|
||||
"examples"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "bunx tsc --build",
|
||||
"build": "bun run --filter '*' build",
|
||||
"check": "bunx tsc --build && biome check .",
|
||||
"typecheck": "bunx tsc --build",
|
||||
"format": "biome format --write .",
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
# @uncaged/cli-workflow
|
||||
|
||||
Command-line interface for the Uncaged workflow engine (`uncaged-workflow`).
|
||||
|
||||
The CLI reads and writes the workflow registry, starts and inspects threads, manages CAS blobs, and prints agent-oriented reference docs via `skill`. It uses the same storage layout as `@uncaged/workflow` (default `~/.uncaged/workflow`).
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @uncaged/cli-workflow
|
||||
```
|
||||
|
||||
In this monorepo: `"@uncaged/cli-workflow": "workspace:*"`. Depends on `"@uncaged/workflow": "workspace:*"`.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
uncaged-workflow workflow list
|
||||
uncaged-workflow run <name> --prompt "Your task"
|
||||
uncaged-workflow thread show <id>
|
||||
uncaged-workflow skill
|
||||
```
|
||||
|
||||
Invoking the CLI with no command (or from this repo: `bun packages/cli-workflow/src/cli.ts`) prints:
|
||||
|
||||
```
|
||||
uncaged-workflow — workflow engine CLI
|
||||
|
||||
Workflow registry:
|
||||
workflow add <name> <file.esm.js> [--types <path>] Register a workflow bundle in the registry
|
||||
workflow list List all registered workflows
|
||||
workflow show <name> Show details of a registered workflow
|
||||
workflow rm <name> Remove a workflow from the registry
|
||||
workflow history <name> Show version history of a workflow
|
||||
workflow rollback <name> [hash] Rollback a workflow to a previous version
|
||||
|
||||
Thread execution:
|
||||
thread run <name> [--prompt <text>] [--max-rounds N] Start a new thread executing a workflow
|
||||
thread list [name] List threads, optionally filtered by workflow name
|
||||
thread show <id> Show thread details and state
|
||||
thread rm <id> Remove a thread
|
||||
thread fork <thread-id> [--from-role <role>] Fork a thread, optionally from a specific role
|
||||
thread ps List running threads
|
||||
thread kill <thread-id> Kill a running thread
|
||||
thread live <thread-id> | --latest [--debug] [--role <name>] Attach to a thread and stream output live
|
||||
thread pause <thread-id> Pause a running thread
|
||||
thread resume <thread-id> Resume a paused thread
|
||||
|
||||
Content-addressable storage:
|
||||
cas get <hash> Retrieve content by hash from CAS
|
||||
cas put <content> Store content in CAS, prints hash
|
||||
cas list List all hashes in CAS
|
||||
cas rm <hash> Remove a CAS entry by hash
|
||||
cas gc Garbage-collect unreferenced CAS entries
|
||||
|
||||
Development:
|
||||
init workspace <name> Initialize a new workflow workspace
|
||||
init template <name> Initialize a new workflow template
|
||||
|
||||
Shortcuts:
|
||||
run <name> [...] → thread run
|
||||
live <id> [...] → thread live
|
||||
|
||||
Reference:
|
||||
skill [topic] Agent-consumable docs (cli, develop, author)
|
||||
|
||||
Use <command> --help for subcommand details.
|
||||
|
||||
Environment variables:
|
||||
WORKFLOW_STORAGE_ROOT Override storage directory (default: ~/.uncaged/workflow)
|
||||
UNCAGED_WORKFLOW_STORAGE_ROOT Internal override (takes priority over WORKFLOW_STORAGE_ROOT)
|
||||
```
|
||||
|
||||
## API overview
|
||||
|
||||
This package is bin-only; programmatic use is via `@uncaged/workflow`. Entry: `src/cli.ts` → `runCli` in `src/cli-dispatch.js`.
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ParsedAddArgv } from "../src/commands/workflow/index.js";
|
||||
import type { ParsedAddArgv } from "../src/cmd-add.js";
|
||||
|
||||
export function addCliArgs(name: string, filePath: string): ParsedAddArgv {
|
||||
return { name, filePath, typesPath: null };
|
||||
|
||||
@@ -3,31 +3,22 @@ import { mkdir, mkdtemp, readFile, rm, unlink, writeFile } from "node:fs/promise
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { getGlobalCasDir } from "@uncaged/workflow-util";
|
||||
import { createContentMerkleNode, serializeMerkleNode } from "@uncaged/workflow-cas";
|
||||
import { getRegisteredWorkflow, readWorkflowRegistry } from "@uncaged/workflow-register";
|
||||
import { cmdCasGet, cmdCasList, cmdCasPut, cmdCasRm } from "../src/commands/cas/index.js";
|
||||
import {
|
||||
cmdAdd,
|
||||
cmdHistory,
|
||||
cmdList,
|
||||
cmdRemove,
|
||||
cmdRollback,
|
||||
cmdShow,
|
||||
formatListLines,
|
||||
} from "../src/commands/workflow/index.js";
|
||||
import { getGlobalCasDir, getRegisteredWorkflow, readWorkflowRegistry } from "@uncaged/workflow";
|
||||
import { cmdAdd } from "../src/cmd-add.js";
|
||||
import { cmdCasGet, cmdCasList, cmdCasPut, cmdCasRm } from "../src/cmd-cas.js";
|
||||
import { cmdHistory } from "../src/cmd-history.js";
|
||||
import { cmdList, formatListLines } from "../src/cmd-list.js";
|
||||
import { cmdRemove } from "../src/cmd-remove.js";
|
||||
import { cmdRollback } from "../src/cmd-rollback.js";
|
||||
import { cmdShow } from "../src/cmd-show.js";
|
||||
import { addCliArgs } from "./bundle-fixture.js";
|
||||
|
||||
const fixtureDescriptor = `export const descriptor = { description: "fixture", roles: {} };
|
||||
`;
|
||||
|
||||
const wfPutImport = `import { putContentMerkleNode } from "@uncaged/workflow-cas";
|
||||
const wfPutImport = `import { putContentMerkleNode } from "@uncaged/workflow";
|
||||
`;
|
||||
|
||||
function casStoredForm(raw: string): string {
|
||||
return serializeMerkleNode(createContentMerkleNode(raw));
|
||||
}
|
||||
|
||||
describe("cli workflow commands", () => {
|
||||
let prevEnv: string | undefined;
|
||||
let storageRoot: string;
|
||||
@@ -408,35 +399,33 @@ export const run = async function* (input, options) {
|
||||
});
|
||||
|
||||
test("cas put/get/list/rm use global cas dir (thread id not required for storage)", async () => {
|
||||
const raw = "phase doc";
|
||||
const stored = casStoredForm(raw);
|
||||
const put = await cmdCasPut(storageRoot, raw);
|
||||
const put = await cmdCasPut(storageRoot, "nonexistent-thread-id", "phase doc");
|
||||
expect(put.ok).toBe(true);
|
||||
if (!put.ok) {
|
||||
return;
|
||||
}
|
||||
const hash = put.value;
|
||||
const blobPath = join(getGlobalCasDir(storageRoot), `${hash}.txt`);
|
||||
expect(await readFile(blobPath, "utf8")).toBe(stored);
|
||||
expect(await readFile(blobPath, "utf8")).toBe("phase doc");
|
||||
|
||||
const got = await cmdCasGet(storageRoot, hash);
|
||||
const got = await cmdCasGet(storageRoot, "other-thread", hash);
|
||||
expect(got.ok).toBe(true);
|
||||
if (!got.ok) {
|
||||
return;
|
||||
}
|
||||
expect(got.value).toBe(stored);
|
||||
expect(got.value).toBe("phase doc");
|
||||
|
||||
const listed = await cmdCasList(storageRoot);
|
||||
const listed = await cmdCasList(storageRoot, "another-thread");
|
||||
expect(listed.ok).toBe(true);
|
||||
if (!listed.ok) {
|
||||
return;
|
||||
}
|
||||
expect(listed.value).toContain(hash);
|
||||
|
||||
const removed = await cmdCasRm(storageRoot, hash);
|
||||
const removed = await cmdCasRm(storageRoot, "rm-thread", hash);
|
||||
expect(removed.ok).toBe(true);
|
||||
|
||||
const missing = await cmdCasGet(storageRoot, hash);
|
||||
const missing = await cmdCasGet(storageRoot, "after-rm", hash);
|
||||
expect(missing.ok).toBe(false);
|
||||
});
|
||||
|
||||
|
||||
@@ -2,16 +2,15 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { createCasStore, getContentMerklePayload } from "@uncaged/workflow-cas";
|
||||
import { getGlobalCasDir } from "@uncaged/workflow-util";
|
||||
import { cmdFork, cmdRun } from "../src/commands/thread/index.js";
|
||||
import { cmdAdd } from "../src/commands/workflow/index.js";
|
||||
import { createCasStore, getContentMerklePayload, getGlobalCasDir } from "@uncaged/workflow";
|
||||
import { cmdAdd } from "../src/cmd-add.js";
|
||||
import { cmdFork } from "../src/cmd-fork.js";
|
||||
import { cmdRun } from "../src/cmd-run.js";
|
||||
import { pathExists } from "../src/fs-utils.js";
|
||||
import { addCliArgs } from "./bundle-fixture.js";
|
||||
import { ensureTestWorkflowRegistryConfig } from "./workflow-registry-fixture.js";
|
||||
|
||||
/** Three-role workflow that respects `input.steps` for fork/resume. */
|
||||
const threeRoleBundleSource = `import { putContentMerkleNode } from "@uncaged/workflow-cas";
|
||||
const threeRoleBundleSource = `import { putContentMerkleNode } from "@uncaged/workflow";
|
||||
|
||||
export const descriptor = {
|
||||
description: "fork-cli",
|
||||
@@ -79,7 +78,6 @@ describe("cli fork", () => {
|
||||
prevEnv = process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
|
||||
storageRoot = await mkdtemp(join(tmpdir(), "uncaged-wf-fork-"));
|
||||
process.env.UNCAGED_WORKFLOW_STORAGE_ROOT = storageRoot;
|
||||
await ensureTestWorkflowRegistryConfig(storageRoot);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
|
||||
@@ -4,10 +4,13 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createCasStore, putContentMerkleNode } from "@uncaged/workflow-cas";
|
||||
import { getGlobalCasDir } from "@uncaged/workflow-util";
|
||||
import { garbageCollectCas } from "@uncaged/workflow-execute";
|
||||
import { cmdThreadRemove } from "../src/commands/thread/index.js";
|
||||
import {
|
||||
createCasStore,
|
||||
garbageCollectCas,
|
||||
getGlobalCasDir,
|
||||
putContentMerkleNode,
|
||||
} from "@uncaged/workflow";
|
||||
import { cmdThreadRemove } from "../src/cmd-thread.js";
|
||||
import { pathExists } from "../src/fs-utils.js";
|
||||
|
||||
const cliEntryPath = fileURLToPath(new URL("../src/cli.ts", import.meta.url));
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { formatCliUsage, runCli } from "../src/cli-dispatch.js";
|
||||
import { formatSkillIndex, formatSkillTopic, getSkillTopics } from "../src/skill.js";
|
||||
import {
|
||||
formatSkillDoc,
|
||||
formatSkillIndex,
|
||||
formatSkillTopic,
|
||||
getSkillTopics,
|
||||
} from "../src/cmd-help.js";
|
||||
|
||||
const STORAGE_ROOT = "/tmp/help-test-storage";
|
||||
|
||||
describe("runCli usage", () => {
|
||||
test("no args prints usage and returns 1", async () => {
|
||||
describe("help command", () => {
|
||||
test("help returns 0", async () => {
|
||||
const code = await runCli(STORAGE_ROOT, ["help"]);
|
||||
expect(code).toBe(0);
|
||||
});
|
||||
|
||||
test("no args prints usage (not red) and returns 1", async () => {
|
||||
const code = await runCli(STORAGE_ROOT, []);
|
||||
expect(code).toBe(1);
|
||||
});
|
||||
@@ -32,6 +42,11 @@ describe("skill command", () => {
|
||||
expect(code).toBe(0);
|
||||
});
|
||||
|
||||
test("skill --help returns 0", async () => {
|
||||
const code = await runCli(STORAGE_ROOT, ["skill", "--help"]);
|
||||
expect(code).toBe(0);
|
||||
});
|
||||
|
||||
test("skill unknown returns 1", async () => {
|
||||
const code = await runCli(STORAGE_ROOT, ["skill", "unknown"]);
|
||||
expect(code).toBe(1);
|
||||
@@ -60,6 +75,13 @@ describe("--help flag on groups", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("legacy help --skill compat", () => {
|
||||
test("help --skill still works (lists topics)", async () => {
|
||||
const code = await runCli(STORAGE_ROOT, ["help", "--skill"]);
|
||||
expect(code).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSkillTopics", () => {
|
||||
test("returns all topics", () => {
|
||||
const topics = getSkillTopics();
|
||||
@@ -111,13 +133,8 @@ describe("formatCliUsage", () => {
|
||||
});
|
||||
});
|
||||
|
||||
const cliSkillDoc = formatSkillTopic("cli");
|
||||
if (cliSkillDoc === null) {
|
||||
throw new Error("BUG: cli skill topic missing");
|
||||
}
|
||||
|
||||
describe("formatSkillTopic('cli')", () => {
|
||||
const doc = cliSkillDoc;
|
||||
describe("formatSkillTopic('cli') — legacy formatSkillDoc", () => {
|
||||
const doc = formatSkillDoc();
|
||||
|
||||
test("contains title", () => {
|
||||
expect(doc).toContain("# uncaged-workflow CLI Reference");
|
||||
|
||||
@@ -4,7 +4,7 @@ import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { runCli } from "../src/cli-dispatch.js";
|
||||
import { cmdInitTemplate, cmdInitWorkspace } from "../src/commands/init/index.js";
|
||||
import { cmdInitTemplate, cmdInitWorkspace } from "../src/cmd-init.js";
|
||||
import { pathExists } from "../src/fs-utils.js";
|
||||
|
||||
describe("init template", () => {
|
||||
@@ -50,7 +50,7 @@ describe("init template", () => {
|
||||
dependencies: Record<string, string>;
|
||||
};
|
||||
expect(pkg.type).toBe("module");
|
||||
expect(pkg.dependencies["@uncaged/workflow-runtime"]).toBeDefined();
|
||||
expect(pkg.dependencies["@uncaged/workflow"]).toBeDefined();
|
||||
expect(pkg.dependencies.zod).toBeDefined();
|
||||
expect(pkg.name).toContain("review-pr");
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { formatCliUsage, runCli } from "../src/cli-dispatch.js";
|
||||
import { cmdInitWorkspace } from "../src/commands/init/index.js";
|
||||
import { cmdInitWorkspace } from "../src/cmd-init.js";
|
||||
import { pathExists } from "../src/fs-utils.js";
|
||||
|
||||
describe("init workspace", () => {
|
||||
@@ -46,7 +46,7 @@ describe("init workspace", () => {
|
||||
dependencies: Record<string, string>;
|
||||
};
|
||||
expect(wfPkg.type).toBe("module");
|
||||
expect(wfPkg.dependencies["@uncaged/workflow-runtime"]).toBeDefined();
|
||||
expect(wfPkg.dependencies["@uncaged/workflow"]).toBeDefined();
|
||||
expect(wfPkg.dependencies.zod).toBeDefined();
|
||||
|
||||
const tsconfig = JSON.parse(await readFile(join(root, "tsconfig.json"), "utf8")) as {
|
||||
|
||||
@@ -5,8 +5,7 @@ import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { createCasStore, putContentMerkleNode } from "@uncaged/workflow-cas";
|
||||
import { getGlobalCasDir } from "@uncaged/workflow-util";
|
||||
import { createCasStore, getGlobalCasDir, putContentMerkleNode } from "@uncaged/workflow";
|
||||
|
||||
import {
|
||||
formatLiveDebugLine,
|
||||
@@ -14,7 +13,7 @@ import {
|
||||
LIVE_CONTENT_MAX_LINES,
|
||||
type LiveRoleRow,
|
||||
renderLiveRoleStepLines,
|
||||
} from "../src/commands/thread/index.js";
|
||||
} from "../src/cmd-live.js";
|
||||
import { parseLiveArgv } from "../src/live-argv.js";
|
||||
|
||||
const cliEntryPath = fileURLToPath(new URL("../src/cli.ts", import.meta.url));
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { createContentMerkleNode, serializeMerkleNode } from "@uncaged/workflow-cas";
|
||||
|
||||
import { createApp } from "../src/commands/serve/app.js";
|
||||
|
||||
function casStoredForm(raw: string): string {
|
||||
return serializeMerkleNode(createContentMerkleNode(raw));
|
||||
}
|
||||
|
||||
function buildApp(storageRoot: string) {
|
||||
const app = createApp(storageRoot);
|
||||
return {
|
||||
fetch: (path: string, init?: RequestInit) =>
|
||||
app.fetch(new Request(`http://localhost${path}`, init)),
|
||||
};
|
||||
}
|
||||
|
||||
describe("serve /healthz", () => {
|
||||
test("returns ok", async () => {
|
||||
const { fetch } = buildApp("/tmp/uncaged-serve-test-nonexistent");
|
||||
const res = await fetch("/healthz");
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { ok: boolean };
|
||||
expect(body.ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("serve /api/workflows", () => {
|
||||
test("returns empty list for missing storage", async () => {
|
||||
const { fetch } = buildApp("/tmp/uncaged-serve-test-nonexistent");
|
||||
const res = await fetch("/api/workflows");
|
||||
// Registry file won't exist, should return error
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("serve /api/threads", () => {
|
||||
test("returns empty list for missing storage", async () => {
|
||||
const { fetch } = buildApp("/tmp/uncaged-serve-test-nonexistent");
|
||||
const res = await fetch("/api/threads");
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { threads: unknown[] };
|
||||
expect(body.threads).toEqual([]);
|
||||
});
|
||||
|
||||
test("returns 404 for missing thread", async () => {
|
||||
const { fetch } = buildApp("/tmp/uncaged-serve-test-nonexistent");
|
||||
const res = await fetch("/api/threads/nonexistent-id");
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("serve /api/threads/running", () => {
|
||||
test("returns empty list for missing storage", async () => {
|
||||
const { fetch } = buildApp("/tmp/uncaged-serve-test-nonexistent");
|
||||
const res = await fetch("/api/threads/running");
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { threads: unknown[] };
|
||||
expect(body.threads).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("serve /api/cas", () => {
|
||||
test("returns empty list for missing storage", async () => {
|
||||
const { fetch } = buildApp("/tmp/uncaged-serve-test-nonexistent");
|
||||
const res = await fetch("/api/cas");
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { hashes: unknown[] };
|
||||
expect(body.hashes).toEqual([]);
|
||||
});
|
||||
|
||||
test("returns 404 for missing hash", async () => {
|
||||
const { fetch } = buildApp("/tmp/uncaged-serve-test-nonexistent");
|
||||
const res = await fetch("/api/cas/nonexistent-hash");
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("serve error handling", () => {
|
||||
test("POST /api/threads with invalid JSON body → 400", async () => {
|
||||
const { fetch } = buildApp("/tmp/uncaged-serve-test-nonexistent");
|
||||
const res = await fetch("/api/threads", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: "not json",
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
const body = (await res.json()) as { error: string };
|
||||
expect(body.error).toBe("invalid JSON body");
|
||||
});
|
||||
|
||||
test("POST /api/cas with invalid JSON body → 400", async () => {
|
||||
const { fetch } = buildApp("/tmp/uncaged-serve-test-nonexistent");
|
||||
const res = await fetch("/api/cas", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: "not json",
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
const body = (await res.json()) as { error: string };
|
||||
expect(body.error).toBe("invalid JSON body");
|
||||
});
|
||||
|
||||
test("POST /api/threads with missing required fields → 400", async () => {
|
||||
const { fetch } = buildApp("/tmp/uncaged-serve-test-nonexistent");
|
||||
const res = await fetch("/api/threads", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ foo: "bar" }),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
const body = (await res.json()) as { error: string };
|
||||
expect(body.error).toContain("required");
|
||||
});
|
||||
|
||||
test("global error handler returns 500 with JSON", async () => {
|
||||
const app = createApp("/tmp/uncaged-serve-test-nonexistent");
|
||||
app.get("/test-error", () => {
|
||||
throw new Error("boom");
|
||||
});
|
||||
const res = await app.fetch(new Request("http://localhost/test-error"));
|
||||
expect(res.status).toBe(500);
|
||||
const body = (await res.json()) as { error: string };
|
||||
expect(body.error).toBe("Internal server error");
|
||||
});
|
||||
});
|
||||
|
||||
describe("serve security", () => {
|
||||
test("CORS headers present on responses", async () => {
|
||||
const app = createApp("/tmp/uncaged-serve-test-nonexistent");
|
||||
const res2 = await app.fetch(
|
||||
new Request("http://localhost/healthz", {
|
||||
headers: { Origin: "http://localhost:5173" },
|
||||
}),
|
||||
);
|
||||
expect(res2.headers.get("Access-Control-Allow-Origin")).toBe("http://localhost:5173");
|
||||
});
|
||||
|
||||
test("POST with body > 1MB → 413", async () => {
|
||||
const { fetch } = buildApp("/tmp/uncaged-serve-test-nonexistent");
|
||||
const largeBody = "x".repeat(1_048_577);
|
||||
const res = await fetch("/api/cas", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Content-Length": String(largeBody.length),
|
||||
},
|
||||
body: largeBody,
|
||||
});
|
||||
expect(res.status).toBe(413);
|
||||
const body = (await res.json()) as { error: string };
|
||||
expect(body.error).toBe("Payload too large");
|
||||
});
|
||||
});
|
||||
|
||||
describe("serve CAS round-trip", () => {
|
||||
const tmpDir = `/tmp/uncaged-serve-cas-test-${Date.now()}`;
|
||||
|
||||
test("put then get", async () => {
|
||||
const { fetch } = buildApp(tmpDir);
|
||||
|
||||
const putRes = await fetch("/api/cas", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ content: "hello world" }),
|
||||
});
|
||||
expect(putRes.status).toBe(201);
|
||||
const putBody = (await putRes.json()) as { hash: string };
|
||||
expect(typeof putBody.hash).toBe("string");
|
||||
|
||||
const getRes = await fetch(`/api/cas/${putBody.hash}`);
|
||||
expect(getRes.status).toBe(200);
|
||||
const getBody = (await getRes.json()) as { content: string };
|
||||
expect(getBody.content).toBe(casStoredForm("hello world"));
|
||||
|
||||
// cleanup
|
||||
const delRes = await fetch(`/api/cas/${putBody.hash}`, { method: "DELETE" });
|
||||
expect(delRes.status).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { getDefaultWorkflowStorageRoot } from "@uncaged/workflow-util";
|
||||
import { getDefaultWorkflowStorageRoot } from "@uncaged/workflow";
|
||||
import { resolveWorkflowStorageRoot } from "../src/storage-env.js";
|
||||
|
||||
describe("resolveWorkflowStorageRoot", () => {
|
||||
|
||||
@@ -4,24 +4,20 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { getGlobalCasDir } from "@uncaged/workflow-util";
|
||||
import { cmdCasPut } from "../src/commands/cas/index.js";
|
||||
import {
|
||||
cmdKill,
|
||||
cmdPause,
|
||||
cmdPs,
|
||||
cmdResume,
|
||||
cmdRun,
|
||||
cmdThreadRemove,
|
||||
cmdThreadShow,
|
||||
cmdThreads,
|
||||
} from "../src/commands/thread/index.js";
|
||||
import { cmdAdd } from "../src/commands/workflow/index.js";
|
||||
import { getGlobalCasDir } from "@uncaged/workflow";
|
||||
import { cmdAdd } from "../src/cmd-add.js";
|
||||
import { cmdCasPut } from "../src/cmd-cas.js";
|
||||
import { cmdKill } from "../src/cmd-kill.js";
|
||||
import { cmdPause } from "../src/cmd-pause.js";
|
||||
import { cmdPs } from "../src/cmd-ps.js";
|
||||
import { cmdResume } from "../src/cmd-resume.js";
|
||||
import { cmdRun } from "../src/cmd-run.js";
|
||||
import { cmdThreadRemove, cmdThreadShow } from "../src/cmd-thread.js";
|
||||
import { cmdThreads } from "../src/cmd-threads.js";
|
||||
import { pathExists, readTextFileIfExists } from "../src/fs-utils.js";
|
||||
import { addCliArgs } from "./bundle-fixture.js";
|
||||
import { ensureTestWorkflowRegistryConfig } from "./workflow-registry-fixture.js";
|
||||
|
||||
const wfPutImport = `import { putContentMerkleNode } from "@uncaged/workflow-cas";
|
||||
const wfPutImport = `import { putContentMerkleNode } from "@uncaged/workflow";
|
||||
`;
|
||||
|
||||
const threadFixtureDescriptor = `export const descriptor = {
|
||||
@@ -143,7 +139,6 @@ describe("cli thread commands", () => {
|
||||
prevEnv = process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
|
||||
storageRoot = await mkdtemp(join(tmpdir(), "uncaged-wf-thread-"));
|
||||
process.env.UNCAGED_WORKFLOW_STORAGE_ROOT = storageRoot;
|
||||
await ensureTestWorkflowRegistryConfig(storageRoot);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -238,7 +233,7 @@ describe("cli thread commands", () => {
|
||||
const runningPath = join(dirname(dataPath), `${threadId}.running`);
|
||||
await waitUntilRunningFileAbsent(runningPath, 120);
|
||||
|
||||
const put = await cmdCasPut(storageRoot, "keep-after-thread-rm");
|
||||
const put = await cmdCasPut(storageRoot, threadId, "keep-after-thread-rm");
|
||||
expect(put.ok).toBe(true);
|
||||
if (!put.ok) {
|
||||
return;
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
/** Minimal valid global config so {@link executeThread} can resolve the extract scene (CLI integration tests). */
|
||||
export const TEST_WORKFLOW_REGISTRY_YAML = `config:
|
||||
maxDepth: 3
|
||||
providers:
|
||||
stub:
|
||||
baseUrl: http://127.0.0.1:9
|
||||
apiKey: test
|
||||
models:
|
||||
default: stub/m
|
||||
workflows: {}
|
||||
`;
|
||||
|
||||
export async function ensureTestWorkflowRegistryConfig(storageRoot: string): Promise<void> {
|
||||
await writeFile(join(storageRoot, "workflow.yaml"), TEST_WORKFLOW_REGISTRY_YAML, "utf8");
|
||||
}
|
||||
@@ -1,21 +1,16 @@
|
||||
{
|
||||
"name": "@uncaged/cli-workflow",
|
||||
"version": "0.2.0",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"uncaged-workflow": "src/cli.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/workflow-protocol": "workspace:*",
|
||||
"@uncaged/workflow-util": "workspace:*",
|
||||
"@uncaged/workflow-cas": "workspace:*",
|
||||
"@uncaged/workflow-execute": "workspace:*",
|
||||
"@uncaged/workflow-register": "workspace:*",
|
||||
"@uncaged/workflow-runtime": "workspace:*",
|
||||
"hono": "^4.12.18",
|
||||
"@uncaged/workflow": "workspace:*",
|
||||
"yaml": "^2.8.4"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "echo 'TODO'",
|
||||
"test": "bun test"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
import { err, ok, type Result } from "@uncaged/workflow";
|
||||
|
||||
import { pathExists } from "./fs-utils.js";
|
||||
async function pathExists(path: string): Promise<boolean> {
|
||||
try {
|
||||
await stat(path);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export type BundleFileSource = { kind: "text"; text: string } | { kind: "path"; path: string };
|
||||
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
export function shouldUseColor(): boolean {
|
||||
return process.stdout.isTTY === true && process.env.NO_COLOR === undefined;
|
||||
}
|
||||
|
||||
export function highlightLiveRole(name: string): string {
|
||||
if (!shouldUseColor()) {
|
||||
return name;
|
||||
}
|
||||
return `\x1b[1m\x1b[36m${name}\x1b[0m`;
|
||||
}
|
||||
|
||||
export function dimGreyLine(line: string): string {
|
||||
if (!shouldUseColor()) {
|
||||
return line;
|
||||
}
|
||||
return `\x1b[2m\x1b[90m${line}\x1b[0m`;
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
export type DispatchFn = (storageRoot: string, argv: string[]) => Promise<number>;
|
||||
|
||||
export type CommandEntry = {
|
||||
handler: DispatchFn;
|
||||
args: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export type CommandGroup = {
|
||||
name: string;
|
||||
commands: ReadonlyArray<{ name: string; args: string; description: string }>;
|
||||
};
|
||||
|
||||
export type DispatchGroupFn = (
|
||||
tableName: string,
|
||||
table: Record<string, CommandEntry>,
|
||||
storageRoot: string,
|
||||
argv: string[],
|
||||
) => Promise<number> | null;
|
||||
@@ -1,16 +1,613 @@
|
||||
import type { CommandEntry, DispatchFn } from "./cli-command-types.js";
|
||||
import { printCliError, printCliLine } from "./cli-output.js";
|
||||
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 { createThreadDispatcher, dispatchLive, dispatchRun } from "./commands/thread/index.js";
|
||||
import { createWorkflowDispatcher } from "./commands/workflow/index.js";
|
||||
import { formatSkillIndex, formatSkillTopic, getSkillTopics } from "./skill.js";
|
||||
import { printCliError, printCliLine, printCliWarn } from "./cli-output.js";
|
||||
import { cmdAdd, formatAddSuccess, parseAddArgv } from "./cmd-add.js";
|
||||
import { cmdCasGet, cmdCasList, cmdCasPut, cmdCasRm } from "./cmd-cas.js";
|
||||
import { cmdFork, parseForkArgv } from "./cmd-fork.js";
|
||||
import { cmdGc } from "./cmd-gc.js";
|
||||
import {
|
||||
formatSkillDoc,
|
||||
formatSkillIndex,
|
||||
formatSkillTopic,
|
||||
getSkillTopics,
|
||||
} from "./cmd-help.js";
|
||||
import { cmdHistory } from "./cmd-history.js";
|
||||
import { cmdInitTemplate, cmdInitWorkspace } from "./cmd-init.js";
|
||||
import { cmdKill } from "./cmd-kill.js";
|
||||
import { cmdList, formatListLines } from "./cmd-list.js";
|
||||
import { cmdLive } from "./cmd-live.js";
|
||||
import { cmdPause } from "./cmd-pause.js";
|
||||
import { cmdPs } from "./cmd-ps.js";
|
||||
import { cmdRemove } from "./cmd-remove.js";
|
||||
import { cmdResume } from "./cmd-resume.js";
|
||||
import { cmdRollback } from "./cmd-rollback.js";
|
||||
import { cmdRun } from "./cmd-run.js";
|
||||
import { cmdShow, formatShowYaml } from "./cmd-show.js";
|
||||
import { cmdThreadRemove, cmdThreadShow } from "./cmd-thread.js";
|
||||
import { cmdThreads } from "./cmd-threads.js";
|
||||
import { parseLiveArgv } from "./live-argv.js";
|
||||
import { parseRunArgv } from "./run-argv.js";
|
||||
|
||||
export type { CommandEntry, CommandGroup, DispatchFn } from "./cli-command-types.js";
|
||||
export { getCommandRegistry } from "./cli-registry.js";
|
||||
type DispatchFn = (storageRoot: string, argv: string[]) => Promise<number>;
|
||||
|
||||
type CommandEntry = {
|
||||
handler: DispatchFn;
|
||||
args: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
type CommandGroup = {
|
||||
name: string;
|
||||
commands: ReadonlyArray<{ name: string; args: string; description: string }>;
|
||||
};
|
||||
|
||||
// ── Individual dispatch functions ──────────────────────────────────────
|
||||
|
||||
async function dispatchInitWorkspace(_storageRoot: string, argv: string[]): Promise<number> {
|
||||
const name = argv[0];
|
||||
if (name === undefined || argv.length > 1) {
|
||||
printCliError(`${formatCliUsage()}\n\nerror: init workspace requires <name>`);
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdInitWorkspace(process.cwd(), name);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
printCliLine(`initialized workflow workspace at ${result.value.rootPath}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function dispatchInitTemplate(_storageRoot: string, argv: string[]): Promise<number> {
|
||||
const name = argv[0];
|
||||
if (name === undefined || argv.length > 1) {
|
||||
printCliError(`${formatCliUsage()}\n\nerror: init template requires <name>`);
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdInitTemplate(process.cwd(), name);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
printCliLine(`initialized template at ${result.value.templatePath}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function dispatchAdd(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const parsed = parseAddArgv(argv);
|
||||
if (!parsed.ok) {
|
||||
printCliError(`${formatCliUsage()}\n\nerror: ${parsed.error}`);
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdAdd(storageRoot, parsed.value);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
for (const w of result.value.warnings) {
|
||||
printCliWarn(w);
|
||||
}
|
||||
printCliLine(formatAddSuccess(parsed.value.name, parsed.value.filePath, result.value.hash));
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function dispatchList(storageRoot: string, argv: string[]): Promise<number> {
|
||||
if (argv.length > 0) {
|
||||
printCliError(`${formatCliUsage()}\n\nerror: list takes no arguments`);
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdList(storageRoot);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
for (const line of formatListLines(result.value)) {
|
||||
printCliLine(line);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function dispatchShow(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const name = argv[0];
|
||||
if (name === undefined || argv.length > 1) {
|
||||
printCliError(`${formatCliUsage()}\n\nerror: show requires <name>`);
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdShow(storageRoot, name);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
printCliLine(formatShowYaml(name, result.value));
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function dispatchRemove(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const name = argv[0];
|
||||
if (name === undefined || argv.length > 1) {
|
||||
printCliError(`${formatCliUsage()}\n\nerror: remove requires <name>`);
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdRemove(storageRoot, name);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
printCliLine(`removed workflow "${name}" from registry`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function dispatchRun(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const parsed = parseRunArgv(argv);
|
||||
if (!parsed.ok) {
|
||||
printCliError(`${formatCliUsage()}\n\nerror: ${parsed.error}`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
const result = await cmdRun(
|
||||
storageRoot,
|
||||
parsed.value.name,
|
||||
parsed.value.prompt,
|
||||
parsed.value.maxRounds,
|
||||
);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
|
||||
printCliLine(result.value.threadId);
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function dispatchPs(storageRoot: string, argv: string[]): Promise<number> {
|
||||
if (argv.length > 0) {
|
||||
printCliError(`${formatCliUsage()}\n\nerror: ps takes no arguments`);
|
||||
return 1;
|
||||
}
|
||||
for (const line of await cmdPs(storageRoot)) {
|
||||
printCliLine(line);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function dispatchKill(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const threadId = argv[0];
|
||||
if (threadId === undefined || argv.length > 1) {
|
||||
printCliError(`${formatCliUsage()}\n\nerror: kill requires <thread-id>`);
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdKill(storageRoot, threadId);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
printCliLine(`kill sent for thread ${threadId}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function dispatchLive(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const parsed = parseLiveArgv(argv);
|
||||
if (!parsed.ok) {
|
||||
printCliError(`${formatCliUsage()}\n\nerror: ${parsed.error}`);
|
||||
return 1;
|
||||
}
|
||||
return cmdLive(storageRoot, parsed.value);
|
||||
}
|
||||
|
||||
async function dispatchHistory(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const name = argv[0];
|
||||
if (name === undefined || argv.length > 1) {
|
||||
printCliError(`${formatCliUsage()}\n\nerror: history requires <name>`);
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdHistory(storageRoot, name);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
for (const line of result.value) {
|
||||
printCliLine(line);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function dispatchRollback(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const name = argv[0];
|
||||
if (name === undefined || argv.length > 2) {
|
||||
printCliError(`${formatCliUsage()}\n\nerror: rollback requires <name> [hash]`);
|
||||
return 1;
|
||||
}
|
||||
const hashArg = argv[1];
|
||||
const result = await cmdRollback(storageRoot, name, hashArg === undefined ? null : hashArg);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
printCliLine(`rolled back workflow "${name}"`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function dispatchPause(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const threadId = argv[0];
|
||||
if (threadId === undefined || argv.length > 1) {
|
||||
printCliError(`${formatCliUsage()}\n\nerror: pause requires <thread-id>`);
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdPause(storageRoot, threadId);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
printCliLine(`pause sent for thread ${threadId}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function dispatchResume(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const threadId = argv[0];
|
||||
if (threadId === undefined || argv.length > 1) {
|
||||
printCliError(`${formatCliUsage()}\n\nerror: resume requires <thread-id>`);
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdResume(storageRoot, threadId);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
printCliLine(`resume sent for thread ${threadId}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function dispatchThreadList(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const result = await cmdThreads(storageRoot, argv);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
for (const line of result.value) {
|
||||
printCliLine(line);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function dispatchThreadShow(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const id = argv[0];
|
||||
if (id === undefined || argv.length > 1) {
|
||||
printCliError(`${formatCliUsage()}\n\nerror: thread show requires <id>`);
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdThreadShow(storageRoot, id);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
printCliLine(result.value);
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function dispatchThreadRm(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const id = argv[0];
|
||||
if (id === undefined || argv.length > 1) {
|
||||
printCliError(`${formatCliUsage()}\n\nerror: thread rm requires <id>`);
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdThreadRemove(storageRoot, id);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
printCliLine(`removed thread ${id}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function dispatchGc(storageRoot: string, argv: string[]): Promise<number> {
|
||||
if (argv.length > 0) {
|
||||
printCliError(`${formatCliUsage()}\n\nerror: gc takes no arguments`);
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdGc(storageRoot);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
const stats = result.value;
|
||||
printCliLine(
|
||||
`scanned ${stats.scannedThreads} threads, ${stats.activeRefs} active refs, deleted ${stats.deletedEntries} entries`,
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function dispatchFork(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const parsed = parseForkArgv(argv);
|
||||
if (!parsed.ok) {
|
||||
printCliError(`${formatCliUsage()}\n\nerror: ${parsed.error}`);
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdFork(storageRoot, parsed.value.threadId, parsed.value.fromRole);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
printCliLine(result.value.threadId);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ── CAS subcommand table ───────────────────────────────────────────────
|
||||
|
||||
async function dispatchCasGet(storageRoot: string, rest: string[]): Promise<number> {
|
||||
const threadId = rest[0];
|
||||
const hash = rest[1];
|
||||
if (threadId === undefined || hash === undefined || rest.length > 2) {
|
||||
printCliError(`${formatCliUsage()}\n\nerror: cas get requires <thread-id> <hash>`);
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdCasGet(storageRoot, threadId, hash);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
printCliLine(result.value);
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function dispatchCasPut(storageRoot: string, rest: string[]): Promise<number> {
|
||||
const threadId = rest[0];
|
||||
const content = rest[1];
|
||||
if (threadId === undefined || content === undefined || rest.length > 2) {
|
||||
printCliError(`${formatCliUsage()}\n\nerror: cas put requires <thread-id> <content>`);
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdCasPut(storageRoot, threadId, content);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
printCliLine(result.value);
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function dispatchCasList(storageRoot: string, rest: string[]): Promise<number> {
|
||||
const threadId = rest[0];
|
||||
if (threadId === undefined || rest.length > 1) {
|
||||
printCliError(`${formatCliUsage()}\n\nerror: cas list requires <thread-id>`);
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdCasList(storageRoot, threadId);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
for (const hash of result.value) {
|
||||
printCliLine(hash);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function dispatchCasRm(storageRoot: string, rest: string[]): Promise<number> {
|
||||
const threadId = rest[0];
|
||||
const hash = rest[1];
|
||||
if (threadId === undefined || hash === undefined || rest.length > 2) {
|
||||
printCliError(`${formatCliUsage()}\n\nerror: cas rm requires <thread-id> <hash>`);
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdCasRm(storageRoot, threadId, hash);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
printCliLine(`removed cas entry ${hash}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ── Subcommand tables with metadata ────────────────────────────────────
|
||||
|
||||
const WORKFLOW_SUBCOMMAND_TABLE: Record<string, CommandEntry> = {
|
||||
add: {
|
||||
handler: dispatchAdd,
|
||||
args: "<name> <file.esm.js> [--types <path>]",
|
||||
description: "Register a workflow bundle in the registry",
|
||||
},
|
||||
list: { handler: dispatchList, args: "", description: "List all registered workflows" },
|
||||
show: {
|
||||
handler: dispatchShow,
|
||||
args: "<name>",
|
||||
description: "Show details of a registered workflow",
|
||||
},
|
||||
rm: {
|
||||
handler: dispatchRemove,
|
||||
args: "<name>",
|
||||
description: "Remove a workflow from the registry",
|
||||
},
|
||||
history: {
|
||||
handler: dispatchHistory,
|
||||
args: "<name>",
|
||||
description: "Show version history of a workflow",
|
||||
},
|
||||
rollback: {
|
||||
handler: dispatchRollback,
|
||||
args: "<name> [hash]",
|
||||
description: "Rollback a workflow to a previous version",
|
||||
},
|
||||
};
|
||||
|
||||
const THREAD_SUBCOMMAND_TABLE: Record<string, CommandEntry> = {
|
||||
run: {
|
||||
handler: dispatchRun,
|
||||
args: "<name> [--prompt <text>] [--max-rounds N]",
|
||||
description: "Start a new thread executing a workflow",
|
||||
},
|
||||
list: {
|
||||
handler: dispatchThreadList,
|
||||
args: "[name]",
|
||||
description: "List threads, optionally filtered by workflow name",
|
||||
},
|
||||
show: { handler: dispatchThreadShow, args: "<id>", description: "Show thread details and state" },
|
||||
rm: { handler: dispatchThreadRm, args: "<id>", description: "Remove a thread" },
|
||||
fork: {
|
||||
handler: dispatchFork,
|
||||
args: "<thread-id> [--from-role <role>]",
|
||||
description: "Fork a thread, optionally from a specific role",
|
||||
},
|
||||
ps: { handler: dispatchPs, args: "", description: "List running threads" },
|
||||
kill: { handler: dispatchKill, args: "<thread-id>", description: "Kill a running thread" },
|
||||
live: {
|
||||
handler: dispatchLive,
|
||||
args: "<thread-id> | --latest [--debug] [--role <name>]",
|
||||
description: "Attach to a thread and stream output live",
|
||||
},
|
||||
pause: { handler: dispatchPause, args: "<thread-id>", description: "Pause a running thread" },
|
||||
resume: { handler: dispatchResume, args: "<thread-id>", description: "Resume a paused thread" },
|
||||
};
|
||||
|
||||
const CAS_SUBCOMMAND_TABLE: Record<string, CommandEntry> = {
|
||||
get: {
|
||||
handler: dispatchCasGet,
|
||||
args: "<thread-id> <hash>",
|
||||
description: "Retrieve content by hash from a thread's CAS",
|
||||
},
|
||||
put: {
|
||||
handler: dispatchCasPut,
|
||||
args: "<thread-id> <content>",
|
||||
description: "Store content in a thread's CAS, returns hash",
|
||||
},
|
||||
list: {
|
||||
handler: dispatchCasList,
|
||||
args: "<thread-id>",
|
||||
description: "List all CAS entries for a thread",
|
||||
},
|
||||
rm: { handler: dispatchCasRm, args: "<thread-id> <hash>", description: "Remove a CAS entry" },
|
||||
gc: { handler: dispatchGc, args: "", description: "Garbage-collect unreferenced CAS entries" },
|
||||
};
|
||||
|
||||
const INIT_SUBCOMMAND_TABLE: Record<string, CommandEntry> = {
|
||||
workspace: {
|
||||
handler: dispatchInitWorkspace,
|
||||
args: "<name>",
|
||||
description: "Initialize a new workflow workspace",
|
||||
},
|
||||
template: {
|
||||
handler: dispatchInitTemplate,
|
||||
args: "<name>",
|
||||
description: "Initialize a new workflow template",
|
||||
},
|
||||
};
|
||||
|
||||
// ── Command registry ───────────────────────────────────────────────────
|
||||
|
||||
export function getCommandRegistry(): ReadonlyArray<CommandGroup> {
|
||||
return [
|
||||
{
|
||||
name: "workflow",
|
||||
commands: Object.entries(WORKFLOW_SUBCOMMAND_TABLE).map(([name, e]) => ({
|
||||
name,
|
||||
args: e.args,
|
||||
description: e.description,
|
||||
})),
|
||||
},
|
||||
{
|
||||
name: "thread",
|
||||
commands: Object.entries(THREAD_SUBCOMMAND_TABLE).map(([name, e]) => ({
|
||||
name,
|
||||
args: e.args,
|
||||
description: e.description,
|
||||
})),
|
||||
},
|
||||
{
|
||||
name: "cas",
|
||||
commands: Object.entries(CAS_SUBCOMMAND_TABLE).map(([name, e]) => ({
|
||||
name,
|
||||
args: e.args,
|
||||
description: e.description,
|
||||
})),
|
||||
},
|
||||
{
|
||||
name: "init",
|
||||
commands: Object.entries(INIT_SUBCOMMAND_TABLE).map(([name, e]) => ({
|
||||
name,
|
||||
args: e.args,
|
||||
description: e.description,
|
||||
})),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// ── Auto-generated CLI usage ───────────────────────────────────────────
|
||||
|
||||
const USAGE_SECTION_BY_GROUP: Record<string, string> = {
|
||||
workflow: "Workflow registry:",
|
||||
thread: "Thread execution:",
|
||||
cas: "Content-addressable storage:",
|
||||
init: "Development:",
|
||||
};
|
||||
|
||||
function formatUsageCommandLines(
|
||||
rows: ReadonlyArray<{ prefix: string; description: string }>,
|
||||
): string[] {
|
||||
const maxPrefix = rows.reduce((max, row) => Math.max(max, row.prefix.length), 0);
|
||||
const gap = 2;
|
||||
return rows.map((row) => {
|
||||
const pad = " ".repeat(maxPrefix - row.prefix.length + gap);
|
||||
return ` ${row.prefix}${pad}${row.description}`;
|
||||
});
|
||||
}
|
||||
|
||||
export function formatCliUsage(): string {
|
||||
const groups = getCommandRegistry();
|
||||
const lines: string[] = ["uncaged-workflow — workflow engine CLI", ""];
|
||||
|
||||
for (const group of groups) {
|
||||
const sectionTitle = USAGE_SECTION_BY_GROUP[group.name];
|
||||
if (sectionTitle === undefined) {
|
||||
throw new Error(`BUG: missing usage section title for group "${group.name}"`);
|
||||
}
|
||||
lines.push(sectionTitle);
|
||||
const rows = group.commands.map((cmd) => {
|
||||
const args = cmd.args ? ` ${cmd.args}` : "";
|
||||
return {
|
||||
prefix: `${group.name} ${cmd.name}${args}`,
|
||||
description: cmd.description,
|
||||
};
|
||||
});
|
||||
lines.push(...formatUsageCommandLines(rows));
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
lines.push("Shortcuts:");
|
||||
lines.push(
|
||||
...formatUsageCommandLines([
|
||||
{ prefix: "run <name> [...]", description: "→ thread run" },
|
||||
{ prefix: "live <id> [...]", description: "→ thread live" },
|
||||
]),
|
||||
);
|
||||
lines.push("");
|
||||
|
||||
lines.push("Reference:");
|
||||
const skillTopicNames = getSkillTopics()
|
||||
.map((t) => t.name)
|
||||
.join(", ");
|
||||
lines.push(
|
||||
...formatUsageCommandLines([
|
||||
{
|
||||
prefix: "skill [topic]",
|
||||
description: `Agent-consumable docs (${skillTopicNames})`,
|
||||
},
|
||||
]),
|
||||
);
|
||||
lines.push("");
|
||||
lines.push("Use <command> --help for subcommand details.");
|
||||
lines.push("");
|
||||
lines.push("Environment variables:");
|
||||
lines.push(
|
||||
" WORKFLOW_STORAGE_ROOT Override storage directory (default: ~/.uncaged/workflow)",
|
||||
);
|
||||
lines.push(
|
||||
" UNCAGED_WORKFLOW_STORAGE_ROOT Internal override (takes priority over WORKFLOW_STORAGE_ROOT)",
|
||||
);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function printDeprecation(oldCmd: string, newCmd: string): void {
|
||||
printCliWarn(`⚠ "${oldCmd}" is deprecated, use "${newCmd}" instead`);
|
||||
}
|
||||
|
||||
// ── Group dispatchers ──────────────────────────────────────────────────
|
||||
|
||||
function dispatchGroup(
|
||||
tableName: string,
|
||||
@@ -37,17 +634,55 @@ function dispatchGroup(
|
||||
return entry.handler(storageRoot, argv.slice(1));
|
||||
}
|
||||
|
||||
export function formatCliUsage(): string {
|
||||
return formatCliUsageWithGroups(getCommandRegistry(), getSkillTopics());
|
||||
async function dispatchInit(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const result = dispatchGroup("init", INIT_SUBCOMMAND_TABLE, storageRoot, argv);
|
||||
if (result !== null) {
|
||||
return result;
|
||||
}
|
||||
const sub = argv[0];
|
||||
printCliError(`${formatCliUsage()}\n\nerror: unknown init subcommand: ${sub}`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
const dispatchWorkflow = createWorkflowDispatcher({ dispatchGroup });
|
||||
const dispatchThread = createThreadDispatcher({ dispatchGroup });
|
||||
const dispatchCas = createCasDispatcher({ dispatchGroup });
|
||||
const dispatchInit = createInitDispatcher({ dispatchGroup });
|
||||
async function dispatchWorkflow(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const result = dispatchGroup("workflow", WORKFLOW_SUBCOMMAND_TABLE, storageRoot, argv);
|
||||
if (result !== null) {
|
||||
return result;
|
||||
}
|
||||
const sub = argv[0];
|
||||
if (sub === "remove") {
|
||||
printDeprecation("workflow remove", "workflow rm");
|
||||
return dispatchRemove(storageRoot, argv.slice(1));
|
||||
}
|
||||
printCliError(`${formatCliUsage()}\n\nerror: unknown workflow subcommand: ${sub}`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
async function showSkillDocOrIndex(topic: string | undefined): Promise<number> {
|
||||
if (topic === undefined) {
|
||||
async function dispatchThread(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const result = dispatchGroup("thread", THREAD_SUBCOMMAND_TABLE, storageRoot, argv);
|
||||
if (result !== null) {
|
||||
return result;
|
||||
}
|
||||
const sub = argv[0];
|
||||
printCliError(`${formatCliUsage()}\n\nerror: unknown thread subcommand: ${sub}`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
async function dispatchCas(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const result = dispatchGroup("cas", CAS_SUBCOMMAND_TABLE, storageRoot, argv);
|
||||
if (result !== null) {
|
||||
return result;
|
||||
}
|
||||
const sub = argv[0];
|
||||
printCliError(`${formatCliUsage()}\n\nerror: unknown cas subcommand: ${sub}`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// ── Help ────────────────────────────────────────────────────────────────
|
||||
|
||||
async function dispatchSkill(_storageRoot: string, argv: string[]): Promise<number> {
|
||||
const topic = argv[0];
|
||||
if (topic === undefined || topic === "--help" || topic === "-h") {
|
||||
printCliLine(formatSkillIndex());
|
||||
return 0;
|
||||
}
|
||||
@@ -60,19 +695,58 @@ async function showSkillDocOrIndex(topic: string | undefined): Promise<number> {
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function dispatchSkill(_storageRoot: string, argv: string[]): Promise<number> {
|
||||
return showSkillDocOrIndex(argv[0]);
|
||||
async function dispatchHelp(_storageRoot: string, argv: string[]): Promise<number> {
|
||||
// Legacy compat: help --skill [topic] → skill [topic]
|
||||
const skillIdx = argv.indexOf("--skill");
|
||||
if (skillIdx !== -1) {
|
||||
const topic = argv[skillIdx + 1];
|
||||
if (topic === undefined) {
|
||||
printCliLine(formatSkillIndex());
|
||||
return 0;
|
||||
}
|
||||
const doc = formatSkillTopic(topic);
|
||||
if (doc === null) {
|
||||
printCliError(`unknown skill topic: ${topic}\n\n${formatSkillIndex()}`);
|
||||
return 1;
|
||||
}
|
||||
printCliLine(doc);
|
||||
return 0;
|
||||
}
|
||||
printCliLine(formatCliUsage());
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ── Top-level command table (Phase 3) ──────────────────────────────────
|
||||
|
||||
const COMMAND_TABLE: Record<string, DispatchFn> = {
|
||||
// Grouped commands (primary)
|
||||
workflow: dispatchWorkflow,
|
||||
thread: dispatchThread,
|
||||
cas: dispatchCas,
|
||||
init: dispatchInit,
|
||||
help: dispatchHelp,
|
||||
skill: dispatchSkill,
|
||||
|
||||
// Top-level shortcuts (no deprecation)
|
||||
run: dispatchRun,
|
||||
live: dispatchLive,
|
||||
serve: dispatchServe,
|
||||
};
|
||||
|
||||
// Deprecated flat commands that delegate to grouped commands
|
||||
const DEPRECATED_ALIASES: Record<string, { newCmd: string; handler: DispatchFn }> = {
|
||||
add: { newCmd: "workflow add", handler: dispatchAdd },
|
||||
list: { newCmd: "workflow list", handler: dispatchList },
|
||||
show: { newCmd: "workflow show", handler: dispatchShow },
|
||||
remove: { newCmd: "workflow rm", handler: dispatchRemove },
|
||||
ps: { newCmd: "thread ps", handler: dispatchPs },
|
||||
kill: { newCmd: "thread kill", handler: dispatchKill },
|
||||
pause: { newCmd: "thread pause", handler: dispatchPause },
|
||||
resume: { newCmd: "thread resume", handler: dispatchResume },
|
||||
threads: { newCmd: "thread list", handler: dispatchThreadList },
|
||||
fork: { newCmd: "thread fork", handler: dispatchFork },
|
||||
gc: { newCmd: "cas gc", handler: dispatchGc },
|
||||
history: { newCmd: "workflow history", handler: dispatchHistory },
|
||||
rollback: { newCmd: "workflow rollback", handler: dispatchRollback },
|
||||
};
|
||||
|
||||
export async function runCli(storageRoot: string, argv: string[]): Promise<number> {
|
||||
@@ -92,6 +766,12 @@ export async function runCli(storageRoot: string, argv: string[]): Promise<numbe
|
||||
return dispatch(storageRoot, rest);
|
||||
}
|
||||
|
||||
const deprecated = DEPRECATED_ALIASES[command];
|
||||
if (deprecated !== undefined) {
|
||||
printDeprecation(command, deprecated.newCmd);
|
||||
return deprecated.handler(storageRoot, rest);
|
||||
}
|
||||
|
||||
printCliError(`${formatCliUsage()}\n\nerror: unknown command ${command}`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import type { CommandGroup } from "./cli-command-types.js";
|
||||
import { setCommandGroupsForUsage } from "./cli-usage-context.js";
|
||||
import { CAS_SUBCOMMAND_TABLE } from "./commands/cas/index.js";
|
||||
import { INIT_SUBCOMMAND_TABLE } from "./commands/init/index.js";
|
||||
import { THREAD_SUBCOMMAND_TABLE } from "./commands/thread/index.js";
|
||||
import { WORKFLOW_SUBCOMMAND_TABLE } from "./commands/workflow/index.js";
|
||||
|
||||
export function getCommandRegistry(): ReadonlyArray<CommandGroup> {
|
||||
return [
|
||||
{
|
||||
name: "workflow",
|
||||
commands: Object.entries(WORKFLOW_SUBCOMMAND_TABLE).map(([name, e]) => ({
|
||||
name,
|
||||
args: e.args,
|
||||
description: e.description,
|
||||
})),
|
||||
},
|
||||
{
|
||||
name: "thread",
|
||||
commands: Object.entries(THREAD_SUBCOMMAND_TABLE).map(([name, e]) => ({
|
||||
name,
|
||||
args: e.args,
|
||||
description: e.description,
|
||||
})),
|
||||
},
|
||||
{
|
||||
name: "cas",
|
||||
commands: Object.entries(CAS_SUBCOMMAND_TABLE).map(([name, e]) => ({
|
||||
name,
|
||||
args: e.args,
|
||||
description: e.description,
|
||||
})),
|
||||
},
|
||||
{
|
||||
name: "init",
|
||||
commands: Object.entries(INIT_SUBCOMMAND_TABLE).map(([name, e]) => ({
|
||||
name,
|
||||
args: e.args,
|
||||
description: e.description,
|
||||
})),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
setCommandGroupsForUsage(getCommandRegistry());
|
||||
@@ -1,14 +0,0 @@
|
||||
import type { CommandGroup } from "./cli-command-types.js";
|
||||
|
||||
let commandGroupsForUsage: ReadonlyArray<CommandGroup> | null = null;
|
||||
|
||||
export function setCommandGroupsForUsage(groups: ReadonlyArray<CommandGroup>): void {
|
||||
commandGroupsForUsage = groups;
|
||||
}
|
||||
|
||||
export function getCommandGroupsForUsage(): ReadonlyArray<CommandGroup> {
|
||||
if (commandGroupsForUsage === null) {
|
||||
throw new Error("BUG: command groups for usage not initialized");
|
||||
}
|
||||
return commandGroupsForUsage;
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
import type { CommandGroup } from "./cli-command-types.js";
|
||||
|
||||
/** Keep aligned with `getSkillTopics()` in skill.ts (names only, for error usage lines). */
|
||||
export const USAGE_SKILL_TOPIC_ROWS: ReadonlyArray<{ name: string }> = [
|
||||
{ name: "cli" },
|
||||
{ name: "develop" },
|
||||
{ name: "author" },
|
||||
];
|
||||
|
||||
const USAGE_SECTION_BY_GROUP: Record<string, string> = {
|
||||
workflow: "Workflow registry:",
|
||||
thread: "Thread execution:",
|
||||
cas: "Content-addressable storage:",
|
||||
init: "Development:",
|
||||
};
|
||||
|
||||
export function formatUsageCommandLines(
|
||||
rows: ReadonlyArray<{ prefix: string; description: string }>,
|
||||
): string[] {
|
||||
const maxPrefix = rows.reduce((max, row) => Math.max(max, row.prefix.length), 0);
|
||||
const gap = 2;
|
||||
return rows.map((row) => {
|
||||
const pad = " ".repeat(maxPrefix - row.prefix.length + gap);
|
||||
return ` ${row.prefix}${pad}${row.description}`;
|
||||
});
|
||||
}
|
||||
|
||||
export function formatCliUsage(
|
||||
groups: ReadonlyArray<CommandGroup>,
|
||||
skillTopics: ReadonlyArray<{ name: string }>,
|
||||
): string {
|
||||
const lines: string[] = ["uncaged-workflow — workflow engine CLI", ""];
|
||||
|
||||
for (const group of groups) {
|
||||
const sectionTitle = USAGE_SECTION_BY_GROUP[group.name];
|
||||
if (sectionTitle === undefined) {
|
||||
throw new Error(`BUG: missing usage section title for group "${group.name}"`);
|
||||
}
|
||||
lines.push(sectionTitle);
|
||||
const rows = group.commands.map((cmd) => {
|
||||
const args = cmd.args ? ` ${cmd.args}` : "";
|
||||
return {
|
||||
prefix: `${group.name} ${cmd.name}${args}`,
|
||||
description: cmd.description,
|
||||
};
|
||||
});
|
||||
lines.push(...formatUsageCommandLines(rows));
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
lines.push("Shortcuts:");
|
||||
lines.push(
|
||||
...formatUsageCommandLines([
|
||||
{ prefix: "run <name> [...]", description: "→ thread run" },
|
||||
{ prefix: "live <id> [...]", description: "→ thread live" },
|
||||
]),
|
||||
);
|
||||
lines.push("");
|
||||
|
||||
lines.push("Server:");
|
||||
lines.push(
|
||||
...formatUsageCommandLines([
|
||||
{
|
||||
prefix: "serve [--port N] [--host ADDR]",
|
||||
description: "Start HTTP API server (default: 127.0.0.1:7860)",
|
||||
},
|
||||
]),
|
||||
);
|
||||
lines.push("");
|
||||
|
||||
lines.push("Reference:");
|
||||
const skillTopicNames = skillTopics.map((t) => t.name).join(", ");
|
||||
lines.push(
|
||||
...formatUsageCommandLines([
|
||||
{
|
||||
prefix: "skill [topic]",
|
||||
description: `Agent-consumable docs (${skillTopicNames})`,
|
||||
},
|
||||
]),
|
||||
);
|
||||
lines.push("");
|
||||
lines.push("Use <command> --help for subcommand details.");
|
||||
lines.push("");
|
||||
lines.push("Environment variables:");
|
||||
lines.push(
|
||||
" WORKFLOW_STORAGE_ROOT Override storage directory (default: ~/.uncaged/workflow)",
|
||||
);
|
||||
lines.push(
|
||||
" UNCAGED_WORKFLOW_STORAGE_ROOT Internal override (takes priority over WORKFLOW_STORAGE_ROOT)",
|
||||
);
|
||||
return lines.join("\n");
|
||||
}
|
||||
+87
-6
@@ -1,21 +1,33 @@
|
||||
import { readFile, stat } from "node:fs/promises";
|
||||
import { basename, resolve } from "node:path";
|
||||
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
import { hashWorkflowBundleBytes } from "@uncaged/workflow-cas";
|
||||
import {
|
||||
err,
|
||||
extractBundleExports,
|
||||
hashWorkflowBundleBytes,
|
||||
ok,
|
||||
type Result,
|
||||
readWorkflowRegistry,
|
||||
registerWorkflowVersion,
|
||||
stringifyWorkflowDescriptor,
|
||||
validateWorkflowBundle,
|
||||
writeWorkflowRegistry,
|
||||
} from "@uncaged/workflow-register";
|
||||
} from "@uncaged/workflow";
|
||||
|
||||
import { storeWorkflowBundleArtifacts } from "../../bundle-store.js";
|
||||
import { validateCliWorkflowName } from "../../workflow-name.js";
|
||||
import { storeWorkflowBundleArtifacts } from "./bundle-store.js";
|
||||
import { validateCliWorkflowName } from "./workflow-name.js";
|
||||
|
||||
import type { CmdAddSuccess, ParsedAddArgv } from "./types.js";
|
||||
export type ParsedAddArgv = {
|
||||
name: string;
|
||||
filePath: string;
|
||||
/** Override path to `.d.ts` when adding a bundle. */
|
||||
typesPath: string | null;
|
||||
};
|
||||
|
||||
export type CmdAddSuccess = {
|
||||
hash: string;
|
||||
warnings: ReadonlyArray<string>;
|
||||
};
|
||||
|
||||
function isEsmBundle(path: string): boolean {
|
||||
return path.endsWith(".esm.js");
|
||||
@@ -25,6 +37,75 @@ function defaultTypesPath(bundlePath: string): string {
|
||||
return bundlePath.replace(/\.esm\.js$/i, ".d.ts");
|
||||
}
|
||||
|
||||
type ParsedLongFlag = { advance: 2; kind: "types"; value: string };
|
||||
|
||||
function tryParseAddLongFlag(argv: string[], index: number): Result<ParsedLongFlag | null, string> {
|
||||
const tok = argv[index];
|
||||
if (tok !== "--types") {
|
||||
return ok(null);
|
||||
}
|
||||
const value = argv[index + 1];
|
||||
if (value === undefined || value.startsWith("--")) {
|
||||
return err("missing value for --types");
|
||||
}
|
||||
return ok({ advance: 2, kind: "types", value });
|
||||
}
|
||||
|
||||
type PositionalSlots = {
|
||||
name: string | undefined;
|
||||
filePath: string | undefined;
|
||||
};
|
||||
|
||||
function assignPositional(tok: string, slots: PositionalSlots): Result<void, string> {
|
||||
if (slots.name === undefined) {
|
||||
slots.name = tok;
|
||||
return ok(undefined);
|
||||
}
|
||||
if (slots.filePath === undefined) {
|
||||
slots.filePath = tok;
|
||||
return ok(undefined);
|
||||
}
|
||||
return err("too many arguments");
|
||||
}
|
||||
|
||||
export function parseAddArgv(argv: string[]): Result<ParsedAddArgv, string> {
|
||||
const slots: PositionalSlots = { name: undefined, filePath: undefined };
|
||||
let typesPath: string | null = null;
|
||||
|
||||
let i = 0;
|
||||
while (i < argv.length) {
|
||||
const flag = tryParseAddLongFlag(argv, i);
|
||||
if (!flag.ok) {
|
||||
return flag;
|
||||
}
|
||||
if (flag.value !== null) {
|
||||
typesPath = flag.value.value;
|
||||
i += flag.value.advance;
|
||||
continue;
|
||||
}
|
||||
|
||||
const tok = argv[i];
|
||||
if (tok?.startsWith("--")) {
|
||||
return err(`unknown add flag: ${tok}`);
|
||||
}
|
||||
if (tok === undefined) {
|
||||
break;
|
||||
}
|
||||
const placed = assignPositional(tok, slots);
|
||||
if (!placed.ok) {
|
||||
return placed;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
|
||||
const { name, filePath } = slots;
|
||||
if (name === undefined || name === "" || filePath === undefined || filePath === "") {
|
||||
return err("add requires <name> <file>");
|
||||
}
|
||||
|
||||
return ok({ name, filePath, typesPath });
|
||||
}
|
||||
|
||||
async function registerHash(
|
||||
storageRoot: string,
|
||||
name: string,
|
||||
@@ -0,0 +1,43 @@
|
||||
import { createCasStore, err, getGlobalCasDir, ok, type Result } from "@uncaged/workflow";
|
||||
|
||||
export async function cmdCasGet(
|
||||
storageRoot: string,
|
||||
_threadId: string,
|
||||
hash: string,
|
||||
): Promise<Result<string, string>> {
|
||||
const cas = createCasStore(getGlobalCasDir(storageRoot));
|
||||
const content = await cas.get(hash);
|
||||
if (content === null) {
|
||||
return err(`cas entry not found: ${hash}`);
|
||||
}
|
||||
return ok(content);
|
||||
}
|
||||
|
||||
export async function cmdCasPut(
|
||||
storageRoot: string,
|
||||
_threadId: string,
|
||||
content: string,
|
||||
): Promise<Result<string, string>> {
|
||||
const cas = createCasStore(getGlobalCasDir(storageRoot));
|
||||
const hash = await cas.put(content);
|
||||
return ok(hash);
|
||||
}
|
||||
|
||||
export async function cmdCasList(
|
||||
storageRoot: string,
|
||||
_threadId: string,
|
||||
): Promise<Result<string[], string>> {
|
||||
const cas = createCasStore(getGlobalCasDir(storageRoot));
|
||||
const hashes = await cas.list();
|
||||
return ok(hashes);
|
||||
}
|
||||
|
||||
export async function cmdCasRm(
|
||||
storageRoot: string,
|
||||
_threadId: string,
|
||||
hash: string,
|
||||
): Promise<Result<void, string>> {
|
||||
const cas = createCasStore(getGlobalCasDir(storageRoot));
|
||||
await cas.delete(hash);
|
||||
return ok(undefined);
|
||||
}
|
||||
+31
-6
@@ -1,12 +1,37 @@
|
||||
import { join } from "node:path";
|
||||
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
import { generateUlid } from "@uncaged/workflow-util";
|
||||
import { buildForkPlan } from "@uncaged/workflow-execute";
|
||||
import { buildForkPlan, err, generateUlid, ok, type Result } from "@uncaged/workflow";
|
||||
|
||||
import { pathExists, readTextFileIfExists } from "../../fs-utils.js";
|
||||
import { resolveThreadDataPath } from "../../thread-scan.js";
|
||||
import { ensureWorkerForHash, sendWorkerTcpCommand } from "../../worker-spawn.js";
|
||||
import { pathExists, readTextFileIfExists } from "./fs-utils.js";
|
||||
import { resolveThreadDataPath } from "./thread-scan.js";
|
||||
import { ensureWorkerForHash, sendWorkerTcpCommand } from "./worker-spawn.js";
|
||||
|
||||
export function parseForkArgv(
|
||||
argv: string[],
|
||||
): Result<{ threadId: string; fromRole: string | null }, string> {
|
||||
if (argv.length === 0) {
|
||||
return err("fork requires <thread-id>");
|
||||
}
|
||||
const threadId = argv[0];
|
||||
if (threadId === undefined || threadId === "") {
|
||||
return err("fork requires <thread-id>");
|
||||
}
|
||||
let fromRole: string | null = null;
|
||||
for (let i = 1; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a === "--from-role") {
|
||||
const r = argv[i + 1];
|
||||
if (r === undefined || r === "") {
|
||||
return err("--from-role requires a role name");
|
||||
}
|
||||
fromRole = r;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
return err(`unexpected argument: ${a}`);
|
||||
}
|
||||
return ok({ threadId, fromRole });
|
||||
}
|
||||
|
||||
export async function cmdFork(
|
||||
storageRoot: string,
|
||||
@@ -0,0 +1,5 @@
|
||||
import { type GcResult, garbageCollectCas, type Result } from "@uncaged/workflow";
|
||||
|
||||
export async function cmdGc(storageRoot: string): Promise<Result<GcResult, string>> {
|
||||
return garbageCollectCas(storageRoot);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getCommandRegistry } from "./cli-registry.js";
|
||||
import { getCommandRegistry } from "./cli-dispatch.js";
|
||||
|
||||
type SkillTopic = {
|
||||
name: string;
|
||||
@@ -126,13 +126,13 @@ uncaged-workflow thread list
|
||||
|
||||
## CAS (Content-Addressable Storage)
|
||||
|
||||
Store and retrieve content by hash in workflow storage (global CAS directory).
|
||||
Store and retrieve content by hash, scoped to the current thread.
|
||||
|
||||
| Operation | Command |
|
||||
|-----------|---------|
|
||||
| **Store** | \`uncaged-workflow cas put '<content>'\` → prints hash |
|
||||
| **Read** | \`uncaged-workflow cas get <HASH>\` → prints content |
|
||||
| **List** | \`uncaged-workflow cas list\` |
|
||||
| **Store** | \`uncaged-workflow cas put <THREAD_ID> '<content>'\` → prints hash |
|
||||
| **Read** | \`uncaged-workflow cas get <THREAD_ID> <HASH>\` → prints content |
|
||||
| **List** | \`uncaged-workflow cas list <THREAD_ID>\` |
|
||||
|
||||
CAS is the **only** supported way to persist structured data (phase plans, review notes, etc.) within a thread. Do not use temp files.
|
||||
|
||||
@@ -203,6 +203,7 @@ Each role has:
|
||||
| \`extractPrompt\` | string | Instruction for extracting structured meta |
|
||||
| \`schema\` | ZodSchema | Validates the extracted meta |
|
||||
| \`extractRefs\` | fn or null | Extracts CAS hashes from meta for DAG linking |
|
||||
| \`extractMode\` | "single" | Extraction mode |
|
||||
|
||||
## Development Workflow
|
||||
|
||||
@@ -228,3 +229,10 @@ uncaged-workflow live --latest
|
||||
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.
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Legacy compat ──────────────────────────────────────────────────────
|
||||
|
||||
/** @deprecated Use formatSkillTopic("cli") instead */
|
||||
export function formatSkillDoc(): string {
|
||||
return formatSkillCli();
|
||||
}
|
||||
+8
-3
@@ -1,7 +1,12 @@
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
import { getRegisteredWorkflow, readWorkflowRegistry } from "@uncaged/workflow-register";
|
||||
import {
|
||||
err,
|
||||
getRegisteredWorkflow,
|
||||
ok,
|
||||
type Result,
|
||||
readWorkflowRegistry,
|
||||
} from "@uncaged/workflow";
|
||||
|
||||
import { validateCliWorkflowName } from "../../workflow-name.js";
|
||||
import { validateCliWorkflowName } from "./workflow-name.js";
|
||||
|
||||
export async function cmdHistory(
|
||||
storageRoot: string,
|
||||
+207
-8
@@ -1,11 +1,30 @@
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
import { err, ok, type Result } from "@uncaged/workflow";
|
||||
|
||||
import { pathExists } from "../../fs-utils.js";
|
||||
import type { CmdInitWorkspaceSuccess } from "./types.js";
|
||||
import { validateWorkspaceSegment } from "./validate.js";
|
||||
import { pathExists } from "./fs-utils.js";
|
||||
|
||||
export type CmdInitWorkspaceSuccess = {
|
||||
rootPath: string;
|
||||
};
|
||||
|
||||
export type CmdInitTemplateSuccess = {
|
||||
templatePath: string;
|
||||
};
|
||||
|
||||
function validateWorkspaceSegment(name: string): Result<void, string> {
|
||||
if (name.length === 0) {
|
||||
return err("workspace name must not be empty");
|
||||
}
|
||||
if (name === "." || name === "..") {
|
||||
return err("invalid workspace name");
|
||||
}
|
||||
if (name.includes("/") || name.includes("\\")) {
|
||||
return err("workspace name must not contain path separators");
|
||||
}
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
function rootPackageJson(workspaceName: string): string {
|
||||
return `${JSON.stringify(
|
||||
@@ -28,7 +47,7 @@ function workflowsPackageJson(): string {
|
||||
private: true,
|
||||
type: "module",
|
||||
dependencies: {
|
||||
"@uncaged/workflow-runtime": "^0.1.0",
|
||||
"@uncaged/workflow": "^0.1.0",
|
||||
zod: "^4.0.0",
|
||||
},
|
||||
},
|
||||
@@ -107,7 +126,7 @@ Init 生成的骨架:\`templates/\` 下放可复用定义,\`workflows/\` 下
|
||||
2. **编写 RoleDefinition**:为每个角色写 Zod \`schema\`,补齐 \`systemPrompt\` / \`extractPrompt\` / \`description\`。
|
||||
3. **编写 Moderator**:根据 \`ctx.steps\` 与业务状态返回下一个角色名或 \`END\`。
|
||||
4. **组装 WorkflowDefinition**:在模板 \`index\` 中导出 definition(以及必要的角色 / moderator 导出)。
|
||||
5. **实例化**:在 workflow 包中使用 \`createWorkflow(def, binding)\`(或项目约定的封装)绑定 **AgentFn**;**ExtractFn** 由引擎从 **workflow.yaml** 注入 \`WorkflowRuntime\`。
|
||||
5. **实例化**:在 workflow 包中使用 \`createWorkflow(def, binding, extract)\`(或项目约定的封装)绑定 **AgentFn** / **ExtractFn**。
|
||||
6. **构建**:打包为单个 **.esm.js** bundle,使用 **uncaged-workflow add** 注册。
|
||||
|
||||
## 4. 编码规范
|
||||
@@ -214,3 +233,183 @@ export async function cmdInitWorkspace(
|
||||
|
||||
return ok({ rootPath });
|
||||
}
|
||||
|
||||
function hasTemplatesWorkspaceGlob(workspaces: unknown): boolean {
|
||||
return Array.isArray(workspaces) && workspaces.includes("templates/*");
|
||||
}
|
||||
|
||||
async function readPackageJsonWorkspaces(dir: string): Promise<unknown | null> {
|
||||
const pkgPath = join(dir, "package.json");
|
||||
if (!(await pathExists(pkgPath))) {
|
||||
return null;
|
||||
}
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await readFile(pkgPath, "utf8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw) as unknown;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (typeof parsed !== "object" || parsed === null || !("workspaces" in parsed)) {
|
||||
return null;
|
||||
}
|
||||
return (parsed as { workspaces: unknown }).workspaces;
|
||||
}
|
||||
|
||||
/** Resolve uncaged-workflow workspace root (package.json with `templates/*` in `workspaces`). */
|
||||
async function findWorkflowWorkspaceRoot(startDir: string): Promise<Result<string, string>> {
|
||||
let dir = resolve(startDir);
|
||||
for (;;) {
|
||||
const workspaces = await readPackageJsonWorkspaces(dir);
|
||||
if (workspaces !== null && hasTemplatesWorkspaceGlob(workspaces)) {
|
||||
return ok(dir);
|
||||
}
|
||||
const parent = dirname(dir);
|
||||
if (parent === dir) {
|
||||
return err(
|
||||
'not inside a workflow workspace (no package.json with workspaces containing "templates/*")',
|
||||
);
|
||||
}
|
||||
dir = parent;
|
||||
}
|
||||
}
|
||||
|
||||
function templatePackageJson(templateName: string): string {
|
||||
return `${JSON.stringify(
|
||||
{
|
||||
name: `template-${templateName}`,
|
||||
version: "0.0.0",
|
||||
private: true,
|
||||
type: "module",
|
||||
dependencies: {
|
||||
"@uncaged/workflow": "^0.1.0",
|
||||
zod: "^4.0.0",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`;
|
||||
}
|
||||
|
||||
function templateTsconfigJson(): string {
|
||||
return `${JSON.stringify(
|
||||
{
|
||||
extends: "../../tsconfig.json",
|
||||
compilerOptions: {
|
||||
rootDir: "src",
|
||||
outDir: "dist",
|
||||
},
|
||||
include: ["src/**/*.ts"],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`;
|
||||
}
|
||||
|
||||
function templateRolesTs(): string {
|
||||
return `import type { RoleDefinition } from "@uncaged/workflow";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
export const HELLO_TEMPLATE_DESCRIPTION =
|
||||
"Minimal starter template: one greeter role, then END.";
|
||||
|
||||
export type HelloTemplateMeta = {
|
||||
greeter: {
|
||||
message: string;
|
||||
};
|
||||
};
|
||||
|
||||
const greeterMetaSchema = z.object({
|
||||
message: z.string(),
|
||||
});
|
||||
|
||||
export const greeterRole: RoleDefinition<HelloTemplateMeta["greeter"]> = {
|
||||
description: "Says hello — replace with your first role.",
|
||||
systemPrompt: "You are a helpful assistant. Reply with one short friendly sentence.",
|
||||
extractPrompt: "Extract the assistant's greeting as message.",
|
||||
schema: greeterMetaSchema,
|
||||
extractRefs: null,
|
||||
};
|
||||
`;
|
||||
}
|
||||
|
||||
function templateModeratorTs(): string {
|
||||
return `import { END, type Moderator, type ModeratorContext } from "@uncaged/workflow";
|
||||
|
||||
import type { HelloTemplateMeta } from "./roles.js";
|
||||
|
||||
export const helloTemplateModerator: Moderator<HelloTemplateMeta> = (
|
||||
ctx: ModeratorContext<HelloTemplateMeta>,
|
||||
) => {
|
||||
if (ctx.steps.length === 0) {
|
||||
return "greeter";
|
||||
}
|
||||
return END;
|
||||
};
|
||||
`;
|
||||
}
|
||||
|
||||
function templateIndexTs(): string {
|
||||
return `import type { WorkflowDefinition } from "@uncaged/workflow";
|
||||
|
||||
import { helloTemplateModerator } from "./moderator.js";
|
||||
import {
|
||||
HELLO_TEMPLATE_DESCRIPTION,
|
||||
type HelloTemplateMeta,
|
||||
greeterRole,
|
||||
} from "./roles.js";
|
||||
|
||||
export {
|
||||
HELLO_TEMPLATE_DESCRIPTION,
|
||||
type HelloTemplateMeta,
|
||||
greeterRole,
|
||||
} from "./roles.js";
|
||||
export { helloTemplateModerator } from "./moderator.js";
|
||||
|
||||
export const helloTemplateWorkflowDefinition: WorkflowDefinition<HelloTemplateMeta> = {
|
||||
description: HELLO_TEMPLATE_DESCRIPTION,
|
||||
roles: {
|
||||
greeter: greeterRole,
|
||||
},
|
||||
moderator: helloTemplateModerator,
|
||||
};
|
||||
`;
|
||||
}
|
||||
|
||||
export async function cmdInitTemplate(
|
||||
startDir: string,
|
||||
templateName: string,
|
||||
): Promise<Result<CmdInitTemplateSuccess, string>> {
|
||||
const validated = validateWorkspaceSegment(templateName);
|
||||
if (!validated.ok) {
|
||||
return validated;
|
||||
}
|
||||
|
||||
const rootResult = await findWorkflowWorkspaceRoot(startDir);
|
||||
if (!rootResult.ok) {
|
||||
return rootResult;
|
||||
}
|
||||
|
||||
const workspaceRoot = rootResult.value;
|
||||
const templateDir = join(workspaceRoot, "templates", templateName);
|
||||
if (await pathExists(templateDir)) {
|
||||
return err(`template already exists: ${templateDir}`);
|
||||
}
|
||||
|
||||
await mkdir(join(templateDir, "src"), { recursive: true });
|
||||
|
||||
await Promise.all([
|
||||
writeFile(join(templateDir, "package.json"), templatePackageJson(templateName), "utf8"),
|
||||
writeFile(join(templateDir, "tsconfig.json"), templateTsconfigJson(), "utf8"),
|
||||
writeFile(join(templateDir, "src", "roles.ts"), templateRolesTs(), "utf8"),
|
||||
writeFile(join(templateDir, "src", "moderator.ts"), templateModeratorTs(), "utf8"),
|
||||
writeFile(join(templateDir, "src", "index.ts"), templateIndexTs(), "utf8"),
|
||||
]);
|
||||
|
||||
return ok({ templatePath: templateDir });
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { join } from "node:path";
|
||||
|
||||
import { err, type Result } from "@uncaged/workflow";
|
||||
|
||||
import { readTextFileIfExists } from "./fs-utils.js";
|
||||
import {
|
||||
resolveRunningHashForThread,
|
||||
sendWorkerTcpCommand,
|
||||
type WorkerCtl,
|
||||
} from "./worker-spawn.js";
|
||||
|
||||
export async function cmdKill(
|
||||
storageRoot: string,
|
||||
threadId: string,
|
||||
): Promise<Result<void, string>> {
|
||||
const hashResult = await resolveRunningHashForThread(storageRoot, threadId);
|
||||
if (!hashResult.ok) {
|
||||
return hashResult;
|
||||
}
|
||||
|
||||
const ctlPath = join(storageRoot, "workers", `${hashResult.value}.json`);
|
||||
const ctlText = await readTextFileIfExists(ctlPath);
|
||||
if (ctlText === null) {
|
||||
return err(`worker control file missing for bundle hash ${hashResult.value}`);
|
||||
}
|
||||
|
||||
let ctl: WorkerCtl;
|
||||
try {
|
||||
ctl = JSON.parse(ctlText) as WorkerCtl;
|
||||
} catch {
|
||||
return err(`corrupt worker control file: ${ctlPath}`);
|
||||
}
|
||||
|
||||
if (typeof ctl.port !== "number" || ctl.port <= 0) {
|
||||
return err(`invalid worker control file: ${ctlPath}`);
|
||||
}
|
||||
|
||||
return await sendWorkerTcpCommand(
|
||||
ctl.port,
|
||||
{ type: "kill", threadId },
|
||||
{ awaitResponseLine: true },
|
||||
);
|
||||
}
|
||||
+4
-2
@@ -1,9 +1,11 @@
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
import {
|
||||
err,
|
||||
listRegisteredWorkflowNames,
|
||||
ok,
|
||||
type Result,
|
||||
readWorkflowRegistry,
|
||||
type WorkflowRegistryFile,
|
||||
} from "@uncaged/workflow-register";
|
||||
} from "@uncaged/workflow";
|
||||
|
||||
export async function cmdList(storageRoot: string): Promise<Result<WorkflowRegistryFile, string>> {
|
||||
const reg = await readWorkflowRegistry(storageRoot);
|
||||
+38
-10
@@ -2,20 +2,30 @@ import { watch } from "node:fs";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
import type { CasStore, WorkflowCompletion } from "@uncaged/workflow-protocol";
|
||||
import { getGlobalCasDir } from "@uncaged/workflow-util";
|
||||
import { createCasStore, getContentMerklePayload } from "@uncaged/workflow-cas";
|
||||
import { tryParseRoleStepRecord, tryParseWorkflowResultRecord } from "@uncaged/workflow-execute";
|
||||
import {
|
||||
type CasStore,
|
||||
createCasStore,
|
||||
getContentMerklePayload,
|
||||
getGlobalCasDir,
|
||||
tryParseRoleStepRecord,
|
||||
tryParseWorkflowResultRecord,
|
||||
type WorkflowCompletion,
|
||||
} from "@uncaged/workflow";
|
||||
|
||||
import { dimGreyLine, highlightLiveRole } from "../../cli-color.js";
|
||||
import { printCliError, printCliLine } from "../../cli-output.js";
|
||||
import { pathExists } from "../../fs-utils.js";
|
||||
import type { ParsedLiveArgv } from "../../live-argv.js";
|
||||
import { findLatestThreadDataPath, resolveThreadDataPath } from "../../thread-scan.js";
|
||||
import type { LiveRoleRow } from "./types.js";
|
||||
import { printCliError, printCliLine } from "./cli-output.js";
|
||||
import { pathExists } from "./fs-utils.js";
|
||||
import type { ParsedLiveArgv } from "./live-argv.js";
|
||||
import { findLatestThreadDataPath, resolveThreadDataPath } from "./thread-scan.js";
|
||||
|
||||
export const LIVE_CONTENT_MAX_LINES = 10;
|
||||
|
||||
export type LiveRoleRow = {
|
||||
role: string;
|
||||
content: string;
|
||||
meta: Record<string, unknown>;
|
||||
timestamp: number;
|
||||
};
|
||||
|
||||
export function formatLiveTimeLabel(timestampMs: number): string {
|
||||
const d = new Date(timestampMs);
|
||||
const hh = String(d.getHours()).padStart(2, "0");
|
||||
@@ -24,6 +34,24 @@ export function formatLiveTimeLabel(timestampMs: number): string {
|
||||
return `${hh}:${mm}:${ss}`;
|
||||
}
|
||||
|
||||
function shouldUseColor(): boolean {
|
||||
return process.stdout.isTTY === true && process.env.NO_COLOR === undefined;
|
||||
}
|
||||
|
||||
function highlightLiveRole(name: string): string {
|
||||
if (!shouldUseColor()) {
|
||||
return name;
|
||||
}
|
||||
return `\x1b[1m\x1b[36m${name}\x1b[0m`;
|
||||
}
|
||||
|
||||
function dimGreyLine(line: string): string {
|
||||
if (!shouldUseColor()) {
|
||||
return line;
|
||||
}
|
||||
return `\x1b[2m\x1b[90m${line}\x1b[0m`;
|
||||
}
|
||||
|
||||
export function formatLiveDebugLine(timestampMs: number, tag: string, message: string): string {
|
||||
const label = `[${formatLiveTimeLabel(timestampMs)}] [${tag}] ${message.replace(/\n/g, " ")}`;
|
||||
return dimGreyLine(label);
|
||||
@@ -0,0 +1,43 @@
|
||||
import { join } from "node:path";
|
||||
|
||||
import { err, type Result } from "@uncaged/workflow";
|
||||
|
||||
import { readTextFileIfExists } from "./fs-utils.js";
|
||||
import {
|
||||
resolveRunningHashForThread,
|
||||
sendWorkerTcpCommand,
|
||||
type WorkerCtl,
|
||||
} from "./worker-spawn.js";
|
||||
|
||||
export async function cmdPause(
|
||||
storageRoot: string,
|
||||
threadId: string,
|
||||
): Promise<Result<void, string>> {
|
||||
const hashResult = await resolveRunningHashForThread(storageRoot, threadId);
|
||||
if (!hashResult.ok) {
|
||||
return hashResult;
|
||||
}
|
||||
|
||||
const ctlPath = join(storageRoot, "workers", `${hashResult.value}.json`);
|
||||
const ctlText = await readTextFileIfExists(ctlPath);
|
||||
if (ctlText === null) {
|
||||
return err(`worker control file missing for bundle hash ${hashResult.value}`);
|
||||
}
|
||||
|
||||
let ctl: WorkerCtl;
|
||||
try {
|
||||
ctl = JSON.parse(ctlText) as WorkerCtl;
|
||||
} catch {
|
||||
return err(`corrupt worker control file: ${ctlPath}`);
|
||||
}
|
||||
|
||||
if (typeof ctl.port !== "number" || ctl.port <= 0) {
|
||||
return err(`invalid worker control file: ${ctlPath}`);
|
||||
}
|
||||
|
||||
return await sendWorkerTcpCommand(
|
||||
ctl.port,
|
||||
{ type: "pause", threadId },
|
||||
{ awaitResponseLine: true },
|
||||
);
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { listRunningThreads } from "../../thread-scan.js";
|
||||
import { listRunningThreads } from "./thread-scan.js";
|
||||
|
||||
export async function cmdPs(storageRoot: string): Promise<string[]> {
|
||||
const rows = await listRunningThreads(storageRoot);
|
||||
+5
-3
@@ -1,11 +1,13 @@
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
import {
|
||||
err,
|
||||
ok,
|
||||
type Result,
|
||||
readWorkflowRegistry,
|
||||
unregisterWorkflow,
|
||||
writeWorkflowRegistry,
|
||||
} from "@uncaged/workflow-register";
|
||||
} from "@uncaged/workflow";
|
||||
|
||||
import { validateCliWorkflowName } from "../../workflow-name.js";
|
||||
import { validateCliWorkflowName } from "./workflow-name.js";
|
||||
|
||||
export async function cmdRemove(storageRoot: string, name: string): Promise<Result<void, string>> {
|
||||
const nameOk = validateCliWorkflowName(name);
|
||||
@@ -0,0 +1,43 @@
|
||||
import { join } from "node:path";
|
||||
|
||||
import { err, type Result } from "@uncaged/workflow";
|
||||
|
||||
import { readTextFileIfExists } from "./fs-utils.js";
|
||||
import {
|
||||
resolveRunningHashForThread,
|
||||
sendWorkerTcpCommand,
|
||||
type WorkerCtl,
|
||||
} from "./worker-spawn.js";
|
||||
|
||||
export async function cmdResume(
|
||||
storageRoot: string,
|
||||
threadId: string,
|
||||
): Promise<Result<void, string>> {
|
||||
const hashResult = await resolveRunningHashForThread(storageRoot, threadId);
|
||||
if (!hashResult.ok) {
|
||||
return hashResult;
|
||||
}
|
||||
|
||||
const ctlPath = join(storageRoot, "workers", `${hashResult.value}.json`);
|
||||
const ctlText = await readTextFileIfExists(ctlPath);
|
||||
if (ctlText === null) {
|
||||
return err(`worker control file missing for bundle hash ${hashResult.value}`);
|
||||
}
|
||||
|
||||
let ctl: WorkerCtl;
|
||||
try {
|
||||
ctl = JSON.parse(ctlText) as WorkerCtl;
|
||||
} catch {
|
||||
return err(`corrupt worker control file: ${ctlPath}`);
|
||||
}
|
||||
|
||||
if (typeof ctl.port !== "number" || ctl.port <= 0) {
|
||||
return err(`invalid worker control file: ${ctlPath}`);
|
||||
}
|
||||
|
||||
return await sendWorkerTcpCommand(
|
||||
ctl.port,
|
||||
{ type: "resume", threadId },
|
||||
{ awaitResponseLine: true },
|
||||
);
|
||||
}
|
||||
+6
-4
@@ -1,15 +1,17 @@
|
||||
import { join } from "node:path";
|
||||
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
import {
|
||||
err,
|
||||
getRegisteredWorkflow,
|
||||
ok,
|
||||
type Result,
|
||||
readWorkflowRegistry,
|
||||
rollbackWorkflowToHistoryHash,
|
||||
writeWorkflowRegistry,
|
||||
} from "@uncaged/workflow-register";
|
||||
} from "@uncaged/workflow";
|
||||
|
||||
import { pathExists } from "../../fs-utils.js";
|
||||
import { validateCliWorkflowName } from "../../workflow-name.js";
|
||||
import { pathExists } from "./fs-utils.js";
|
||||
import { validateCliWorkflowName } from "./workflow-name.js";
|
||||
|
||||
export async function cmdRollback(
|
||||
storageRoot: string,
|
||||
+10
-5
@@ -1,10 +1,15 @@
|
||||
import { join } from "node:path";
|
||||
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
import { generateUlid } from "@uncaged/workflow-util";
|
||||
import { getRegisteredWorkflow, readWorkflowRegistry } from "@uncaged/workflow-register";
|
||||
import { ensureWorkerForHash, sendWorkerTcpCommand } from "../../worker-spawn.js";
|
||||
import { validateCliWorkflowName } from "../../workflow-name.js";
|
||||
import {
|
||||
err,
|
||||
generateUlid,
|
||||
getRegisteredWorkflow,
|
||||
ok,
|
||||
type Result,
|
||||
readWorkflowRegistry,
|
||||
} from "@uncaged/workflow";
|
||||
import { ensureWorkerForHash, sendWorkerTcpCommand } from "./worker-spawn.js";
|
||||
import { validateCliWorkflowName } from "./workflow-name.js";
|
||||
|
||||
export async function cmdRun(
|
||||
storageRoot: string,
|
||||
+5
-3
@@ -1,12 +1,14 @@
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
import {
|
||||
err,
|
||||
getRegisteredWorkflow,
|
||||
ok,
|
||||
type Result,
|
||||
readWorkflowRegistry,
|
||||
type WorkflowRegistryEntry,
|
||||
} from "@uncaged/workflow-register";
|
||||
} from "@uncaged/workflow";
|
||||
import { stringify } from "yaml";
|
||||
|
||||
import { validateCliWorkflowName } from "../../workflow-name.js";
|
||||
import { validateCliWorkflowName } from "./workflow-name.js";
|
||||
|
||||
export async function cmdShow(
|
||||
storageRoot: string,
|
||||
+18
-3
@@ -1,10 +1,25 @@
|
||||
import { unlink } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
import { garbageCollectCas } from "@uncaged/workflow-execute";
|
||||
import { err, garbageCollectCas, ok, type Result } from "@uncaged/workflow";
|
||||
|
||||
import { resolveThreadDataPath } from "../../thread-scan.js";
|
||||
import { readTextFileIfExists } from "./fs-utils.js";
|
||||
import { resolveThreadDataPath } from "./thread-scan.js";
|
||||
|
||||
export async function cmdThreadShow(
|
||||
storageRoot: string,
|
||||
threadId: string,
|
||||
): Promise<Result<string, string>> {
|
||||
const dataPath = await resolveThreadDataPath(storageRoot, threadId);
|
||||
if (dataPath === null) {
|
||||
return err(`thread not found: ${threadId}`);
|
||||
}
|
||||
const text = await readTextFileIfExists(dataPath);
|
||||
if (text === null) {
|
||||
return err(`thread data missing: ${threadId}`);
|
||||
}
|
||||
return ok(text.endsWith("\n") ? text.slice(0, -1) : text);
|
||||
}
|
||||
|
||||
export async function cmdThreadRemove(
|
||||
storageRoot: string,
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
import { err, ok, type Result } from "@uncaged/workflow";
|
||||
|
||||
import { listHistoricalThreads } from "../../thread-scan.js";
|
||||
import { validateCliWorkflowName } from "../../workflow-name.js";
|
||||
import { listHistoricalThreads } from "./thread-scan.js";
|
||||
import { validateCliWorkflowName } from "./workflow-name.js";
|
||||
|
||||
export async function cmdThreads(
|
||||
storageRoot: string,
|
||||
@@ -1,125 +0,0 @@
|
||||
import type { CommandEntry } from "../../cli-command-types.js";
|
||||
import { printCliError, printCliLine } from "../../cli-output.js";
|
||||
import { formatCliUsage, USAGE_SKILL_TOPIC_ROWS } from "../../cli-usage.js";
|
||||
import { getCommandGroupsForUsage } from "../../cli-usage-context.js";
|
||||
import { cmdGc } from "./gc.js";
|
||||
import { cmdCasGet } from "./get.js";
|
||||
import { cmdCasList } from "./list.js";
|
||||
import { cmdCasPut } from "./put.js";
|
||||
import { cmdCasRm } from "./rm.js";
|
||||
import type { CasDispatchDeps } from "./types.js";
|
||||
|
||||
function usageText(): string {
|
||||
return formatCliUsage(getCommandGroupsForUsage(), USAGE_SKILL_TOPIC_ROWS);
|
||||
}
|
||||
|
||||
export async function dispatchGc(storageRoot: string, argv: string[]): Promise<number> {
|
||||
if (argv.length > 0) {
|
||||
printCliError(`${usageText()}\n\nerror: gc takes no arguments`);
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdGc(storageRoot);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
const stats = result.value;
|
||||
printCliLine(
|
||||
`scanned ${stats.scannedThreads} threads, ${stats.activeRefs} active refs, deleted ${stats.deletedEntries} entries`,
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function dispatchCasGet(storageRoot: string, rest: string[]): Promise<number> {
|
||||
const hash = rest[0];
|
||||
if (hash === undefined || rest.length > 1) {
|
||||
printCliError(`${usageText()}\n\nerror: cas get requires <hash>`);
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdCasGet(storageRoot, hash);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
printCliLine(result.value);
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function dispatchCasPut(storageRoot: string, rest: string[]): Promise<number> {
|
||||
const content = rest[0];
|
||||
if (content === undefined || rest.length > 1) {
|
||||
printCliError(`${usageText()}\n\nerror: cas put requires <content>`);
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdCasPut(storageRoot, content);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
printCliLine(result.value);
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function dispatchCasList(storageRoot: string, rest: string[]): Promise<number> {
|
||||
if (rest.length > 0) {
|
||||
printCliError(`${usageText()}\n\nerror: cas list takes no arguments`);
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdCasList(storageRoot);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
for (const hash of result.value) {
|
||||
printCliLine(hash);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function dispatchCasRm(storageRoot: string, rest: string[]): Promise<number> {
|
||||
const hash = rest[0];
|
||||
if (hash === undefined || rest.length > 1) {
|
||||
printCliError(`${usageText()}\n\nerror: cas rm requires <hash>`);
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdCasRm(storageRoot, hash);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
printCliLine(`removed cas entry ${hash}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
export const CAS_SUBCOMMAND_TABLE: Record<string, CommandEntry> = {
|
||||
get: {
|
||||
handler: dispatchCasGet,
|
||||
args: "<hash>",
|
||||
description: "Retrieve content by hash from CAS",
|
||||
},
|
||||
put: {
|
||||
handler: dispatchCasPut,
|
||||
args: "<content>",
|
||||
description: "Store content in CAS, prints hash",
|
||||
},
|
||||
list: {
|
||||
handler: dispatchCasList,
|
||||
args: "",
|
||||
description: "List all hashes in CAS",
|
||||
},
|
||||
rm: { handler: dispatchCasRm, args: "<hash>", description: "Remove a CAS entry by hash" },
|
||||
gc: { handler: dispatchGc, args: "", description: "Garbage-collect unreferenced CAS entries" },
|
||||
};
|
||||
|
||||
export function createCasDispatcher(deps: CasDispatchDeps) {
|
||||
const { dispatchGroup } = deps;
|
||||
return async function dispatchCas(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const result = dispatchGroup("cas", CAS_SUBCOMMAND_TABLE, storageRoot, argv);
|
||||
if (result !== null) {
|
||||
return result;
|
||||
}
|
||||
const sub = argv[0];
|
||||
printCliError(`${usageText()}\n\nerror: unknown cas subcommand: ${sub}`);
|
||||
return 1;
|
||||
};
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import type { Result } from "@uncaged/workflow-protocol";
|
||||
import { type GcResult, garbageCollectCas } from "@uncaged/workflow-execute";
|
||||
|
||||
export async function cmdGc(storageRoot: string): Promise<Result<GcResult, string>> {
|
||||
return garbageCollectCas(storageRoot);
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
import { getGlobalCasDir } from "@uncaged/workflow-util";
|
||||
import { createCasStore } from "@uncaged/workflow-cas";
|
||||
|
||||
export async function cmdCasGet(
|
||||
storageRoot: string,
|
||||
hash: string,
|
||||
): Promise<Result<string, string>> {
|
||||
const cas = createCasStore(getGlobalCasDir(storageRoot));
|
||||
const content = await cas.get(hash);
|
||||
if (content === null) {
|
||||
return err(`cas entry not found: ${hash}`);
|
||||
}
|
||||
return ok(content);
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
export {
|
||||
CAS_SUBCOMMAND_TABLE,
|
||||
createCasDispatcher,
|
||||
dispatchCasGet,
|
||||
dispatchCasList,
|
||||
dispatchCasPut,
|
||||
dispatchCasRm,
|
||||
dispatchGc,
|
||||
} from "./dispatch.js";
|
||||
export { cmdGc } from "./gc.js";
|
||||
export { cmdCasGet } from "./get.js";
|
||||
export { cmdCasList } from "./list.js";
|
||||
export { cmdCasPut } from "./put.js";
|
||||
export { cmdCasRm } from "./rm.js";
|
||||
@@ -1,9 +0,0 @@
|
||||
import { ok, type Result } from "@uncaged/workflow-protocol";
|
||||
import { getGlobalCasDir } from "@uncaged/workflow-util";
|
||||
import { createCasStore } from "@uncaged/workflow-cas";
|
||||
|
||||
export async function cmdCasList(storageRoot: string): Promise<Result<string[], string>> {
|
||||
const cas = createCasStore(getGlobalCasDir(storageRoot));
|
||||
const hashes = await cas.list();
|
||||
return ok(hashes);
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { ok, type Result } from "@uncaged/workflow-protocol";
|
||||
import { getGlobalCasDir } from "@uncaged/workflow-util";
|
||||
import { createCasStore } from "@uncaged/workflow-cas";
|
||||
|
||||
export async function cmdCasPut(
|
||||
storageRoot: string,
|
||||
content: string,
|
||||
): Promise<Result<string, string>> {
|
||||
const cas = createCasStore(getGlobalCasDir(storageRoot));
|
||||
const hash = await cas.put(content);
|
||||
return ok(hash);
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { ok, type Result } from "@uncaged/workflow-protocol";
|
||||
import { getGlobalCasDir } from "@uncaged/workflow-util";
|
||||
import { createCasStore } from "@uncaged/workflow-cas";
|
||||
|
||||
export async function cmdCasRm(storageRoot: string, hash: string): Promise<Result<void, string>> {
|
||||
const cas = createCasStore(getGlobalCasDir(storageRoot));
|
||||
await cas.delete(hash);
|
||||
return ok(undefined);
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import type { DispatchGroupFn } from "../../cli-command-types.js";
|
||||
|
||||
export type CasDispatchDeps = {
|
||||
dispatchGroup: DispatchGroupFn;
|
||||
};
|
||||
@@ -1,67 +0,0 @@
|
||||
import type { CommandEntry } from "../../cli-command-types.js";
|
||||
import { printCliError, printCliLine } from "../../cli-output.js";
|
||||
import { formatCliUsage, USAGE_SKILL_TOPIC_ROWS } from "../../cli-usage.js";
|
||||
import { getCommandGroupsForUsage } from "../../cli-usage-context.js";
|
||||
import { cmdInitTemplate } from "./template.js";
|
||||
import type { InitDispatchDeps } from "./types.js";
|
||||
import { cmdInitWorkspace } from "./workspace.js";
|
||||
|
||||
function usageText(): string {
|
||||
return formatCliUsage(getCommandGroupsForUsage(), USAGE_SKILL_TOPIC_ROWS);
|
||||
}
|
||||
|
||||
export async function dispatchInitWorkspace(_storageRoot: string, argv: string[]): Promise<number> {
|
||||
const name = argv[0];
|
||||
if (name === undefined || argv.length > 1) {
|
||||
printCliError(`${usageText()}\n\nerror: init workspace requires <name>`);
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdInitWorkspace(process.cwd(), name);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
printCliLine(`initialized workflow workspace at ${result.value.rootPath}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function dispatchInitTemplate(_storageRoot: string, argv: string[]): Promise<number> {
|
||||
const name = argv[0];
|
||||
if (name === undefined || argv.length > 1) {
|
||||
printCliError(`${usageText()}\n\nerror: init template requires <name>`);
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdInitTemplate(process.cwd(), name);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
printCliLine(`initialized template at ${result.value.templatePath}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
export const INIT_SUBCOMMAND_TABLE: Record<string, CommandEntry> = {
|
||||
workspace: {
|
||||
handler: dispatchInitWorkspace,
|
||||
args: "<name>",
|
||||
description: "Initialize a new workflow workspace",
|
||||
},
|
||||
template: {
|
||||
handler: dispatchInitTemplate,
|
||||
args: "<name>",
|
||||
description: "Initialize a new workflow template",
|
||||
},
|
||||
};
|
||||
|
||||
export function createInitDispatcher(deps: InitDispatchDeps) {
|
||||
const { dispatchGroup } = deps;
|
||||
return async function dispatchInit(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const result = dispatchGroup("init", INIT_SUBCOMMAND_TABLE, storageRoot, argv);
|
||||
if (result !== null) {
|
||||
return result;
|
||||
}
|
||||
const sub = argv[0];
|
||||
printCliError(`${usageText()}\n\nerror: unknown init subcommand: ${sub}`);
|
||||
return 1;
|
||||
};
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
export {
|
||||
createInitDispatcher,
|
||||
dispatchInitTemplate,
|
||||
dispatchInitWorkspace,
|
||||
INIT_SUBCOMMAND_TABLE,
|
||||
} from "./dispatch.js";
|
||||
export { cmdInitTemplate } from "./template.js";
|
||||
export type { CmdInitTemplateSuccess, CmdInitWorkspaceSuccess } from "./types.js";
|
||||
export { cmdInitWorkspace } from "./workspace.js";
|
||||
@@ -1,94 +0,0 @@
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
|
||||
import { pathExists } from "../../fs-utils.js";
|
||||
|
||||
import {
|
||||
templateIndexTs,
|
||||
templateModeratorTs,
|
||||
templatePackageJson,
|
||||
templateRolesTs,
|
||||
templateTsconfigJson,
|
||||
} from "./templates.js";
|
||||
import type { CmdInitTemplateSuccess } from "./types.js";
|
||||
import { validateWorkspaceSegment } from "./validate.js";
|
||||
|
||||
function hasTemplatesWorkspaceGlob(workspaces: unknown): boolean {
|
||||
return Array.isArray(workspaces) && workspaces.includes("templates/*");
|
||||
}
|
||||
|
||||
async function readPackageJsonWorkspaces(dir: string): Promise<unknown | null> {
|
||||
const pkgPath = join(dir, "package.json");
|
||||
if (!(await pathExists(pkgPath))) {
|
||||
return null;
|
||||
}
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await readFile(pkgPath, "utf8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw) as unknown;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (typeof parsed !== "object" || parsed === null || !("workspaces" in parsed)) {
|
||||
return null;
|
||||
}
|
||||
return (parsed as { workspaces: unknown }).workspaces;
|
||||
}
|
||||
|
||||
/** Resolve uncaged-workflow workspace root (package.json with `templates/*` in `workspaces`). */
|
||||
async function findWorkflowWorkspaceRoot(startDir: string): Promise<Result<string, string>> {
|
||||
let dir = resolve(startDir);
|
||||
for (;;) {
|
||||
const workspaces = await readPackageJsonWorkspaces(dir);
|
||||
if (workspaces !== null && hasTemplatesWorkspaceGlob(workspaces)) {
|
||||
return ok(dir);
|
||||
}
|
||||
const parent = dirname(dir);
|
||||
if (parent === dir) {
|
||||
return err(
|
||||
'not inside a workflow workspace (no package.json with workspaces containing "templates/*")',
|
||||
);
|
||||
}
|
||||
dir = parent;
|
||||
}
|
||||
}
|
||||
|
||||
export async function cmdInitTemplate(
|
||||
startDir: string,
|
||||
templateName: string,
|
||||
): Promise<Result<CmdInitTemplateSuccess, string>> {
|
||||
const validated = validateWorkspaceSegment(templateName);
|
||||
if (!validated.ok) {
|
||||
return validated;
|
||||
}
|
||||
|
||||
const rootResult = await findWorkflowWorkspaceRoot(startDir);
|
||||
if (!rootResult.ok) {
|
||||
return rootResult;
|
||||
}
|
||||
|
||||
const workspaceRoot = rootResult.value;
|
||||
const templateDir = join(workspaceRoot, "templates", templateName);
|
||||
if (await pathExists(templateDir)) {
|
||||
return err(`template already exists: ${templateDir}`);
|
||||
}
|
||||
|
||||
await mkdir(join(templateDir, "src"), { recursive: true });
|
||||
|
||||
await Promise.all([
|
||||
writeFile(join(templateDir, "package.json"), templatePackageJson(templateName), "utf8"),
|
||||
writeFile(join(templateDir, "tsconfig.json"), templateTsconfigJson(), "utf8"),
|
||||
writeFile(join(templateDir, "src", "roles.ts"), templateRolesTs(), "utf8"),
|
||||
writeFile(join(templateDir, "src", "moderator.ts"), templateModeratorTs(), "utf8"),
|
||||
writeFile(join(templateDir, "src", "index.ts"), templateIndexTs(), "utf8"),
|
||||
]);
|
||||
|
||||
return ok({ templatePath: templateDir });
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
export function templatePackageJson(templateName: string): string {
|
||||
return `${JSON.stringify(
|
||||
{
|
||||
name: `template-${templateName}`,
|
||||
version: "0.0.0",
|
||||
private: true,
|
||||
type: "module",
|
||||
dependencies: {
|
||||
"@uncaged/workflow-runtime": "^0.1.0",
|
||||
zod: "^4.0.0",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`;
|
||||
}
|
||||
|
||||
export function templateTsconfigJson(): string {
|
||||
return `${JSON.stringify(
|
||||
{
|
||||
extends: "../../tsconfig.json",
|
||||
compilerOptions: {
|
||||
rootDir: "src",
|
||||
outDir: "dist",
|
||||
},
|
||||
include: ["src/**/*.ts"],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`;
|
||||
}
|
||||
|
||||
export function templateRolesTs(): string {
|
||||
return `import type { RoleDefinition } from "@uncaged/workflow-runtime";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
export const HELLO_TEMPLATE_DESCRIPTION =
|
||||
"Minimal starter template: one greeter role, then END.";
|
||||
|
||||
export type HelloTemplateMeta = {
|
||||
greeter: {
|
||||
message: string;
|
||||
};
|
||||
};
|
||||
|
||||
const greeterMetaSchema = z.object({
|
||||
message: z.string(),
|
||||
});
|
||||
|
||||
export const greeterRole: RoleDefinition<HelloTemplateMeta["greeter"]> = {
|
||||
description: "Says hello — replace with your first role.",
|
||||
systemPrompt: "You are a helpful assistant. Reply with one short friendly sentence.",
|
||||
extractPrompt: "Extract the assistant's greeting as message.",
|
||||
schema: greeterMetaSchema,
|
||||
extractRefs: null,
|
||||
};
|
||||
`;
|
||||
}
|
||||
|
||||
export function templateModeratorTs(): string {
|
||||
return `import { END, type Moderator, type ModeratorContext } from "@uncaged/workflow-runtime";
|
||||
|
||||
import type { HelloTemplateMeta } from "./roles.js";
|
||||
|
||||
export const helloTemplateModerator: Moderator<HelloTemplateMeta> = (
|
||||
ctx: ModeratorContext<HelloTemplateMeta>,
|
||||
) => {
|
||||
if (ctx.steps.length === 0) {
|
||||
return "greeter";
|
||||
}
|
||||
return END;
|
||||
};
|
||||
`;
|
||||
}
|
||||
|
||||
export function templateIndexTs(): string {
|
||||
return `import type { WorkflowDefinition } from "@uncaged/workflow-runtime";
|
||||
|
||||
import { helloTemplateModerator } from "./moderator.js";
|
||||
import {
|
||||
HELLO_TEMPLATE_DESCRIPTION,
|
||||
type HelloTemplateMeta,
|
||||
greeterRole,
|
||||
} from "./roles.js";
|
||||
|
||||
export {
|
||||
HELLO_TEMPLATE_DESCRIPTION,
|
||||
type HelloTemplateMeta,
|
||||
greeterRole,
|
||||
} from "./roles.js";
|
||||
export { helloTemplateModerator } from "./moderator.js";
|
||||
|
||||
export const helloTemplateWorkflowDefinition: WorkflowDefinition<HelloTemplateMeta> = {
|
||||
description: HELLO_TEMPLATE_DESCRIPTION,
|
||||
roles: {
|
||||
greeter: greeterRole,
|
||||
},
|
||||
moderator: helloTemplateModerator,
|
||||
};
|
||||
`;
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import type { DispatchGroupFn } from "../../cli-command-types.js";
|
||||
|
||||
export type CmdInitTemplateSuccess = {
|
||||
templatePath: string;
|
||||
};
|
||||
|
||||
export type CmdInitWorkspaceSuccess = {
|
||||
rootPath: string;
|
||||
};
|
||||
|
||||
export type InitDispatchDeps = {
|
||||
dispatchGroup: DispatchGroupFn;
|
||||
};
|
||||
@@ -1,15 +0,0 @@
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
|
||||
/** Validates a single path segment for workspace / template names (no separators, not `.` / `..`). */
|
||||
export function validateWorkspaceSegment(name: string): Result<void, string> {
|
||||
if (name.length === 0) {
|
||||
return err("workspace name must not be empty");
|
||||
}
|
||||
if (name === "." || name === "..") {
|
||||
return err("invalid workspace name");
|
||||
}
|
||||
if (name.includes("/") || name.includes("\\")) {
|
||||
return err("workspace name must not contain path separators");
|
||||
}
|
||||
return ok(undefined);
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import { Hono } from "hono";
|
||||
import { cors } from "hono/cors";
|
||||
|
||||
import { createCasRoutes } from "./routes-cas.js";
|
||||
import { createLiveRoutes } from "./routes-live.js";
|
||||
import { createThreadRoutes } from "./routes-thread.js";
|
||||
import { createWorkflowRoutes } from "./routes-workflow.js";
|
||||
|
||||
const MAX_BODY_SIZE = 1_048_576; // 1 MB
|
||||
|
||||
export function createApp(storageRoot: string): Hono {
|
||||
const app = new Hono();
|
||||
|
||||
app.onError((_err, c) => {
|
||||
return c.json({ error: "Internal server error" }, 500);
|
||||
});
|
||||
|
||||
app.use(
|
||||
"*",
|
||||
cors({
|
||||
origin: [
|
||||
"http://localhost:5173",
|
||||
"http://127.0.0.1:5173",
|
||||
"http://localhost:7860",
|
||||
"http://127.0.0.1:7860",
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
app.use("*", async (c, next) => {
|
||||
if (c.req.method === "POST") {
|
||||
const contentLength = c.req.header("content-length");
|
||||
if (contentLength !== undefined && Number(contentLength) > MAX_BODY_SIZE) {
|
||||
return c.json({ error: "Payload too large" }, 413);
|
||||
}
|
||||
}
|
||||
await next();
|
||||
});
|
||||
|
||||
app.get("/healthz", (c) => c.json({ ok: true }));
|
||||
|
||||
app.route("/api/workflows", createWorkflowRoutes(storageRoot));
|
||||
app.route("/api/threads", createThreadRoutes(storageRoot));
|
||||
app.route("/api/threads", createLiveRoutes(storageRoot));
|
||||
app.route("/api/cas", createCasRoutes(storageRoot));
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export { createApp } from "./app.js";
|
||||
export { dispatchServe, startServer } from "./serve.js";
|
||||
export type { ServeOptions } from "./types.js";
|
||||
@@ -1,57 +0,0 @@
|
||||
import { getGlobalCasDir } from "@uncaged/workflow-util";
|
||||
import { createCasStore } from "@uncaged/workflow-cas";
|
||||
import { garbageCollectCas } from "@uncaged/workflow-execute";
|
||||
import { Hono } from "hono";
|
||||
|
||||
export function createCasRoutes(storageRoot: string): Hono {
|
||||
const app = new Hono();
|
||||
const casDir = getGlobalCasDir(storageRoot);
|
||||
const cas = createCasStore(casDir);
|
||||
|
||||
app.get("/", async (c) => {
|
||||
const hashes = await cas.list();
|
||||
return c.json({ hashes });
|
||||
});
|
||||
|
||||
app.get("/:hash", async (c) => {
|
||||
const content = await cas.get(c.req.param("hash"));
|
||||
if (content === null) {
|
||||
return c.json({ error: "not found" }, 404);
|
||||
}
|
||||
return c.json({ hash: c.req.param("hash"), content });
|
||||
});
|
||||
|
||||
app.post("/", async (c) => {
|
||||
let body: { content: string };
|
||||
try {
|
||||
body = (await c.req.json()) as { content: string };
|
||||
} catch {
|
||||
return c.json({ error: "invalid JSON body" }, 400);
|
||||
}
|
||||
if (typeof body.content !== "string") {
|
||||
return c.json({ error: "content field required" }, 400);
|
||||
}
|
||||
const hash = await cas.put(body.content);
|
||||
return c.json({ hash }, 201);
|
||||
});
|
||||
|
||||
app.delete("/:hash", async (c) => {
|
||||
const hash = c.req.param("hash");
|
||||
const content = await cas.get(hash);
|
||||
if (content === null) {
|
||||
return c.json({ error: "not found" }, 404);
|
||||
}
|
||||
await cas.delete(hash);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
app.post("/gc", async (c) => {
|
||||
const result = await garbageCollectCas(storageRoot);
|
||||
if (!result.ok) {
|
||||
return c.json({ error: result.error }, 500);
|
||||
}
|
||||
return c.json(result.value);
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
import { statSync, watch } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { Hono } from "hono";
|
||||
import { streamSSE } from "hono/streaming";
|
||||
|
||||
import { resolveThreadDataPath } from "../../thread-scan.js";
|
||||
|
||||
type PumpState = {
|
||||
contentOffset: number;
|
||||
carry: string;
|
||||
};
|
||||
|
||||
function fileSize(path: string): number {
|
||||
try {
|
||||
return statSync(path).size;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
async function readNewBytes(path: string, state: PumpState): Promise<string | null> {
|
||||
const size = fileSize(path);
|
||||
if (size < state.contentOffset) {
|
||||
// File was truncated — reset
|
||||
state.contentOffset = 0;
|
||||
state.carry = "";
|
||||
}
|
||||
if (size <= state.contentOffset) {
|
||||
return null;
|
||||
}
|
||||
const blob = Bun.file(path).slice(state.contentOffset, size);
|
||||
const chunk = await blob.text();
|
||||
state.contentOffset = size;
|
||||
return chunk;
|
||||
}
|
||||
|
||||
function parseJsonLine(line: string): unknown {
|
||||
try {
|
||||
return JSON.parse(line) as unknown;
|
||||
} catch {
|
||||
return { raw: line };
|
||||
}
|
||||
}
|
||||
|
||||
function isWorkflowResult(record: unknown): boolean {
|
||||
return (
|
||||
record !== null &&
|
||||
typeof record === "object" &&
|
||||
"type" in (record as Record<string, unknown>) &&
|
||||
(record as Record<string, unknown>).type === "workflow-result"
|
||||
);
|
||||
}
|
||||
|
||||
function parseNewLines(chunk: string, state: PumpState): string[] {
|
||||
state.carry += chunk;
|
||||
|
||||
const parts = state.carry.split("\n");
|
||||
state.carry = parts.pop() ?? "";
|
||||
|
||||
const lines: string[] = [];
|
||||
for (const line of parts) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed !== "") {
|
||||
lines.push(trimmed);
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
export function createLiveRoutes(storageRoot: string): Hono {
|
||||
const app = new Hono();
|
||||
|
||||
app.get("/:threadId/live", async (c) => {
|
||||
const threadId = c.req.param("threadId");
|
||||
const dataPath = await resolveThreadDataPath(storageRoot, threadId);
|
||||
if (dataPath === null) {
|
||||
return c.json({ error: `thread not found: ${threadId}` }, 404);
|
||||
}
|
||||
const resolvedDataPath = dataPath;
|
||||
|
||||
const infoPath = join(dirname(resolvedDataPath), `${threadId}.info.jsonl`);
|
||||
|
||||
return streamSSE(c, async (stream) => {
|
||||
const dataState: PumpState = { contentOffset: 0, carry: "" };
|
||||
const infoState: PumpState = { contentOffset: 0, carry: "" };
|
||||
let eventId = 0;
|
||||
|
||||
async function pumpData(): Promise<boolean> {
|
||||
let chunk: string | null;
|
||||
try {
|
||||
chunk = await readNewBytes(resolvedDataPath, dataState);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (chunk === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const lines = parseNewLines(chunk, dataState);
|
||||
for (const line of lines) {
|
||||
const record = parseJsonLine(line);
|
||||
eventId++;
|
||||
await stream.writeSSE({
|
||||
event: "record",
|
||||
data: JSON.stringify(record),
|
||||
id: String(eventId),
|
||||
});
|
||||
|
||||
if (isWorkflowResult(record)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function pumpInfo(): Promise<void> {
|
||||
let chunk: string | null;
|
||||
try {
|
||||
chunk = await readNewBytes(infoPath, infoState);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (chunk === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = parseNewLines(chunk, infoState);
|
||||
for (const line of lines) {
|
||||
const record = parseJsonLine(line);
|
||||
if (
|
||||
typeof record === "object" &&
|
||||
record !== null &&
|
||||
"raw" in (record as Record<string, unknown>)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
eventId++;
|
||||
await stream.writeSSE({
|
||||
event: "info",
|
||||
data: JSON.stringify(record),
|
||||
id: String(eventId),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Initial pump
|
||||
const done = await pumpData();
|
||||
await pumpInfo();
|
||||
if (done) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Watch for changes
|
||||
const controller = new AbortController();
|
||||
let completed = false;
|
||||
|
||||
const dataWatcher = watch(resolvedDataPath, async () => {
|
||||
if (completed) return;
|
||||
const finished = await pumpData();
|
||||
if (finished) {
|
||||
completed = true;
|
||||
controller.abort();
|
||||
}
|
||||
});
|
||||
|
||||
let infoWatcher: ReturnType<typeof watch> | null = null;
|
||||
try {
|
||||
infoWatcher = watch(infoPath, async () => {
|
||||
if (completed) return;
|
||||
await pumpInfo();
|
||||
});
|
||||
} catch {
|
||||
// info file may not exist
|
||||
}
|
||||
|
||||
stream.onAbort(() => {
|
||||
completed = true;
|
||||
dataWatcher.close();
|
||||
infoWatcher?.close();
|
||||
});
|
||||
|
||||
// Keep stream alive until completion or client disconnect
|
||||
await new Promise<void>((resolve) => {
|
||||
if (completed) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
controller.signal.addEventListener("abort", () => resolve(), { once: true });
|
||||
stream.onAbort(() => resolve());
|
||||
});
|
||||
|
||||
dataWatcher.close();
|
||||
infoWatcher?.close();
|
||||
});
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
import { Hono } from "hono";
|
||||
|
||||
import { readTextFileIfExists } from "../../fs-utils.js";
|
||||
import {
|
||||
listHistoricalThreads,
|
||||
listRunningThreads,
|
||||
resolveThreadDataPath,
|
||||
} from "../../thread-scan.js";
|
||||
import { cmdKill, cmdPause, cmdResume } from "../thread/control.js";
|
||||
import { cmdRun } from "../thread/run.js";
|
||||
|
||||
export function createThreadRoutes(storageRoot: string): Hono {
|
||||
const app = new Hono();
|
||||
|
||||
app.get("/", async (c) => {
|
||||
const nameFilter = c.req.query("workflow") ?? null;
|
||||
const rows = await listHistoricalThreads(storageRoot, nameFilter);
|
||||
return c.json({ threads: rows });
|
||||
});
|
||||
|
||||
app.get("/running", async (c) => {
|
||||
const rows = await listRunningThreads(storageRoot);
|
||||
return c.json({ threads: rows });
|
||||
});
|
||||
|
||||
app.get("/:threadId", async (c) => {
|
||||
const threadId = c.req.param("threadId");
|
||||
const dataPath = await resolveThreadDataPath(storageRoot, threadId);
|
||||
if (dataPath === null) {
|
||||
return c.json({ error: `thread not found: ${threadId}` }, 404);
|
||||
}
|
||||
const text = await readTextFileIfExists(dataPath);
|
||||
if (text === null) {
|
||||
return c.json({ error: `thread data missing: ${threadId}` }, 404);
|
||||
}
|
||||
const lines = text.trim().split("\n");
|
||||
const records = lines.map((line) => {
|
||||
try {
|
||||
return JSON.parse(line) as unknown;
|
||||
} catch {
|
||||
return { raw: line };
|
||||
}
|
||||
});
|
||||
return c.json({ threadId, records });
|
||||
});
|
||||
|
||||
app.post("/", async (c) => {
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
body = (await c.req.json()) as Record<string, unknown>;
|
||||
} catch {
|
||||
return c.json({ error: "invalid JSON body" }, 400);
|
||||
}
|
||||
|
||||
const name = body.workflow;
|
||||
const prompt = body.prompt;
|
||||
const maxRounds = typeof body.maxRounds === "number" ? body.maxRounds : 10;
|
||||
|
||||
if (typeof name !== "string" || typeof prompt !== "string") {
|
||||
return c.json({ error: "workflow (string) and prompt (string) are required" }, 400);
|
||||
}
|
||||
|
||||
const result = await cmdRun(storageRoot, name, prompt, maxRounds);
|
||||
if (!result.ok) {
|
||||
return c.json({ error: result.error }, 400);
|
||||
}
|
||||
return c.json({ threadId: result.value.threadId }, 201);
|
||||
});
|
||||
|
||||
app.post("/:threadId/kill", async (c) => {
|
||||
const threadId = c.req.param("threadId");
|
||||
const result = await cmdKill(storageRoot, threadId);
|
||||
if (!result.ok) {
|
||||
return c.json({ error: result.error }, 400);
|
||||
}
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
app.post("/:threadId/pause", async (c) => {
|
||||
const threadId = c.req.param("threadId");
|
||||
const result = await cmdPause(storageRoot, threadId);
|
||||
if (!result.ok) {
|
||||
return c.json({ error: result.error }, 400);
|
||||
}
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
app.post("/:threadId/resume", async (c) => {
|
||||
const threadId = c.req.param("threadId");
|
||||
const result = await cmdResume(storageRoot, threadId);
|
||||
if (!result.ok) {
|
||||
return c.json({ error: result.error }, 400);
|
||||
}
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import {
|
||||
getRegisteredWorkflow,
|
||||
listRegisteredWorkflowNames,
|
||||
readWorkflowRegistry,
|
||||
} from "@uncaged/workflow-register";
|
||||
import { Hono } from "hono";
|
||||
|
||||
export function createWorkflowRoutes(storageRoot: string): Hono {
|
||||
const app = new Hono();
|
||||
|
||||
app.get("/", async (c) => {
|
||||
const reg = await readWorkflowRegistry(storageRoot);
|
||||
if (!reg.ok) {
|
||||
return c.json({ error: reg.error.message }, 500);
|
||||
}
|
||||
const names = listRegisteredWorkflowNames(reg.value);
|
||||
const workflows = names.map((name) => {
|
||||
const entry = reg.value.workflows[name];
|
||||
return {
|
||||
name,
|
||||
hash: entry?.hash ?? null,
|
||||
timestamp: entry?.timestamp ?? null,
|
||||
};
|
||||
});
|
||||
return c.json({ workflows });
|
||||
});
|
||||
|
||||
app.get("/:name", async (c) => {
|
||||
const reg = await readWorkflowRegistry(storageRoot);
|
||||
if (!reg.ok) {
|
||||
return c.json({ error: reg.error.message }, 500);
|
||||
}
|
||||
const name = c.req.param("name");
|
||||
const entry = getRegisteredWorkflow(reg.value, name);
|
||||
if (entry === null) {
|
||||
return c.json({ error: `workflow not found: ${name}` }, 404);
|
||||
}
|
||||
return c.json({ name, ...entry });
|
||||
});
|
||||
|
||||
app.get("/:name/history", async (c) => {
|
||||
const reg = await readWorkflowRegistry(storageRoot);
|
||||
if (!reg.ok) {
|
||||
return c.json({ error: reg.error.message }, 500);
|
||||
}
|
||||
const name = c.req.param("name");
|
||||
const entry = getRegisteredWorkflow(reg.value, name);
|
||||
if (entry === null) {
|
||||
return c.json({ error: `workflow not found: ${name}` }, 404);
|
||||
}
|
||||
return c.json({ name, history: entry.history });
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
import { serve } from "bun";
|
||||
|
||||
import { printCliLine } from "../../cli-output.js";
|
||||
import { createApp } from "./app.js";
|
||||
import type { ServeOptions } from "./types.js";
|
||||
|
||||
export function startServer(storageRoot: string, options: ServeOptions): void {
|
||||
const app = createApp(storageRoot);
|
||||
|
||||
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 parseServeArgv(argv: string[]): Result<ServeOptions, string> {
|
||||
let port = 7860;
|
||||
let hostname = "127.0.0.1";
|
||||
|
||||
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 === "--host") {
|
||||
const next = argv[i + 1];
|
||||
if (next === undefined) {
|
||||
return err("--host requires a value");
|
||||
}
|
||||
hostname = next;
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
return ok({ port, hostname });
|
||||
}
|
||||
|
||||
export async function dispatchServe(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const parsed = parseServeArgv(argv);
|
||||
if (!parsed.ok) {
|
||||
printCliLine(`error: ${parsed.error}`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
startServer(storageRoot, parsed.value);
|
||||
|
||||
// Keep process alive
|
||||
await new Promise(() => {});
|
||||
return 0;
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
export type ServeOptions = {
|
||||
port: number;
|
||||
hostname: string;
|
||||
};
|
||||
@@ -1,52 +0,0 @@
|
||||
import type { Result } from "@uncaged/workflow-protocol";
|
||||
|
||||
import {
|
||||
readWorkerCtl,
|
||||
resolveRunningHashForThread,
|
||||
sendWorkerTcpCommand,
|
||||
} from "../../worker-spawn.js";
|
||||
|
||||
type ThreadControlAction = "kill" | "pause" | "resume";
|
||||
|
||||
async function cmdThreadControl(
|
||||
storageRoot: string,
|
||||
threadId: string,
|
||||
action: ThreadControlAction,
|
||||
): Promise<Result<void, string>> {
|
||||
const hashResult = await resolveRunningHashForThread(storageRoot, threadId);
|
||||
if (!hashResult.ok) {
|
||||
return hashResult;
|
||||
}
|
||||
|
||||
const ctlResult = await readWorkerCtl(storageRoot, hashResult.value);
|
||||
if (!ctlResult.ok) {
|
||||
return ctlResult;
|
||||
}
|
||||
|
||||
return await sendWorkerTcpCommand(
|
||||
ctlResult.value.port,
|
||||
{ type: action, threadId },
|
||||
{ awaitResponseLine: true },
|
||||
);
|
||||
}
|
||||
|
||||
export async function cmdKill(
|
||||
storageRoot: string,
|
||||
threadId: string,
|
||||
): Promise<Result<void, string>> {
|
||||
return cmdThreadControl(storageRoot, threadId, "kill");
|
||||
}
|
||||
|
||||
export async function cmdPause(
|
||||
storageRoot: string,
|
||||
threadId: string,
|
||||
): Promise<Result<void, string>> {
|
||||
return cmdThreadControl(storageRoot, threadId, "pause");
|
||||
}
|
||||
|
||||
export async function cmdResume(
|
||||
storageRoot: string,
|
||||
threadId: string,
|
||||
): Promise<Result<void, string>> {
|
||||
return cmdThreadControl(storageRoot, threadId, "resume");
|
||||
}
|
||||
@@ -1,206 +0,0 @@
|
||||
import type { CommandEntry } from "../../cli-command-types.js";
|
||||
import { printCliError, printCliLine } from "../../cli-output.js";
|
||||
import { formatCliUsage, USAGE_SKILL_TOPIC_ROWS } from "../../cli-usage.js";
|
||||
import { getCommandGroupsForUsage } from "../../cli-usage-context.js";
|
||||
import { parseLiveArgv } from "../../live-argv.js";
|
||||
import { parseRunArgv } from "../../run-argv.js";
|
||||
import { cmdKill, cmdPause, cmdResume } from "./control.js";
|
||||
import { cmdFork } from "./fork.js";
|
||||
import { parseForkArgv } from "./fork-argv.js";
|
||||
import { cmdThreads } from "./list.js";
|
||||
import { cmdLive } from "./live.js";
|
||||
import { cmdPs } from "./ps.js";
|
||||
import { cmdThreadRemove } from "./rm.js";
|
||||
import { cmdRun } from "./run.js";
|
||||
import { cmdThreadShow } from "./show.js";
|
||||
import type { ThreadDispatchDeps } from "./types.js";
|
||||
|
||||
function usageText(): string {
|
||||
return formatCliUsage(getCommandGroupsForUsage(), USAGE_SKILL_TOPIC_ROWS);
|
||||
}
|
||||
|
||||
export async function dispatchRun(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const parsed = parseRunArgv(argv);
|
||||
if (!parsed.ok) {
|
||||
printCliError(`${usageText()}\n\nerror: ${parsed.error}`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
const result = await cmdRun(
|
||||
storageRoot,
|
||||
parsed.value.name,
|
||||
parsed.value.prompt,
|
||||
parsed.value.maxRounds,
|
||||
);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
|
||||
printCliLine(result.value.threadId);
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function dispatchPs(storageRoot: string, argv: string[]): Promise<number> {
|
||||
if (argv.length > 0) {
|
||||
printCliError(`${usageText()}\n\nerror: ps takes no arguments`);
|
||||
return 1;
|
||||
}
|
||||
for (const line of await cmdPs(storageRoot)) {
|
||||
printCliLine(line);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function dispatchKill(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const threadId = argv[0];
|
||||
if (threadId === undefined || argv.length > 1) {
|
||||
printCliError(`${usageText()}\n\nerror: kill requires <thread-id>`);
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdKill(storageRoot, threadId);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
printCliLine(`kill sent for thread ${threadId}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function dispatchLive(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const parsed = parseLiveArgv(argv);
|
||||
if (!parsed.ok) {
|
||||
printCliError(`${usageText()}\n\nerror: ${parsed.error}`);
|
||||
return 1;
|
||||
}
|
||||
return cmdLive(storageRoot, parsed.value);
|
||||
}
|
||||
|
||||
export async function dispatchPause(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const threadId = argv[0];
|
||||
if (threadId === undefined || argv.length > 1) {
|
||||
printCliError(`${usageText()}\n\nerror: pause requires <thread-id>`);
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdPause(storageRoot, threadId);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
printCliLine(`pause sent for thread ${threadId}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function dispatchResume(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const threadId = argv[0];
|
||||
if (threadId === undefined || argv.length > 1) {
|
||||
printCliError(`${usageText()}\n\nerror: resume requires <thread-id>`);
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdResume(storageRoot, threadId);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
printCliLine(`resume sent for thread ${threadId}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function dispatchThreadList(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const result = await cmdThreads(storageRoot, argv);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
for (const line of result.value) {
|
||||
printCliLine(line);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function dispatchThreadShow(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const id = argv[0];
|
||||
if (id === undefined || argv.length > 1) {
|
||||
printCliError(`${usageText()}\n\nerror: thread show requires <id>`);
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdThreadShow(storageRoot, id);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
printCliLine(result.value);
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function dispatchThreadRm(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const id = argv[0];
|
||||
if (id === undefined || argv.length > 1) {
|
||||
printCliError(`${usageText()}\n\nerror: thread rm requires <id>`);
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdThreadRemove(storageRoot, id);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
printCliLine(`removed thread ${id}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function dispatchFork(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const parsed = parseForkArgv(argv);
|
||||
if (!parsed.ok) {
|
||||
printCliError(`${usageText()}\n\nerror: ${parsed.error}`);
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdFork(storageRoot, parsed.value.threadId, parsed.value.fromRole);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
printCliLine(result.value.threadId);
|
||||
return 0;
|
||||
}
|
||||
|
||||
export const THREAD_SUBCOMMAND_TABLE: Record<string, CommandEntry> = {
|
||||
run: {
|
||||
handler: dispatchRun,
|
||||
args: "<name> [--prompt <text>] [--max-rounds N]",
|
||||
description: "Start a new thread executing a workflow",
|
||||
},
|
||||
list: {
|
||||
handler: dispatchThreadList,
|
||||
args: "[name]",
|
||||
description: "List threads, optionally filtered by workflow name",
|
||||
},
|
||||
show: { handler: dispatchThreadShow, args: "<id>", description: "Show thread details and state" },
|
||||
rm: { handler: dispatchThreadRm, args: "<id>", description: "Remove a thread" },
|
||||
fork: {
|
||||
handler: dispatchFork,
|
||||
args: "<thread-id> [--from-role <role>]",
|
||||
description: "Fork a thread, optionally from a specific role",
|
||||
},
|
||||
ps: { handler: dispatchPs, args: "", description: "List running threads" },
|
||||
kill: { handler: dispatchKill, args: "<thread-id>", description: "Kill a running thread" },
|
||||
live: {
|
||||
handler: dispatchLive,
|
||||
args: "<thread-id> | --latest [--debug] [--role <name>]",
|
||||
description: "Attach to a thread and stream output live",
|
||||
},
|
||||
pause: { handler: dispatchPause, args: "<thread-id>", description: "Pause a running thread" },
|
||||
resume: { handler: dispatchResume, args: "<thread-id>", description: "Resume a paused thread" },
|
||||
};
|
||||
|
||||
export function createThreadDispatcher(deps: ThreadDispatchDeps) {
|
||||
const { dispatchGroup } = deps;
|
||||
return async function dispatchThread(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const result = dispatchGroup("thread", THREAD_SUBCOMMAND_TABLE, storageRoot, argv);
|
||||
if (result !== null) {
|
||||
return result;
|
||||
}
|
||||
const sub = argv[0];
|
||||
printCliError(`${usageText()}\n\nerror: unknown thread subcommand: ${sub}`);
|
||||
return 1;
|
||||
};
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
|
||||
import type { ParsedForkArgv } from "./types.js";
|
||||
|
||||
export function parseForkArgv(argv: string[]): Result<ParsedForkArgv, string> {
|
||||
if (argv.length === 0) {
|
||||
return err("fork requires <thread-id>");
|
||||
}
|
||||
const threadId = argv[0];
|
||||
if (threadId === undefined || threadId === "") {
|
||||
return err("fork requires <thread-id>");
|
||||
}
|
||||
let fromRole: string | null = null;
|
||||
for (let i = 1; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a === "--from-role") {
|
||||
const r = argv[i + 1];
|
||||
if (r === undefined || r === "") {
|
||||
return err("--from-role requires a role name");
|
||||
}
|
||||
fromRole = r;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
return err(`unexpected argument: ${a}`);
|
||||
}
|
||||
return ok({ threadId, fromRole });
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
export { cmdKill, cmdPause, cmdResume } from "./control.js";
|
||||
export {
|
||||
createThreadDispatcher,
|
||||
dispatchFork,
|
||||
dispatchKill,
|
||||
dispatchLive,
|
||||
dispatchPause,
|
||||
dispatchPs,
|
||||
dispatchResume,
|
||||
dispatchRun,
|
||||
dispatchThreadList,
|
||||
dispatchThreadRm,
|
||||
dispatchThreadShow,
|
||||
THREAD_SUBCOMMAND_TABLE,
|
||||
} from "./dispatch.js";
|
||||
export { cmdFork } from "./fork.js";
|
||||
export { parseForkArgv } from "./fork-argv.js";
|
||||
export { cmdThreads } from "./list.js";
|
||||
export {
|
||||
cmdLive,
|
||||
formatLiveDebugLine,
|
||||
formatLiveTimeLabel,
|
||||
LIVE_CONTENT_MAX_LINES,
|
||||
renderLiveRoleStepLines,
|
||||
} from "./live.js";
|
||||
export { cmdPs } from "./ps.js";
|
||||
export { cmdThreadRemove } from "./rm.js";
|
||||
export { cmdRun } from "./run.js";
|
||||
export { cmdThreadShow } from "./show.js";
|
||||
export type { LiveRoleRow } from "./types.js";
|
||||
@@ -1,19 +0,0 @@
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
|
||||
import { readTextFileIfExists } from "../../fs-utils.js";
|
||||
import { resolveThreadDataPath } from "../../thread-scan.js";
|
||||
|
||||
export async function cmdThreadShow(
|
||||
storageRoot: string,
|
||||
threadId: string,
|
||||
): Promise<Result<string, string>> {
|
||||
const dataPath = await resolveThreadDataPath(storageRoot, threadId);
|
||||
if (dataPath === null) {
|
||||
return err(`thread not found: ${threadId}`);
|
||||
}
|
||||
const text = await readTextFileIfExists(dataPath);
|
||||
if (text === null) {
|
||||
return err(`thread data missing: ${threadId}`);
|
||||
}
|
||||
return ok(text.endsWith("\n") ? text.slice(0, -1) : text);
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import type { DispatchGroupFn } from "../../cli-command-types.js";
|
||||
|
||||
export type LiveRoleRow = {
|
||||
role: string;
|
||||
content: string;
|
||||
meta: Record<string, unknown>;
|
||||
timestamp: number;
|
||||
};
|
||||
|
||||
export type ParsedForkArgv = {
|
||||
threadId: string;
|
||||
fromRole: string | null;
|
||||
};
|
||||
|
||||
export type ThreadDispatchDeps = {
|
||||
dispatchGroup: DispatchGroupFn;
|
||||
};
|
||||
@@ -1,72 +0,0 @@
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
|
||||
import type { ParsedAddArgv } from "./types.js";
|
||||
|
||||
type ParsedLongFlag = { advance: 2; kind: "types"; value: string };
|
||||
|
||||
function tryParseAddLongFlag(argv: string[], index: number): Result<ParsedLongFlag | null, string> {
|
||||
const tok = argv[index];
|
||||
if (tok !== "--types") {
|
||||
return ok(null);
|
||||
}
|
||||
const value = argv[index + 1];
|
||||
if (value === undefined || value.startsWith("--")) {
|
||||
return err("missing value for --types");
|
||||
}
|
||||
return ok({ advance: 2, kind: "types", value });
|
||||
}
|
||||
|
||||
type PositionalSlots = {
|
||||
name: string | undefined;
|
||||
filePath: string | undefined;
|
||||
};
|
||||
|
||||
function assignPositional(tok: string, slots: PositionalSlots): Result<void, string> {
|
||||
if (slots.name === undefined) {
|
||||
slots.name = tok;
|
||||
return ok(undefined);
|
||||
}
|
||||
if (slots.filePath === undefined) {
|
||||
slots.filePath = tok;
|
||||
return ok(undefined);
|
||||
}
|
||||
return err("too many arguments");
|
||||
}
|
||||
|
||||
export function parseAddArgv(argv: string[]): Result<ParsedAddArgv, string> {
|
||||
const slots: PositionalSlots = { name: undefined, filePath: undefined };
|
||||
let typesPath: string | null = null;
|
||||
|
||||
let i = 0;
|
||||
while (i < argv.length) {
|
||||
const flag = tryParseAddLongFlag(argv, i);
|
||||
if (!flag.ok) {
|
||||
return flag;
|
||||
}
|
||||
if (flag.value !== null) {
|
||||
typesPath = flag.value.value;
|
||||
i += flag.value.advance;
|
||||
continue;
|
||||
}
|
||||
|
||||
const tok = argv[i];
|
||||
if (tok?.startsWith("--")) {
|
||||
return err(`unknown add flag: ${tok}`);
|
||||
}
|
||||
if (tok === undefined) {
|
||||
break;
|
||||
}
|
||||
const placed = assignPositional(tok, slots);
|
||||
if (!placed.ok) {
|
||||
return placed;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
|
||||
const { name, filePath } = slots;
|
||||
if (name === undefined || name === "" || filePath === undefined || filePath === "") {
|
||||
return err("add requires <name> <file>");
|
||||
}
|
||||
|
||||
return ok({ name, filePath, typesPath });
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
import type { CommandEntry } from "../../cli-command-types.js";
|
||||
import { printCliError, printCliLine, printCliWarn } from "../../cli-output.js";
|
||||
import { formatCliUsage, USAGE_SKILL_TOPIC_ROWS } from "../../cli-usage.js";
|
||||
import { getCommandGroupsForUsage } from "../../cli-usage-context.js";
|
||||
import { cmdAdd, formatAddSuccess } from "./add.js";
|
||||
import { parseAddArgv } from "./add-argv.js";
|
||||
import { cmdHistory } from "./history.js";
|
||||
import { cmdList, formatListLines } from "./list.js";
|
||||
import { cmdRemove } from "./rm.js";
|
||||
import { cmdRollback } from "./rollback.js";
|
||||
import { cmdShow, formatShowYaml } from "./show.js";
|
||||
import type { WorkflowDispatchDeps } from "./types.js";
|
||||
|
||||
function usageText(): string {
|
||||
return formatCliUsage(getCommandGroupsForUsage(), USAGE_SKILL_TOPIC_ROWS);
|
||||
}
|
||||
|
||||
export async function dispatchAdd(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const parsed = parseAddArgv(argv);
|
||||
if (!parsed.ok) {
|
||||
printCliError(`${usageText()}\n\nerror: ${parsed.error}`);
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdAdd(storageRoot, parsed.value);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
for (const w of result.value.warnings) {
|
||||
printCliWarn(w);
|
||||
}
|
||||
printCliLine(formatAddSuccess(parsed.value.name, parsed.value.filePath, result.value.hash));
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function dispatchList(storageRoot: string, argv: string[]): Promise<number> {
|
||||
if (argv.length > 0) {
|
||||
printCliError(`${usageText()}\n\nerror: list takes no arguments`);
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdList(storageRoot);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
for (const line of formatListLines(result.value)) {
|
||||
printCliLine(line);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function dispatchShow(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const name = argv[0];
|
||||
if (name === undefined || argv.length > 1) {
|
||||
printCliError(`${usageText()}\n\nerror: show requires <name>`);
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdShow(storageRoot, name);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
printCliLine(formatShowYaml(name, result.value));
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function dispatchRemove(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const name = argv[0];
|
||||
if (name === undefined || argv.length > 1) {
|
||||
printCliError(`${usageText()}\n\nerror: remove requires <name>`);
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdRemove(storageRoot, name);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
printCliLine(`removed workflow "${name}" from registry`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function dispatchHistory(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const name = argv[0];
|
||||
if (name === undefined || argv.length > 1) {
|
||||
printCliError(`${usageText()}\n\nerror: history requires <name>`);
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdHistory(storageRoot, name);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
for (const line of result.value) {
|
||||
printCliLine(line);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function dispatchRollback(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const name = argv[0];
|
||||
if (name === undefined || argv.length > 2) {
|
||||
printCliError(`${usageText()}\n\nerror: rollback requires <name> [hash]`);
|
||||
return 1;
|
||||
}
|
||||
const hashArg = argv[1];
|
||||
const result = await cmdRollback(storageRoot, name, hashArg === undefined ? null : hashArg);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
printCliLine(`rolled back workflow "${name}"`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
export const WORKFLOW_SUBCOMMAND_TABLE: Record<string, CommandEntry> = {
|
||||
add: {
|
||||
handler: dispatchAdd,
|
||||
args: "<name> <file.esm.js> [--types <path>]",
|
||||
description: "Register a workflow bundle in the registry",
|
||||
},
|
||||
list: { handler: dispatchList, args: "", description: "List all registered workflows" },
|
||||
show: {
|
||||
handler: dispatchShow,
|
||||
args: "<name>",
|
||||
description: "Show details of a registered workflow",
|
||||
},
|
||||
rm: {
|
||||
handler: dispatchRemove,
|
||||
args: "<name>",
|
||||
description: "Remove a workflow from the registry",
|
||||
},
|
||||
history: {
|
||||
handler: dispatchHistory,
|
||||
args: "<name>",
|
||||
description: "Show version history of a workflow",
|
||||
},
|
||||
rollback: {
|
||||
handler: dispatchRollback,
|
||||
args: "<name> [hash]",
|
||||
description: "Rollback a workflow to a previous version",
|
||||
},
|
||||
};
|
||||
|
||||
export function createWorkflowDispatcher(deps: WorkflowDispatchDeps) {
|
||||
const { dispatchGroup } = deps;
|
||||
return async function dispatchWorkflow(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const result = dispatchGroup("workflow", WORKFLOW_SUBCOMMAND_TABLE, storageRoot, argv);
|
||||
if (result !== null) {
|
||||
return result;
|
||||
}
|
||||
const sub = argv[0];
|
||||
if (sub === "remove") {
|
||||
return dispatchRemove(storageRoot, argv.slice(1));
|
||||
}
|
||||
printCliError(`${usageText()}\n\nerror: unknown workflow subcommand: ${sub}`);
|
||||
return 1;
|
||||
};
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
export { cmdAdd, formatAddSuccess } from "./add.js";
|
||||
export { parseAddArgv } from "./add-argv.js";
|
||||
export {
|
||||
createWorkflowDispatcher,
|
||||
dispatchAdd,
|
||||
dispatchHistory,
|
||||
dispatchList,
|
||||
dispatchRemove,
|
||||
dispatchRollback,
|
||||
dispatchShow,
|
||||
WORKFLOW_SUBCOMMAND_TABLE,
|
||||
} from "./dispatch.js";
|
||||
export { cmdHistory } from "./history.js";
|
||||
export { cmdList, formatListLines } from "./list.js";
|
||||
export { cmdRemove } from "./rm.js";
|
||||
export { cmdRollback } from "./rollback.js";
|
||||
export { cmdShow, formatShowYaml } from "./show.js";
|
||||
export type { CmdAddSuccess, ParsedAddArgv } from "./types.js";
|
||||
@@ -1,17 +0,0 @@
|
||||
import type { DispatchGroupFn } from "../../cli-command-types.js";
|
||||
|
||||
export type ParsedAddArgv = {
|
||||
name: string;
|
||||
filePath: string;
|
||||
/** Override path to `.d.ts` when adding a bundle. */
|
||||
typesPath: string | null;
|
||||
};
|
||||
|
||||
export type CmdAddSuccess = {
|
||||
hash: string;
|
||||
warnings: ReadonlyArray<string>;
|
||||
};
|
||||
|
||||
export type WorkflowDispatchDeps = {
|
||||
dispatchGroup: DispatchGroupFn;
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
import { err, ok, type Result } from "@uncaged/workflow";
|
||||
|
||||
export type ParsedLiveArgv = {
|
||||
threadId: string | null;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
import { err, ok, type Result } from "@uncaged/workflow";
|
||||
|
||||
export type ParsedRunArgv = {
|
||||
name: string;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getDefaultWorkflowStorageRoot } from "@uncaged/workflow-util";
|
||||
import { getDefaultWorkflowStorageRoot } from "@uncaged/workflow";
|
||||
|
||||
/**
|
||||
* Resolve storage root with env var override support.
|
||||
|
||||
@@ -3,23 +3,6 @@ import { join } from "node:path";
|
||||
|
||||
import { pathExists, readTextFileIfExists } from "./fs-utils.js";
|
||||
|
||||
function parseFirstJsonLineObject(text: string): Record<string, unknown> | null {
|
||||
const firstLine = text.split("\n")[0];
|
||||
if (firstLine === undefined || firstLine.trim() === "") {
|
||||
return null;
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(firstLine) as unknown;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (parsed === null || typeof parsed !== "object") {
|
||||
return null;
|
||||
}
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type RunningThreadRow = {
|
||||
threadId: string;
|
||||
hash: string;
|
||||
@@ -37,11 +20,20 @@ async function readThreadStartTimestampMs(dataPath: string): Promise<number | nu
|
||||
if (text === null) {
|
||||
return null;
|
||||
}
|
||||
const parsed = parseFirstJsonLineObject(text);
|
||||
if (parsed === null) {
|
||||
const firstLine = text.split("\n")[0];
|
||||
if (firstLine === undefined || firstLine.trim() === "") {
|
||||
return null;
|
||||
}
|
||||
const ts = parsed.timestamp;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(firstLine) as unknown;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (parsed === null || typeof parsed !== "object") {
|
||||
return null;
|
||||
}
|
||||
const ts = (parsed as Record<string, unknown>).timestamp;
|
||||
return typeof ts === "number" && Number.isFinite(ts) ? ts : null;
|
||||
}
|
||||
|
||||
@@ -50,11 +42,20 @@ async function readWorkflowNameFromDataJsonl(dataPath: string): Promise<string |
|
||||
if (text === null) {
|
||||
return null;
|
||||
}
|
||||
const parsed = parseFirstJsonLineObject(text);
|
||||
if (parsed === null) {
|
||||
const firstLine = text.split("\n")[0];
|
||||
if (firstLine === undefined || firstLine.trim() === "") {
|
||||
return null;
|
||||
}
|
||||
const name = parsed.name;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(firstLine) as unknown;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (parsed === null || typeof parsed !== "object") {
|
||||
return null;
|
||||
}
|
||||
const name = (parsed as Record<string, unknown>).name;
|
||||
return typeof name === "string" ? name : null;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,7 @@ import { mkdir, readdir, unlink, writeFile } from "node:fs/promises";
|
||||
import { createConnection } from "node:net";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
import { getWorkerHostScriptPath } from "@uncaged/workflow-execute";
|
||||
import { err, getWorkerHostScriptPath, ok, type Result } from "@uncaged/workflow";
|
||||
|
||||
import { pathExists, readTextFileIfExists } from "./fs-utils.js";
|
||||
|
||||
@@ -238,30 +237,6 @@ export async function sendWorkerTcpCommand(
|
||||
});
|
||||
}
|
||||
|
||||
export async function readWorkerCtl(
|
||||
storageRoot: string,
|
||||
hash: string,
|
||||
): Promise<Result<WorkerCtl, string>> {
|
||||
const ctlPath = join(storageRoot, "workers", `${hash}.json`);
|
||||
const ctlText = await readTextFileIfExists(ctlPath);
|
||||
if (ctlText === null) {
|
||||
return err(`worker control file missing for bundle hash ${hash}`);
|
||||
}
|
||||
|
||||
let ctl: WorkerCtl;
|
||||
try {
|
||||
ctl = JSON.parse(ctlText) as WorkerCtl;
|
||||
} catch {
|
||||
return err(`corrupt worker control file: ${ctlPath}`);
|
||||
}
|
||||
|
||||
if (typeof ctl.port !== "number" || ctl.port <= 0) {
|
||||
return err(`invalid worker control file: ${ctlPath}`);
|
||||
}
|
||||
|
||||
return ok(ctl);
|
||||
}
|
||||
|
||||
export async function resolveRunningHashForThread(
|
||||
storageRoot: string,
|
||||
threadId: string,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
import { err, ok, type Result } from "@uncaged/workflow";
|
||||
|
||||
const WORKFLOW_NAME_RE = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
|
||||
|
||||
|
||||
@@ -17,13 +17,6 @@
|
||||
"rootDir": "src",
|
||||
"types": ["bun-types"]
|
||||
},
|
||||
"references": [
|
||||
{ "path": "../workflow-runtime" },
|
||||
{ "path": "../workflow-protocol" },
|
||||
{ "path": "../workflow-util" },
|
||||
{ "path": "../workflow-cas" },
|
||||
{ "path": "../workflow-execute" },
|
||||
{ "path": "../workflow-register" }
|
||||
],
|
||||
"references": [{ "path": "../workflow" }],
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
# @uncaged/workflow-agent-cursor
|
||||
|
||||
`AgentFn` adapter that runs the `cursor-agent` CLI against a workspace path derived from the thread.
|
||||
|
||||
The agent builds a full prompt (system + task + step history via `@uncaged/workflow-util-agent`), extracts the absolute workspace path with your `extract` + Zod schema, then spawns `cursor-agent` with `--workspace`, model, and non-interactive flags.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @uncaged/workflow-agent-cursor @uncaged/workflow @uncaged/workflow-util-agent zod
|
||||
```
|
||||
|
||||
In this monorepo: `"@uncaged/workflow-agent-cursor": "workspace:*"` plus `workspace:*` for `@uncaged/workflow` and `@uncaged/workflow-util-agent`.
|
||||
|
||||
## Usage
|
||||
|
||||
```typescript
|
||||
import { createCursorAgent } from "@uncaged/workflow-agent-cursor";
|
||||
|
||||
const agent = createCursorAgent({
|
||||
model: null, // null → "auto"
|
||||
timeout: 0, // ms; 0 = no limit (spawnCli timeout disabled)
|
||||
extract: myExtractFn,
|
||||
});
|
||||
```
|
||||
|
||||
## API overview
|
||||
|
||||
| Export | Description |
|
||||
|--------|-------------|
|
||||
| `createCursorAgent(config)` | Returns `AgentFn` that runs `cursor-agent` with `buildAgentPrompt(ctx)` |
|
||||
| `CursorAgentConfig` | `model`, `timeout`, `extract` (must supply workspace path) |
|
||||
| `validateCursorAgentConfig` | Config validation result |
|
||||
| `buildAgentPrompt` | Re-exported from `@uncaged/workflow-util-agent` |
|
||||
|
||||
Requires `cursor-agent` on `PATH` at runtime.
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type { ExtractContext, ExtractFn } from "@uncaged/workflow-runtime";
|
||||
import type { ExtractContext, ExtractFn } from "@uncaged/workflow";
|
||||
import type * as z from "zod/v4";
|
||||
import { createCursorAgent, validateCursorAgentConfig } from "../src/index.js";
|
||||
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-agent-cursor",
|
||||
"version": "0.2.0",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"scripts": {
|
||||
"build": "echo 'TODO'",
|
||||
"test": "bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/workflow-runtime": "workspace:*",
|
||||
"@uncaged/workflow": "workspace:*",
|
||||
"@uncaged/workflow-util-agent": "workspace:*",
|
||||
"zod": "^4.0.0"
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AgentFn, ExtractContext } from "@uncaged/workflow-runtime";
|
||||
import type { AgentFn, ExtractContext } from "@uncaged/workflow";
|
||||
import { buildAgentPrompt, type SpawnCliError, spawnCli } from "@uncaged/workflow-util-agent";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ExtractFn } from "@uncaged/workflow-runtime";
|
||||
import type { ExtractFn } from "@uncaged/workflow";
|
||||
|
||||
export type CursorAgentConfig = {
|
||||
model: string | null;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { err, ok, type Result } from "@uncaged/workflow-runtime";
|
||||
import { err, ok, type Result } from "@uncaged/workflow";
|
||||
|
||||
import type { CursorAgentConfig } from "./types.js";
|
||||
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"composite": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"references": [{ "path": "../workflow-runtime" }, { "path": "../workflow-util-agent" }]
|
||||
"references": [{ "path": "../workflow" }, { "path": "../workflow-util-agent" }]
|
||||
}
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
# @uncaged/workflow-agent-hermes
|
||||
|
||||
`AgentFn` adapter that runs the `hermes` CLI in non-interactive `chat` mode (Nerve-style flags: `-q`, `--yolo`, `--quiet`, bounded `--max-turns`).
|
||||
|
||||
The agent composes the same thread-aware prompt as other CLI-backed agents via `buildAgentPrompt` from `@uncaged/workflow-util-agent`, then spawns `hermes` and returns stdout on success.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @uncaged/workflow-agent-hermes @uncaged/workflow @uncaged/workflow-util-agent
|
||||
```
|
||||
|
||||
In this monorepo: use `workspace:*` for all three `@uncaged/*` packages.
|
||||
|
||||
## Usage
|
||||
|
||||
```typescript
|
||||
import { createHermesAgent } from "@uncaged/workflow-agent-hermes";
|
||||
|
||||
const agent = createHermesAgent({
|
||||
model: "your-model", // or null to omit --model
|
||||
timeout: 600_000, // ms, or null for no timeout
|
||||
});
|
||||
```
|
||||
|
||||
## API overview
|
||||
|
||||
| Export | Description |
|
||||
|--------|-------------|
|
||||
| `createHermesAgent(config)` | Returns `AgentFn` wrapping `hermes chat -q ...` |
|
||||
| `HermesAgentConfig` | `model`, `timeout` |
|
||||
| `validateHermesAgentConfig` | Config validation result |
|
||||
| `buildAgentPrompt` | Re-exported from `@uncaged/workflow-util-agent` |
|
||||
|
||||
Requires `hermes` on `PATH` at runtime.
|
||||
@@ -1,14 +1,15 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-agent-hermes",
|
||||
"version": "0.2.0",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"scripts": {
|
||||
"build": "echo 'TODO'",
|
||||
"test": "bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/workflow-runtime": "workspace:*",
|
||||
"@uncaged/workflow": "workspace:*",
|
||||
"@uncaged/workflow-util-agent": "workspace:*"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AgentFn } from "@uncaged/workflow-runtime";
|
||||
import type { AgentFn } from "@uncaged/workflow";
|
||||
import { buildAgentPrompt, type SpawnCliError, spawnCli } from "@uncaged/workflow-util-agent";
|
||||
|
||||
import type { HermesAgentConfig } from "./types.js";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { err, ok, type Result } from "@uncaged/workflow-runtime";
|
||||
import { err, ok, type Result } from "@uncaged/workflow";
|
||||
|
||||
import type { HermesAgentConfig } from "./types.js";
|
||||
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"composite": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"references": [{ "path": "../workflow-runtime" }, { "path": "../workflow-util-agent" }]
|
||||
"references": [{ "path": "../workflow" }, { "path": "../workflow-util-agent" }]
|
||||
}
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
# @uncaged/workflow-agent-llm
|
||||
|
||||
`AgentFn` adapter that calls an OpenAI-compatible `POST /chat/completions` endpoint using `@uncaged/workflow`’s `LlmProvider` (base URL, API key, model).
|
||||
|
||||
Single-turn: system text is the current role’s `systemPrompt`, user text is the thread’s initial prompt (`ctx.start.content`). Errors from HTTP, JSON, or empty choices are thrown as `Error` with a JSON payload string.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @uncaged/workflow-agent-llm @uncaged/workflow
|
||||
```
|
||||
|
||||
In this monorepo: `"@uncaged/workflow-agent-llm": "workspace:*"`, `"@uncaged/workflow": "workspace:*"`.
|
||||
|
||||
## Usage
|
||||
|
||||
```typescript
|
||||
import { createLlmAdapter } from "@uncaged/workflow-agent-llm";
|
||||
|
||||
const agent = createLlmAdapter({
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
apiKey: process.env.OPENAI_API_KEY!,
|
||||
model: "gpt-4.1-mini",
|
||||
});
|
||||
```
|
||||
|
||||
## API overview
|
||||
|
||||
| Export | Description |
|
||||
|--------|-------------|
|
||||
| `createLlmAdapter(provider)` | `LlmProvider` → `AgentFn` |
|
||||
| `chatCompletionText({ provider, messages })` | Low-level `Result<string, LlmChatError>` helper |
|
||||
| `LlmMessage` | `{ role: "system" \| "user" \| "assistant"; content: string }` |
|
||||
| `LlmChatError` | Discriminated error kinds for failed completions |
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user