Compare commits
139 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b8b557baf6 | |||
| 727b4bb3ed | |||
| 9bbdfc41bd | |||
| b07f8cf166 | |||
| 1a1e8b3398 | |||
| 39d2a61686 | |||
| bf0bc47a3f | |||
| 2cffaad127 | |||
| 9a3daac657 | |||
| b8f9ffcb59 | |||
| a7171f05f6 | |||
| b53667a2aa | |||
| 5b60fa6454 | |||
| 2c0e744ebf | |||
| ae16f09688 | |||
| 73a3638ad9 | |||
| 7b0260cedd | |||
| 61fc1cfe1b | |||
| 6b1e728700 | |||
| dedab62c49 | |||
| a44f1f34a8 | |||
| 8ff6f7e778 | |||
| e04e75bdee | |||
| c65c29c1b5 | |||
| cc3f2b576c | |||
| 884ff85205 | |||
| a11cc62a81 | |||
| 34f5e655d1 | |||
| 44fb0694aa | |||
| cdcaff15ab | |||
| 402479ddef | |||
| a28dd3050e | |||
| ce0d0a962c | |||
| 46b552ec01 | |||
| 587518ac09 | |||
| e9e4960714 | |||
| 495c000356 | |||
| 7e662f9287 | |||
| 3ed38c65ec | |||
| 38f2b0eeb2 | |||
| 586a0f824e | |||
| 178f6c7519 | |||
| 3153ab26f6 | |||
| 014c442ed2 | |||
| 1f7851d5e3 | |||
| e68790dfc7 | |||
| 520b17b351 | |||
| 085cdcd3f4 | |||
| a8c1c158d6 | |||
| 83649fd836 | |||
| a5c09adae6 | |||
| 9e6cd9d615 | |||
| 1f1128ff4a | |||
| aa01283ce1 | |||
| f81e2a8aac | |||
| 2b38e583be | |||
| 4ff1394224 | |||
| 2bbe5a3d0e | |||
| a4237c0462 | |||
| 321e5b1379 | |||
| 7c3e14c473 | |||
| aecce595e8 | |||
| cf17dedac3 | |||
| 661fdbb263 | |||
| 201abf98ce | |||
| 665965fd01 | |||
| 6a99f84025 | |||
| f61474bec0 | |||
| 9bdb18afd0 | |||
| 2af299f3ce | |||
| d9f79c60a1 | |||
| 485bfcb0b6 | |||
| a47ed06ea5 | |||
| 2ef004eecf | |||
| 2616259a0f | |||
| 23b2c3b47d | |||
| 7d3954097d | |||
| 4a925b98af | |||
| bfea771a52 | |||
| 5e411a1f19 | |||
| 21238f7825 | |||
| 6b3aa4ce35 | |||
| f042c9d640 | |||
| 66bca9ef03 | |||
| 309af39447 | |||
| 86a422f7e2 | |||
| 648f0c6dec | |||
| 8456a8337b | |||
| 9c8b98a551 | |||
| c3272be760 | |||
| c44b773a86 | |||
| 2776f8e419 | |||
| 7b0e256c13 | |||
| c663ba9e9c | |||
| 71b413f20c | |||
| 61be1c662a | |||
| 84e8d70da4 | |||
| 8976f4cf3b | |||
| 07730dd24c | |||
| 4eff4d2370 | |||
| 1d6da18b18 | |||
| c342ff3737 | |||
| 8fe26417cf | |||
| 990200230b | |||
| 4eaefd9974 | |||
| 1a685583bd | |||
| 19769efea6 | |||
| 7f64541c5b | |||
| 43a6600378 | |||
| 74e3f5434c | |||
| 220c9c5224 | |||
| cae59b589e | |||
| 703ac9dfcc | |||
| 2df8accf2f | |||
| b5cc0db17e | |||
| 6196e0974a | |||
| 410e9e6d9b | |||
| 84de74721d | |||
| 4403532f35 | |||
| e95e76c145 | |||
| af69e773a0 | |||
| 6488b7bbb4 | |||
| 15d39c96a7 | |||
| 30e4e99908 | |||
| a3c70a5041 | |||
| 12d58a8206 | |||
| c096f4d94e | |||
| 500401d93c | |||
| 43f466eb67 | |||
| fe829d9ae6 | |||
| f80535d742 | |||
| 0eab3b7001 | |||
| 37c5b89c98 | |||
| 0fdf19879a | |||
| f73bf1e313 | |||
| 8c4441bf6b | |||
| 341ff656dc | |||
| 4b44665c7e | |||
| 172e9b34cc |
@@ -3,3 +3,4 @@ dist/
|
|||||||
bun.lock
|
bun.lock
|
||||||
*.tgz
|
*.tgz
|
||||||
tsconfig.tsbuildinfo
|
tsconfig.tsbuildinfo
|
||||||
|
.npmrc
|
||||||
|
|||||||
@@ -97,6 +97,36 @@ type WorkflowEntry = {
|
|||||||
|
|
||||||
Workflow bundles (`.esm.js`) follow the same rule: export `const run` and `const descriptor`, not `export default`.
|
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
|
## Naming
|
||||||
|
|
||||||
| Type | Style | Example |
|
| Type | Style | Example |
|
||||||
@@ -197,9 +227,8 @@ Test files (`__tests__/**`) are exempt.
|
|||||||
### Commands
|
### Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bun run check # biome check (lint + format)
|
bun run check # tsc --build + biome check
|
||||||
bun run format # biome format --write
|
bun run format # biome format --write
|
||||||
bun run build # full build
|
|
||||||
bun test # run tests
|
bun test # run tests
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# @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",
|
"$schema": "https://biomejs.dev/schemas/2.4.14/schema.json",
|
||||||
"files": {
|
"files": {
|
||||||
"includes": ["**", "!**/dist", "!**/node_modules"]
|
"includes": ["**", "!**/dist", "!**/node_modules", "!packages/workflow/workflow"]
|
||||||
},
|
},
|
||||||
"assist": { "actions": { "source": { "organizeImports": "on" } } },
|
"assist": { "actions": { "source": { "organizeImports": "on" } } },
|
||||||
"formatter": {
|
"formatter": {
|
||||||
|
|||||||
@@ -17,11 +17,8 @@ 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-cursor` | `@uncaged/workflow-agent-cursor` | Cursor CLI agent (extracts workspace from ctx) |
|
||||||
| `workflow-agent-hermes` | `@uncaged/workflow-agent-hermes` | Hermes CLI agent |
|
| `workflow-agent-hermes` | `@uncaged/workflow-agent-hermes` | Hermes CLI agent |
|
||||||
| `workflow-agent-llm` | `@uncaged/workflow-agent-llm` | OpenAI-compatible LLM agent |
|
| `workflow-agent-llm` | `@uncaged/workflow-agent-llm` | OpenAI-compatible LLM agent |
|
||||||
| `workflow-role-planner` | `@uncaged/workflow-role-planner` | Pure data: phased planning prompt + schema |
|
| `workflow-template-develop` | `@uncaged/workflow-template-develop` | Develop workflow template (roles in `src/roles/`) |
|
||||||
| `workflow-role-coder` | `@uncaged/workflow-role-coder` | Pure data: coding prompt + schema |
|
| `workflow-template-solve-issue` | `@uncaged/workflow-template-solve-issue` | Solve-issue workflow template (roles in `src/roles/`) |
|
||||||
| `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 |
|
| `workflow-util-agent` | `@uncaged/workflow-util-agent` | `buildAgentPrompt` + `spawnCli` utilities |
|
||||||
|
|
||||||
Monorepo with **bun workspace**, `workspace:*` protocol.
|
Monorepo with **bun workspace**, `workspace:*` protocol.
|
||||||
|
|||||||
@@ -0,0 +1,315 @@
|
|||||||
|
# Workflow-as-Agent Implementation Plan
|
||||||
|
|
||||||
|
> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** Enable workflows to invoke other workflows as agents, backed by global CAS and refs tracking.
|
||||||
|
|
||||||
|
**Architecture:** Migrate CAS from thread-local to global (`~/.uncaged/workflow/cas/`), add `refs` to RoleStep for GC traceability, then build `workflowAsAgent(name)` factory that resolves workflow name → bundle via registry and spawns a child thread.
|
||||||
|
|
||||||
|
**Tech Stack:** TypeScript, Bun, Zod v4, monorepo with `packages/`
|
||||||
|
|
||||||
|
**Issue:** https://git.shazhou.work/uncaged/workflow/issues/25
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: Global CAS Migration
|
||||||
|
|
||||||
|
Move CAS storage from `<threadDir>/<threadId>.cas/` to `~/.uncaged/workflow/cas/` (global, content-addressed, immutable). This is a **breaking change** — thread-local `.cas/` directories are abandoned.
|
||||||
|
|
||||||
|
### Task 1.1: Add `globalCasDir` helper to `storage-root.ts`
|
||||||
|
|
||||||
|
**Objective:** Provide a single function that returns the global CAS directory path.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `packages/workflow/src/storage-root.ts`
|
||||||
|
- Test: `packages/workflow/__tests__/storage-root.test.ts`
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// storage-root.ts — add export
|
||||||
|
export function getGlobalCasDir(storageRoot?: string): string {
|
||||||
|
const root = storageRoot ?? getDefaultWorkflowStorageRoot();
|
||||||
|
return join(root, "cas");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Export from `packages/workflow/src/index.ts`.
|
||||||
|
|
||||||
|
### Task 1.2: Update `cmd-cas.ts` to use global CAS
|
||||||
|
|
||||||
|
**Objective:** CLI `cas get/put/list/rm` no longer needs threadId for storage location — CAS is global. But keep threadId in CLI for backward compat of planner/coder prompts (they pass threadId).
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `packages/cli-workflow/src/cmd-cas.ts`
|
||||||
|
|
||||||
|
**Changes:**
|
||||||
|
- `resolveCasDir` → use `getGlobalCasDir(storageRoot)` instead of deriving from thread data path
|
||||||
|
- `cmdCasPut` / `cmdCasGet` / `cmdCasList` / `cmdCasRm`: threadId is still accepted (prompts pass it) but storage goes to global dir
|
||||||
|
- Remove the `resolveThreadDataPath` dependency for CAS operations — thread doesn't need to exist to read CAS
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { createThreadCas, getGlobalCasDir } from "@uncaged/workflow";
|
||||||
|
|
||||||
|
export async function cmdCasGet(
|
||||||
|
storageRoot: string,
|
||||||
|
_threadId: string, // kept for CLI compat, not used for path
|
||||||
|
hash: string,
|
||||||
|
): Promise<Result<string, string>> {
|
||||||
|
const cas = createThreadCas(getGlobalCasDir(storageRoot));
|
||||||
|
const content = await cas.get(hash);
|
||||||
|
if (content === null) {
|
||||||
|
return err(`cas entry not found: ${hash}`);
|
||||||
|
}
|
||||||
|
return ok(content);
|
||||||
|
}
|
||||||
|
// ... same pattern for put/list/rm
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 1.3: Update `cmd-thread.ts` — thread rm no longer deletes `.cas/`
|
||||||
|
|
||||||
|
**Objective:** Since CAS is global, `thread rm` should NOT delete CAS entries. CAS cleanup is GC's job.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `packages/cli-workflow/src/cmd-thread.ts`
|
||||||
|
- Check: remove any `rmdir` / `unlink` of `<threadId>.cas/` directory
|
||||||
|
|
||||||
|
### Task 1.4: Rename `createThreadCas` → `createCasStore`
|
||||||
|
|
||||||
|
**Objective:** The name `createThreadCas` is misleading now. Rename to `createCasStore`.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `packages/workflow/src/cas.ts` — rename function
|
||||||
|
- Modify: `packages/workflow/src/index.ts` — update export (keep `createThreadCas` as deprecated alias for one release)
|
||||||
|
- Modify: all consumers (`cmd-cas.ts`)
|
||||||
|
|
||||||
|
### Task 1.5: Update tests
|
||||||
|
|
||||||
|
**Objective:** All CAS-related tests use global dir instead of thread-local.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `packages/cli-workflow/__tests__/commands.test.ts`
|
||||||
|
- Verify: `bun test` passes
|
||||||
|
|
||||||
|
### Task 1.6: Clean up old thread-local `.cas/` references
|
||||||
|
|
||||||
|
**Objective:** Remove dead code that creates/reads thread-local `.cas/` directories.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Search all `*.ts` for `.cas` path construction patterns
|
||||||
|
- Remove orphaned helpers
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: RoleStep `refs` Tracking
|
||||||
|
|
||||||
|
Add `refs: string[]` to persisted role steps so GC can trace which CAS entries are alive.
|
||||||
|
|
||||||
|
### Task 2.1: Add `refs` to `RoleOutput` and engine persistence
|
||||||
|
|
||||||
|
**Objective:** Every role step can declare which CAS hashes it produced or consumed.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `packages/workflow/src/types.ts`
|
||||||
|
- Modify: `packages/workflow/src/engine.ts`
|
||||||
|
|
||||||
|
**Changes to `types.ts`:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export type RoleOutput = {
|
||||||
|
role: string;
|
||||||
|
content: string;
|
||||||
|
meta: Record<string, unknown>;
|
||||||
|
refs: string[]; // CAS hashes produced/consumed by this step
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**Changes to `engine.ts`:**
|
||||||
|
- `appendDataLine` for role steps: include `refs` field (default `[]` if not provided)
|
||||||
|
|
||||||
|
### Task 2.2: Auto-populate refs from meta hashes
|
||||||
|
|
||||||
|
**Objective:** The engine should automatically extract CAS hashes from `meta` to populate `refs`, so roles don't need to manually track them.
|
||||||
|
|
||||||
|
**Strategy:** After meta extraction, walk the meta object and collect any string that looks like a CAS hash (Crockford Base32, 13 chars). This is a heuristic but works because CAS hashes are distinctive.
|
||||||
|
|
||||||
|
Alternative (simpler): Let each `RoleDefinition` optionally declare a `extractRefs(meta: M) => string[]` function. For planner, this returns `meta.phases.map(p => p.hash)`. For coder, `[meta.completedPhase]`.
|
||||||
|
|
||||||
|
**Recommended:** The explicit `extractRefs` approach — no magic, no false positives.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `packages/workflow/src/types.ts` — add optional `extractRefs` to `RoleDefinition`
|
||||||
|
- Modify: `packages/workflow/src/create-workflow.ts` — call `extractRefs` after meta extraction, set on `RoleOutput.refs`
|
||||||
|
- Modify: `packages/workflow-role-planner/src/planner.ts` — implement `extractRefs`
|
||||||
|
- Modify: `packages/workflow-role-coder/src/coder.ts` — implement `extractRefs`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// types.ts — RoleDefinition addition
|
||||||
|
export type RoleDefinition<Meta extends Record<string, unknown>> = {
|
||||||
|
description: string;
|
||||||
|
systemPrompt: string;
|
||||||
|
extractPrompt: string;
|
||||||
|
schema: z.ZodType<Meta>;
|
||||||
|
extractRefs?: (meta: Meta) => string[]; // CAS hashes to track
|
||||||
|
};
|
||||||
|
|
||||||
|
// planner.ts
|
||||||
|
extractRefs: (meta) => meta.phases.map(p => p.hash),
|
||||||
|
|
||||||
|
// coder.ts
|
||||||
|
extractRefs: (meta) => [meta.completedPhase],
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 2.3: Update fork logic to preserve refs
|
||||||
|
|
||||||
|
**Objective:** When forking a thread, `refs` from historical steps must be carried over.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `packages/workflow/src/fork-thread.ts`
|
||||||
|
- Verify: `ForkHistoricalStep` / `PrefilledDiskStep` include `refs`
|
||||||
|
|
||||||
|
### Task 2.4: Tests for refs tracking
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Add: `packages/workflow/__tests__/refs-tracking.test.ts`
|
||||||
|
- Verify: refs appear in `.data.jsonl` output
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: CAS Garbage Collection
|
||||||
|
|
||||||
|
### Task 3.1: Implement `gc.ts` in `@uncaged/workflow`
|
||||||
|
|
||||||
|
**Objective:** Mark-and-sweep GC — scan all thread `.data.jsonl` files, collect `refs`, delete orphaned CAS entries.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `packages/workflow/src/gc.ts`
|
||||||
|
- Export from: `packages/workflow/src/index.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export type GcResult = {
|
||||||
|
scannedThreads: number;
|
||||||
|
activeRefs: number;
|
||||||
|
deletedEntries: number;
|
||||||
|
deletedHashes: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function garbageCollectCas(storageRoot: string): Promise<GcResult> {
|
||||||
|
// 1. Find all .data.jsonl files under storageRoot
|
||||||
|
// 2. Parse each, flatMap step.refs → Set<string>
|
||||||
|
// 3. List all CAS entries via createCasStore(globalCasDir).list()
|
||||||
|
// 4. Delete entries not in active set
|
||||||
|
// 5. Return stats
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 3.2: Add `uncaged-workflow gc` CLI command
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `packages/cli-workflow/src/cmd-gc.ts`
|
||||||
|
- Modify: `packages/cli-workflow/src/cli-dispatch.ts` — add `gc` subcommand
|
||||||
|
|
||||||
|
### Task 3.3: Run GC on `thread rm`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `packages/cli-workflow/src/cmd-thread.ts` — after deleting thread data, optionally run GC
|
||||||
|
|
||||||
|
### Task 3.4: Tests for GC
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `packages/cli-workflow/__tests__/gc-cli.test.ts`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: `workflowAsAgent` Factory
|
||||||
|
|
||||||
|
### Task 4.1: Create `workflowAsAgent` in `@uncaged/workflow`
|
||||||
|
|
||||||
|
**Objective:** Factory function that takes a workflow name, resolves to bundle, returns an `AgentFn`.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `packages/workflow/src/workflow-as-agent.ts`
|
||||||
|
- Export from: `packages/workflow/src/index.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import type { AgentFn } from "./types.js";
|
||||||
|
|
||||||
|
export type WorkflowAsAgentOptions = {
|
||||||
|
storageRoot?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function workflowAsAgent(
|
||||||
|
workflowName: string,
|
||||||
|
options?: WorkflowAsAgentOptions,
|
||||||
|
): AgentFn {
|
||||||
|
return async (ctx) => {
|
||||||
|
const storageRoot = options?.storageRoot ?? getDefaultWorkflowStorageRoot();
|
||||||
|
|
||||||
|
// 1. Read registry → resolve name to bundle hash + path
|
||||||
|
const registry = await readWorkflowRegistry(storageRoot);
|
||||||
|
const entry = getRegisteredWorkflow(registry, workflowName);
|
||||||
|
if (entry === null) {
|
||||||
|
return `ERROR: workflow "${workflowName}" not found in registry`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Load bundle
|
||||||
|
const bundlePath = join(storageRoot, "bundles", `${entry.hash}.esm.js`);
|
||||||
|
const bundleExports = await extractBundleExports(bundlePath);
|
||||||
|
|
||||||
|
// 3. Create child thread input from ctx.start.content (parent prompt)
|
||||||
|
const input: ThreadInput = {
|
||||||
|
prompt: ctx.start.content,
|
||||||
|
steps: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
// 4. Generate child threadId
|
||||||
|
const childThreadId = generateUlid();
|
||||||
|
|
||||||
|
// 5. Execute — collect all yields, return final content
|
||||||
|
const io: ExecuteThreadIo = { ... };
|
||||||
|
const result = await executeThread(bundleExports.run, workflowName, input, ...);
|
||||||
|
|
||||||
|
// 6. Return summary as agent content
|
||||||
|
return result.summary;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 4.2: System-level depth limit
|
||||||
|
|
||||||
|
**Objective:** Prevent infinite recursion. Track depth via thread metadata, enforce a global max (default 3, configurable in `workflow.yaml`).
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `packages/workflow/src/types.ts` — add `depth` to `WorkflowFnOptions`
|
||||||
|
- Modify: `packages/workflow/src/workflow-as-agent.ts` — increment depth, check limit
|
||||||
|
- Modify: registry or config types for `maxDepth` setting
|
||||||
|
|
||||||
|
### Task 4.3: Tests for workflowAsAgent
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `packages/workflow/__tests__/workflow-as-agent.test.ts`
|
||||||
|
- Test: name resolution, depth limit, child thread execution
|
||||||
|
|
||||||
|
### Task 4.4: Integration test — nested workflow
|
||||||
|
|
||||||
|
**Objective:** Create a minimal test workflow that calls another workflow via `workflowAsAgent`.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `packages/workflow/__tests__/workflow-as-agent-integration.test.ts`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Execution Order
|
||||||
|
|
||||||
|
```
|
||||||
|
Phase 1 (Global CAS) → Phase 2 (refs) → Phase 3 (GC) → Phase 4 (workflowAsAgent)
|
||||||
|
```
|
||||||
|
|
||||||
|
Each phase is independently mergeable. Phase 3 depends on Phase 2 (needs refs to know what's alive). Phase 4 depends on Phase 1 (global CAS for cross-thread sharing).
|
||||||
|
|
||||||
|
## Breaking Changes
|
||||||
|
|
||||||
|
- CAS storage location moves from `<thread>.cas/` to `~/.uncaged/workflow/cas/`
|
||||||
|
- `RoleOutput` gains required `refs: string[]` field
|
||||||
|
- Existing threads with thread-local CAS will lose access to old CAS data (acceptable — those are short-lived workflow artifacts)
|
||||||
|
- `createThreadCas` renamed to `createCasStore` (alias kept temporarily)
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
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,
|
|
||||||
};
|
|
||||||
|
|
||||||
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,
|
|
||||||
);
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@uncaged/workflow-examples",
|
|
||||||
"private": true,
|
|
||||||
"type": "module",
|
|
||||||
"dependencies": {
|
|
||||||
"@uncaged/workflow": "workspace:*",
|
|
||||||
"zod": "^4.0.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+2
-3
@@ -2,11 +2,10 @@
|
|||||||
"name": "@uncaged/workflow-monorepo",
|
"name": "@uncaged/workflow-monorepo",
|
||||||
"private": true,
|
"private": true,
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"packages/*",
|
"packages/*"
|
||||||
"examples"
|
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "bun run --filter '*' build",
|
"build": "bunx tsc --build",
|
||||||
"check": "bunx tsc --build && biome check .",
|
"check": "bunx tsc --build && biome check .",
|
||||||
"typecheck": "bunx tsc --build",
|
"typecheck": "bunx tsc --build",
|
||||||
"format": "biome format --write .",
|
"format": "biome format --write .",
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# @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/cmd-add.js";
|
import type { ParsedAddArgv } from "../src/commands/workflow/index.js";
|
||||||
|
|
||||||
export function addCliArgs(name: string, filePath: string): ParsedAddArgv {
|
export function addCliArgs(name: string, filePath: string): ParsedAddArgv {
|
||||||
return { name, filePath, typesPath: null };
|
return { name, filePath, typesPath: null };
|
||||||
|
|||||||
@@ -3,18 +3,31 @@ import { mkdir, mkdtemp, readFile, rm, unlink, writeFile } from "node:fs/promise
|
|||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
|
||||||
import { getRegisteredWorkflow, readWorkflowRegistry } from "@uncaged/workflow";
|
import { getGlobalCasDir } from "@uncaged/workflow-util";
|
||||||
import { cmdAdd } from "../src/cmd-add.js";
|
import { createContentMerkleNode, serializeMerkleNode } from "@uncaged/workflow-cas";
|
||||||
import { cmdHistory } from "../src/cmd-history.js";
|
import { getRegisteredWorkflow, readWorkflowRegistry } from "@uncaged/workflow-register";
|
||||||
import { cmdList, formatListLines } from "../src/cmd-list.js";
|
import { cmdCasGet, cmdCasList, cmdCasPut, cmdCasRm } from "../src/commands/cas/index.js";
|
||||||
import { cmdRemove } from "../src/cmd-remove.js";
|
import {
|
||||||
import { cmdRollback } from "../src/cmd-rollback.js";
|
cmdAdd,
|
||||||
import { cmdShow } from "../src/cmd-show.js";
|
cmdHistory,
|
||||||
|
cmdList,
|
||||||
|
cmdRemove,
|
||||||
|
cmdRollback,
|
||||||
|
cmdShow,
|
||||||
|
formatListLines,
|
||||||
|
} from "../src/commands/workflow/index.js";
|
||||||
import { addCliArgs } from "./bundle-fixture.js";
|
import { addCliArgs } from "./bundle-fixture.js";
|
||||||
|
|
||||||
const fixtureDescriptor = `export const descriptor = { description: "fixture", roles: {} };
|
const fixtureDescriptor = `export const descriptor = { description: "fixture", roles: {} };
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const wfPutImport = `import { putContentMerkleNode } from "@uncaged/workflow-cas";
|
||||||
|
`;
|
||||||
|
|
||||||
|
function casStoredForm(raw: string): string {
|
||||||
|
return serializeMerkleNode(createContentMerkleNode(raw));
|
||||||
|
}
|
||||||
|
|
||||||
describe("cli workflow commands", () => {
|
describe("cli workflow commands", () => {
|
||||||
let prevEnv: string | undefined;
|
let prevEnv: string | undefined;
|
||||||
let storageRoot: string;
|
let storageRoot: string;
|
||||||
@@ -40,11 +53,13 @@ describe("cli workflow commands", () => {
|
|||||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||||
await writeFile(
|
await writeFile(
|
||||||
bundlePath,
|
bundlePath,
|
||||||
`${fixtureDescriptor}import fs from "node:fs";
|
`${fixtureDescriptor}${wfPutImport}import fs from "node:fs";
|
||||||
|
|
||||||
export const run = async function* (input) {
|
export const run = async function* (input, options) {
|
||||||
fs.existsSync(".");
|
fs.existsSync(".");
|
||||||
yield { role: "noop", content: input.prompt, meta: { done: true } };
|
const cas = options.cas;
|
||||||
|
const h = await putContentMerkleNode(cas, input.prompt);
|
||||||
|
yield { role: "noop", contentHash: h, meta: { done: true }, refs: [h] };
|
||||||
return { returnCode: 0, summary: "done" };
|
return { returnCode: 0, summary: "done" };
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
@@ -111,8 +126,8 @@ export const run = async function* (input) { return { returnCode: 0, summary: in
|
|||||||
const bundlePath = join(storageRoot, "solo.esm.js");
|
const bundlePath = join(storageRoot, "solo.esm.js");
|
||||||
await writeFile(
|
await writeFile(
|
||||||
bundlePath,
|
bundlePath,
|
||||||
`export const run = async function* (input) {
|
`export const run = async function* () {
|
||||||
yield { role: "x", content: input.prompt, meta: {} };
|
yield { role: "x", contentHash: "STUBHASH00000000000000001", meta: {}, refs: [] };
|
||||||
return { returnCode: 0, summary: "ok" };
|
return { returnCode: 0, summary: "ok" };
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
@@ -140,8 +155,11 @@ export const run = async function* (input) { return { returnCode: 0, summary: in
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
export const run = async function* (input) {
|
${wfPutImport}
|
||||||
yield { role: "greeter", content: input.prompt, meta: { greeting: "hi" } };
|
export const run = async function* (input, options) {
|
||||||
|
const cas = options.cas;
|
||||||
|
const h = await putContentMerkleNode(cas, input.prompt);
|
||||||
|
yield { role: "greeter", contentHash: h, meta: { greeting: "hi" }, refs: [h] };
|
||||||
return { returnCode: 0, summary: "ok" };
|
return { returnCode: 0, summary: "ok" };
|
||||||
};
|
};
|
||||||
`,
|
`,
|
||||||
@@ -179,8 +197,10 @@ export const run = async function* (input) {
|
|||||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||||
await writeFile(
|
await writeFile(
|
||||||
bundlePath,
|
bundlePath,
|
||||||
`${fixtureDescriptor}export const run = async function* (input) {
|
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||||
yield { role: "a", content: "x", meta: {} };
|
const cas = options.cas;
|
||||||
|
const h = await putContentMerkleNode(cas, "x");
|
||||||
|
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||||
return { returnCode: 0, summary: "x" };
|
return { returnCode: 0, summary: "x" };
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
@@ -208,8 +228,10 @@ export const run = async function* (input) {
|
|||||||
const dtsPath = join(bundleDir, "types.d.ts");
|
const dtsPath = join(bundleDir, "types.d.ts");
|
||||||
await writeFile(
|
await writeFile(
|
||||||
bundlePath,
|
bundlePath,
|
||||||
`${fixtureDescriptor}export const run = async function* (input) {
|
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||||
yield { role: "a", content: "x", meta: {} };
|
const cas = options.cas;
|
||||||
|
const h = await putContentMerkleNode(cas, "x");
|
||||||
|
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||||
return { returnCode: 0, summary: "x" };
|
return { returnCode: 0, summary: "x" };
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
@@ -239,8 +261,10 @@ export const run = async function* (input) {
|
|||||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||||
await writeFile(
|
await writeFile(
|
||||||
bundlePath,
|
bundlePath,
|
||||||
`${fixtureDescriptor}export const run = async function* (input) {
|
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||||
yield { role: "a", content: "x", meta: {} };
|
const cas = options.cas;
|
||||||
|
const h = await putContentMerkleNode(cas, "x");
|
||||||
|
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||||
return { returnCode: 0, summary: "x" };
|
return { returnCode: 0, summary: "x" };
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
@@ -260,13 +284,17 @@ export const run = async function* (input) {
|
|||||||
const bundleDir = join(storageRoot, "src");
|
const bundleDir = join(storageRoot, "src");
|
||||||
await mkdir(bundleDir, { recursive: true });
|
await mkdir(bundleDir, { recursive: true });
|
||||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||||
const v1 = `${fixtureDescriptor}export const run = async function* (input) {
|
const v1 = `${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||||
yield { role: "a", content: "v1", meta: {} };
|
const cas = options.cas;
|
||||||
|
const h = await putContentMerkleNode(cas, "v1");
|
||||||
|
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||||
return { returnCode: 0, summary: "v1" };
|
return { returnCode: 0, summary: "v1" };
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
const v2 = `${fixtureDescriptor}export const run = async function* (input) {
|
const v2 = `${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||||
yield { role: "a", content: "v2", meta: {} };
|
const cas = options.cas;
|
||||||
|
const h = await putContentMerkleNode(cas, "v2");
|
||||||
|
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||||
return { returnCode: 0, summary: "v2" };
|
return { returnCode: 0, summary: "v2" };
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
@@ -298,13 +326,17 @@ export const run = async function* (input) {
|
|||||||
const bundleDir = join(storageRoot, "src");
|
const bundleDir = join(storageRoot, "src");
|
||||||
await mkdir(bundleDir, { recursive: true });
|
await mkdir(bundleDir, { recursive: true });
|
||||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||||
const v1 = `${fixtureDescriptor}export const run = async function* (input) {
|
const v1 = `${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||||
yield { role: "a", content: "v1", meta: {} };
|
const cas = options.cas;
|
||||||
|
const h = await putContentMerkleNode(cas, "v1");
|
||||||
|
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||||
return { returnCode: 0, summary: "v1" };
|
return { returnCode: 0, summary: "v1" };
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
const v2 = `${fixtureDescriptor}export const run = async function* (input) {
|
const v2 = `${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||||
yield { role: "a", content: "v2", meta: {} };
|
const cas = options.cas;
|
||||||
|
const h = await putContentMerkleNode(cas, "v2");
|
||||||
|
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||||
return { returnCode: 0, summary: "v2" };
|
return { returnCode: 0, summary: "v2" };
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
@@ -346,8 +378,10 @@ export const run = async function* (input) {
|
|||||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||||
await writeFile(
|
await writeFile(
|
||||||
bundlePath,
|
bundlePath,
|
||||||
`${fixtureDescriptor}export const run = async function* (input) {
|
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||||
yield { role: "a", content: "x", meta: {} };
|
const cas = options.cas;
|
||||||
|
const h = await putContentMerkleNode(cas, "x");
|
||||||
|
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||||
return { returnCode: 0, summary: "x" };
|
return { returnCode: 0, summary: "x" };
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
@@ -357,8 +391,10 @@ export const run = async function* (input) {
|
|||||||
expect(add1.ok).toBe(true);
|
expect(add1.ok).toBe(true);
|
||||||
await writeFile(
|
await writeFile(
|
||||||
bundlePath,
|
bundlePath,
|
||||||
`${fixtureDescriptor}export const run = async function* (input) {
|
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||||
yield { role: "a", content: "y", meta: {} };
|
const cas = options.cas;
|
||||||
|
const h = await putContentMerkleNode(cas, "y");
|
||||||
|
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||||
return { returnCode: 0, summary: "y" };
|
return { returnCode: 0, summary: "y" };
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
@@ -371,14 +407,49 @@ export const run = async function* (input) {
|
|||||||
expect(bad.ok).toBe(false);
|
expect(bad.ok).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
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);
|
||||||
|
|
||||||
|
const got = await cmdCasGet(storageRoot, hash);
|
||||||
|
expect(got.ok).toBe(true);
|
||||||
|
if (!got.ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
expect(got.value).toBe(stored);
|
||||||
|
|
||||||
|
const listed = await cmdCasList(storageRoot);
|
||||||
|
expect(listed.ok).toBe(true);
|
||||||
|
if (!listed.ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
expect(listed.value).toContain(hash);
|
||||||
|
|
||||||
|
const removed = await cmdCasRm(storageRoot, hash);
|
||||||
|
expect(removed.ok).toBe(true);
|
||||||
|
|
||||||
|
const missing = await cmdCasGet(storageRoot, hash);
|
||||||
|
expect(missing.ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
test("rollback rejects missing bundle file for target hash", async () => {
|
test("rollback rejects missing bundle file for target hash", async () => {
|
||||||
const bundleDir = join(storageRoot, "src");
|
const bundleDir = join(storageRoot, "src");
|
||||||
await mkdir(bundleDir, { recursive: true });
|
await mkdir(bundleDir, { recursive: true });
|
||||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||||
await writeFile(
|
await writeFile(
|
||||||
bundlePath,
|
bundlePath,
|
||||||
`${fixtureDescriptor}export const run = async function* (input) {
|
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||||
yield { role: "a", content: "x", meta: {} };
|
const cas = options.cas;
|
||||||
|
const h = await putContentMerkleNode(cas, "x");
|
||||||
|
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||||
return { returnCode: 0, summary: "x" };
|
return { returnCode: 0, summary: "x" };
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
@@ -392,8 +463,10 @@ export const run = async function* (input) {
|
|||||||
const hash1 = add1.value.hash;
|
const hash1 = add1.value.hash;
|
||||||
await writeFile(
|
await writeFile(
|
||||||
bundlePath,
|
bundlePath,
|
||||||
`${fixtureDescriptor}export const run = async function* (input) {
|
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||||
yield { role: "a", content: "y", meta: {} };
|
const cas = options.cas;
|
||||||
|
const h = await putContentMerkleNode(cas, "y");
|
||||||
|
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||||
return { returnCode: 0, summary: "y" };
|
return { returnCode: 0, summary: "y" };
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
|
|||||||
+4
@@ -0,0 +1,4 @@
|
|||||||
|
{"name":"demo-live","hash":"C9NMV6V2TQT81","threadId":"01LIVECMPLT01DDDDDDDDDDDDG","parameters":{"prompt":"hello","options":{"maxRounds":5,"depth":0}},"timestamp":1714963400000}
|
||||||
|
{"role":"planner","contentHash":"FF7YQ5W3S2EV6","meta":{"phase":"plan","flags":[1,2]},"refs":[],"timestamp":1714963201000}
|
||||||
|
{"role":"coder","contentHash":"EN34XX1W4WAFJ","meta":{},"refs":[],"timestamp":1714963202000}
|
||||||
|
{"returnCode":0,"summary":"fixture completed"}
|
||||||
+2
@@ -0,0 +1,2 @@
|
|||||||
|
{"tag":"DEBUGTAG1","content":"bundle loaded","timestamp":1714963400050}
|
||||||
|
{"tag":"DEBUGTAG2","content":"multi\nline","timestamp":1714963400500}
|
||||||
+2
@@ -0,0 +1,2 @@
|
|||||||
|
{"name":"demo-live","hash":"C9NMV6V2TQT81","threadId":"01LIVEINFLY01DDDDDDDDDDDDG","parameters":{"prompt":"hello","options":{"maxRounds":5,"depth":0}},"timestamp":1714963200000}
|
||||||
|
{"role":"planner","contentHash":"P6M9FHE1GSBN0","meta":{"x":1},"refs":[],"timestamp":1714963201000}
|
||||||
+2
@@ -0,0 +1,2 @@
|
|||||||
|
{"name":"demo-live-old","hash":"C9NMV6V2TQT81","threadId":"01LIVEOLDER01DDDDDDDDDDDDG","parameters":{"prompt":"old","options":{"maxRounds":5,"depth":0}},"timestamp":1714963000000}
|
||||||
|
{"returnCode":0,"summary":"older thread"}
|
||||||
@@ -2,14 +2,18 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|||||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { cmdAdd } from "../src/cmd-add.js";
|
import { createCasStore, getContentMerklePayload } from "@uncaged/workflow-cas";
|
||||||
import { cmdFork } from "../src/cmd-fork.js";
|
import { getGlobalCasDir } from "@uncaged/workflow-util";
|
||||||
import { cmdRun } from "../src/cmd-run.js";
|
import { cmdFork, cmdRun } from "../src/commands/thread/index.js";
|
||||||
|
import { cmdAdd } from "../src/commands/workflow/index.js";
|
||||||
import { pathExists } from "../src/fs-utils.js";
|
import { pathExists } from "../src/fs-utils.js";
|
||||||
import { addCliArgs } from "./bundle-fixture.js";
|
import { addCliArgs } from "./bundle-fixture.js";
|
||||||
|
import { ensureTestWorkflowRegistryConfig } from "./workflow-registry-fixture.js";
|
||||||
|
|
||||||
/** Three-role workflow that respects `input.steps` for fork/resume. */
|
/** Three-role workflow that respects `input.steps` for fork/resume. */
|
||||||
const threeRoleBundleSource = `export const descriptor = {
|
const threeRoleBundleSource = `import { putContentMerkleNode } from "@uncaged/workflow-cas";
|
||||||
|
|
||||||
|
export const descriptor = {
|
||||||
description: "fork-cli",
|
description: "fork-cli",
|
||||||
roles: {
|
roles: {
|
||||||
planner: { description: "planner", schema: {} },
|
planner: { description: "planner", schema: {} },
|
||||||
@@ -17,20 +21,21 @@ const threeRoleBundleSource = `export const descriptor = {
|
|||||||
reviewer: { description: "reviewer", schema: {} },
|
reviewer: { description: "reviewer", schema: {} },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
export const run = async function* (input) {
|
export const run = async function* (input, options) {
|
||||||
|
const cas = options.cas;
|
||||||
const has = (r) => input.steps.some((s) => s.role === r);
|
const has = (r) => input.steps.some((s) => s.role === r);
|
||||||
if (!has("planner")) {
|
if (!has("planner")) {
|
||||||
yield { role: "planner", content: "p1", meta: { k: "planner" } };
|
const h = await putContentMerkleNode(cas, "p1");
|
||||||
|
yield { role: "planner", contentHash: h, meta: { k: "planner" }, refs: [h] };
|
||||||
}
|
}
|
||||||
if (!has("coder")) {
|
if (!has("coder")) {
|
||||||
yield { role: "coder", content: "c1", meta: { k: "coder" } };
|
const h = await putContentMerkleNode(cas, "c1");
|
||||||
|
yield { role: "coder", contentHash: h, meta: { k: "coder" }, refs: [h] };
|
||||||
}
|
}
|
||||||
if (!has("reviewer")) {
|
if (!has("reviewer")) {
|
||||||
yield {
|
const body = "rev-" + String(input.steps.length);
|
||||||
role: "reviewer",
|
const h = await putContentMerkleNode(cas, body);
|
||||||
content: "rev-" + String(input.steps.length),
|
yield { role: "reviewer", contentHash: h, meta: { k: "reviewer" }, refs: [h] };
|
||||||
meta: { k: "reviewer" },
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
return { returnCode: 0, summary: "done" };
|
return { returnCode: 0, summary: "done" };
|
||||||
};
|
};
|
||||||
@@ -74,6 +79,7 @@ describe("cli fork", () => {
|
|||||||
prevEnv = process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
|
prevEnv = process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
|
||||||
storageRoot = await mkdtemp(join(tmpdir(), "uncaged-wf-fork-"));
|
storageRoot = await mkdtemp(join(tmpdir(), "uncaged-wf-fork-"));
|
||||||
process.env.UNCAGED_WORKFLOW_STORAGE_ROOT = storageRoot;
|
process.env.UNCAGED_WORKFLOW_STORAGE_ROOT = storageRoot;
|
||||||
|
await ensureTestWorkflowRegistryConfig(storageRoot);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
@@ -107,7 +113,7 @@ describe("cli fork", () => {
|
|||||||
const sourceData = join(storageRoot, "logs", hash, `${sourceId}.data.jsonl`);
|
const sourceData = join(storageRoot, "logs", hash, `${sourceId}.data.jsonl`);
|
||||||
const sourceRunning = join(storageRoot, "logs", hash, `${sourceId}.running`);
|
const sourceRunning = join(storageRoot, "logs", hash, `${sourceId}.running`);
|
||||||
await waitUntilRunningAbsent(sourceRunning);
|
await waitUntilRunningAbsent(sourceRunning);
|
||||||
await waitUntilMinDataLines(sourceData, 4);
|
await waitUntilMinDataLines(sourceData, 5);
|
||||||
|
|
||||||
const forked = await cmdFork(storageRoot, sourceId, "planner");
|
const forked = await cmdFork(storageRoot, sourceId, "planner");
|
||||||
expect(forked.ok).toBe(true);
|
expect(forked.ok).toBe(true);
|
||||||
@@ -118,21 +124,22 @@ describe("cli fork", () => {
|
|||||||
const newData = join(storageRoot, "logs", hash, `${newId}.data.jsonl`);
|
const newData = join(storageRoot, "logs", hash, `${newId}.data.jsonl`);
|
||||||
const newRunning = join(storageRoot, "logs", hash, `${newId}.running`);
|
const newRunning = join(storageRoot, "logs", hash, `${newId}.running`);
|
||||||
await waitUntilRunningAbsent(newRunning);
|
await waitUntilRunningAbsent(newRunning);
|
||||||
await waitUntilMinDataLines(newData, 4);
|
await waitUntilMinDataLines(newData, 5);
|
||||||
|
|
||||||
const text = await readFile(newData, "utf8");
|
const text = await readFile(newData, "utf8");
|
||||||
const lines = text
|
const lines = text
|
||||||
.trim()
|
.trim()
|
||||||
.split("\n")
|
.split("\n")
|
||||||
.filter((l) => l !== "");
|
.filter((l) => l !== "");
|
||||||
expect(lines.length).toBe(4);
|
expect(lines.length).toBe(5);
|
||||||
const start = JSON.parse(lines[0] ?? "{}") as Record<string, unknown>;
|
const start = JSON.parse(lines[0] ?? "{}") as Record<string, unknown>;
|
||||||
expect(start.threadId).toBe(newId);
|
expect(start.threadId).toBe(newId);
|
||||||
expect(start.forkFrom).toEqual({ threadId: sourceId });
|
expect(start.forkFrom).toEqual({ threadId: sourceId });
|
||||||
|
|
||||||
const last = JSON.parse(lines[lines.length - 1] ?? "{}") as Record<string, unknown>;
|
const lastRoleLine = JSON.parse(lines[lines.length - 2] ?? "{}") as Record<string, unknown>;
|
||||||
expect(last.role).toBe("reviewer");
|
expect(lastRoleLine.role).toBe("reviewer");
|
||||||
expect(last.content).toBe("rev-1");
|
const cas = createCasStore(getGlobalCasDir(storageRoot));
|
||||||
|
expect(await getContentMerklePayload(cas, String(lastRoleLine.contentHash))).toBe("rev-1");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("fork without --from-role retries last role", async () => {
|
test("fork without --from-role retries last role", async () => {
|
||||||
@@ -157,7 +164,7 @@ describe("cli fork", () => {
|
|||||||
const sourceData = join(storageRoot, "logs", hash, `${sourceId}.data.jsonl`);
|
const sourceData = join(storageRoot, "logs", hash, `${sourceId}.data.jsonl`);
|
||||||
const sourceRunning = join(storageRoot, "logs", hash, `${sourceId}.running`);
|
const sourceRunning = join(storageRoot, "logs", hash, `${sourceId}.running`);
|
||||||
await waitUntilRunningAbsent(sourceRunning);
|
await waitUntilRunningAbsent(sourceRunning);
|
||||||
await waitUntilMinDataLines(sourceData, 4);
|
await waitUntilMinDataLines(sourceData, 5);
|
||||||
|
|
||||||
const forked = await cmdFork(storageRoot, sourceId, null);
|
const forked = await cmdFork(storageRoot, sourceId, null);
|
||||||
expect(forked.ok).toBe(true);
|
expect(forked.ok).toBe(true);
|
||||||
@@ -168,22 +175,23 @@ describe("cli fork", () => {
|
|||||||
const newData = join(storageRoot, "logs", hash, `${newId}.data.jsonl`);
|
const newData = join(storageRoot, "logs", hash, `${newId}.data.jsonl`);
|
||||||
const newRunning = join(storageRoot, "logs", hash, `${newId}.running`);
|
const newRunning = join(storageRoot, "logs", hash, `${newId}.running`);
|
||||||
await waitUntilRunningAbsent(newRunning);
|
await waitUntilRunningAbsent(newRunning);
|
||||||
await waitUntilMinDataLines(newData, 4);
|
await waitUntilMinDataLines(newData, 5);
|
||||||
|
|
||||||
const text = await readFile(newData, "utf8");
|
const text = await readFile(newData, "utf8");
|
||||||
const lines = text
|
const lines = text
|
||||||
.trim()
|
.trim()
|
||||||
.split("\n")
|
.split("\n")
|
||||||
.filter((l) => l !== "");
|
.filter((l) => l !== "");
|
||||||
expect(lines.length).toBe(4);
|
expect(lines.length).toBe(5);
|
||||||
|
|
||||||
const replayCoder = JSON.parse(lines[2] ?? "{}") as Record<string, unknown>;
|
const replayCoder = JSON.parse(lines[2] ?? "{}") as Record<string, unknown>;
|
||||||
expect(replayCoder.role).toBe("coder");
|
expect(replayCoder.role).toBe("coder");
|
||||||
expect(replayCoder.content).toBe("c1");
|
const cas = createCasStore(getGlobalCasDir(storageRoot));
|
||||||
|
expect(await getContentMerklePayload(cas, String(replayCoder.contentHash))).toBe("c1");
|
||||||
|
|
||||||
const last = JSON.parse(lines[lines.length - 1] ?? "{}") as Record<string, unknown>;
|
const lastRoleLine = JSON.parse(lines[lines.length - 2] ?? "{}") as Record<string, unknown>;
|
||||||
expect(last.role).toBe("reviewer");
|
expect(lastRoleLine.role).toBe("reviewer");
|
||||||
expect(last.content).toBe("rev-2");
|
expect(await getContentMerklePayload(cas, String(lastRoleLine.contentHash))).toBe("rev-2");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("fork rejects unknown role with available names", async () => {
|
test("fork rejects unknown role with available names", async () => {
|
||||||
@@ -207,7 +215,7 @@ describe("cli fork", () => {
|
|||||||
const sourceData = join(storageRoot, "logs", added.value.hash, `${sourceId}.data.jsonl`);
|
const sourceData = join(storageRoot, "logs", added.value.hash, `${sourceId}.data.jsonl`);
|
||||||
const sourceRunning = join(storageRoot, "logs", added.value.hash, `${sourceId}.running`);
|
const sourceRunning = join(storageRoot, "logs", added.value.hash, `${sourceId}.running`);
|
||||||
await waitUntilRunningAbsent(sourceRunning);
|
await waitUntilRunningAbsent(sourceRunning);
|
||||||
await waitUntilMinDataLines(sourceData, 4);
|
await waitUntilMinDataLines(sourceData, 5);
|
||||||
|
|
||||||
const bad = await cmdFork(storageRoot, sourceId, "ghost-role");
|
const bad = await cmdFork(storageRoot, sourceId, "ghost-role");
|
||||||
expect(bad.ok).toBe(false);
|
expect(bad.ok).toBe(false);
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
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 { pathExists } from "../src/fs-utils.js";
|
||||||
|
|
||||||
|
const cliEntryPath = fileURLToPath(new URL("../src/cli.ts", import.meta.url));
|
||||||
|
|
||||||
|
async function writeDemoDataJsonl(params: {
|
||||||
|
path: string;
|
||||||
|
threadId: string;
|
||||||
|
bundleHash: string;
|
||||||
|
cas: ReturnType<typeof createCasStore>;
|
||||||
|
activeHash: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
const bodyHash = await putContentMerkleNode(params.cas, "p");
|
||||||
|
const text = [
|
||||||
|
JSON.stringify({
|
||||||
|
name: "demo",
|
||||||
|
hash: params.bundleHash,
|
||||||
|
threadId: params.threadId,
|
||||||
|
parameters: { prompt: "hi", options: { maxRounds: 5 } },
|
||||||
|
timestamp: 100,
|
||||||
|
}),
|
||||||
|
JSON.stringify({
|
||||||
|
role: "planner",
|
||||||
|
contentHash: bodyHash,
|
||||||
|
meta: {},
|
||||||
|
refs: [params.activeHash, bodyHash],
|
||||||
|
timestamp: 101,
|
||||||
|
}),
|
||||||
|
"",
|
||||||
|
].join("\n");
|
||||||
|
await writeFile(params.path, text, "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("gc cli and garbageCollectCas", () => {
|
||||||
|
let prevEnv: string | undefined;
|
||||||
|
let storageRoot: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
prevEnv = process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
|
||||||
|
storageRoot = await mkdtemp(join(tmpdir(), "uncaged-wf-gc-"));
|
||||||
|
process.env.UNCAGED_WORKFLOW_STORAGE_ROOT = storageRoot;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (prevEnv === undefined) {
|
||||||
|
delete process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
|
||||||
|
} else {
|
||||||
|
process.env.UNCAGED_WORKFLOW_STORAGE_ROOT = prevEnv;
|
||||||
|
}
|
||||||
|
await rm(storageRoot, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("garbageCollectCas keeps CAS entries referenced by thread refs", async () => {
|
||||||
|
const bundleHash = "C9NMV6V2TQT81";
|
||||||
|
const threadId = "01AAA1111111111111111111";
|
||||||
|
const logsDir = join(storageRoot, "logs", bundleHash);
|
||||||
|
await mkdir(logsDir, { recursive: true });
|
||||||
|
|
||||||
|
const cas = createCasStore(getGlobalCasDir(storageRoot));
|
||||||
|
const activeHash = await cas.put("active-blob");
|
||||||
|
const orphanHash = await cas.put("orphan-blob");
|
||||||
|
|
||||||
|
await writeDemoDataJsonl({
|
||||||
|
path: join(logsDir, `${threadId}.data.jsonl`),
|
||||||
|
threadId,
|
||||||
|
bundleHash,
|
||||||
|
cas,
|
||||||
|
activeHash,
|
||||||
|
});
|
||||||
|
|
||||||
|
const gc = await garbageCollectCas(storageRoot);
|
||||||
|
expect(gc.ok).toBe(true);
|
||||||
|
if (!gc.ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
expect(gc.value.scannedThreads).toBe(1);
|
||||||
|
expect(gc.value.activeRefs).toBe(2);
|
||||||
|
expect(gc.value.deletedEntries).toBe(1);
|
||||||
|
expect(gc.value.deletedHashes).toEqual([orphanHash]);
|
||||||
|
|
||||||
|
expect(await pathExists(join(getGlobalCasDir(storageRoot), `${activeHash}.txt`))).toBe(true);
|
||||||
|
expect(await pathExists(join(getGlobalCasDir(storageRoot), `${orphanHash}.txt`))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("garbageCollectCas deletes orphaned CAS when no threads reference them", async () => {
|
||||||
|
const cas = createCasStore(getGlobalCasDir(storageRoot));
|
||||||
|
const orphanHash = await cas.put("lonely");
|
||||||
|
|
||||||
|
const gc = await garbageCollectCas(storageRoot);
|
||||||
|
expect(gc.ok).toBe(true);
|
||||||
|
if (!gc.ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
expect(gc.value.scannedThreads).toBe(0);
|
||||||
|
expect(gc.value.activeRefs).toBe(0);
|
||||||
|
expect(gc.value.deletedEntries).toBe(1);
|
||||||
|
expect(gc.value.deletedHashes).toEqual([orphanHash]);
|
||||||
|
expect(await pathExists(join(getGlobalCasDir(storageRoot), `${orphanHash}.txt`))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("cli gc prints stats", async () => {
|
||||||
|
const bundleHash = "C9NMV6V2TQT81";
|
||||||
|
const threadId = "01BBB2222222222222222222";
|
||||||
|
const logsDir = join(storageRoot, "logs", bundleHash);
|
||||||
|
await mkdir(logsDir, { recursive: true });
|
||||||
|
|
||||||
|
const cas = createCasStore(getGlobalCasDir(storageRoot));
|
||||||
|
const activeHash = await cas.put("keep-me");
|
||||||
|
await cas.put("drop-me");
|
||||||
|
|
||||||
|
await writeDemoDataJsonl({
|
||||||
|
path: join(logsDir, `${threadId}.data.jsonl`),
|
||||||
|
threadId,
|
||||||
|
bundleHash,
|
||||||
|
cas,
|
||||||
|
activeHash,
|
||||||
|
});
|
||||||
|
|
||||||
|
const env = { ...process.env, UNCAGED_WORKFLOW_STORAGE_ROOT: storageRoot };
|
||||||
|
const proc = spawnSync(process.execPath, [cliEntryPath, "cas", "gc"], {
|
||||||
|
env,
|
||||||
|
encoding: "utf8",
|
||||||
|
});
|
||||||
|
expect(proc.status).toBe(0);
|
||||||
|
expect(String(proc.stdout).trim()).toBe("scanned 1 threads, 2 active refs, deleted 1 entries");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("thread rm triggers gc so unreferenced CAS is removed", async () => {
|
||||||
|
const bundleHash = "C9NMV6V2TQT81";
|
||||||
|
const threadId = "01CCC3333333333333333333";
|
||||||
|
const logsDir = join(storageRoot, "logs", bundleHash);
|
||||||
|
await mkdir(logsDir, { recursive: true });
|
||||||
|
|
||||||
|
const cas = createCasStore(getGlobalCasDir(storageRoot));
|
||||||
|
const activeHash = await cas.put("pinned-by-ref");
|
||||||
|
await writeDemoDataJsonl({
|
||||||
|
path: join(logsDir, `${threadId}.data.jsonl`),
|
||||||
|
threadId,
|
||||||
|
bundleHash,
|
||||||
|
cas,
|
||||||
|
activeHash,
|
||||||
|
});
|
||||||
|
|
||||||
|
const orphanHash = await cas.put("orphan-after-rm");
|
||||||
|
const orphanPath = join(getGlobalCasDir(storageRoot), `${orphanHash}.txt`);
|
||||||
|
|
||||||
|
const removed = await cmdThreadRemove(storageRoot, threadId);
|
||||||
|
expect(removed.ok).toBe(true);
|
||||||
|
|
||||||
|
expect(await pathExists(orphanPath)).toBe(false);
|
||||||
|
expect(await pathExists(join(getGlobalCasDir(storageRoot), `${activeHash}.txt`))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { formatCliUsage, runCli } from "../src/cli-dispatch.js";
|
||||||
|
import { formatSkillIndex, formatSkillTopic, getSkillTopics } from "../src/skill.js";
|
||||||
|
|
||||||
|
const STORAGE_ROOT = "/tmp/help-test-storage";
|
||||||
|
|
||||||
|
describe("runCli usage", () => {
|
||||||
|
test("no args prints usage and returns 1", async () => {
|
||||||
|
const code = await runCli(STORAGE_ROOT, []);
|
||||||
|
expect(code).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("skill command", () => {
|
||||||
|
test("skill (no topic) lists topics and returns 0", async () => {
|
||||||
|
const code = await runCli(STORAGE_ROOT, ["skill"]);
|
||||||
|
expect(code).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("skill cli returns 0", async () => {
|
||||||
|
const code = await runCli(STORAGE_ROOT, ["skill", "cli"]);
|
||||||
|
expect(code).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("skill develop returns 0", async () => {
|
||||||
|
const code = await runCli(STORAGE_ROOT, ["skill", "develop"]);
|
||||||
|
expect(code).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("skill author returns 0", async () => {
|
||||||
|
const code = await runCli(STORAGE_ROOT, ["skill", "author"]);
|
||||||
|
expect(code).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("skill unknown returns 1", async () => {
|
||||||
|
const code = await runCli(STORAGE_ROOT, ["skill", "unknown"]);
|
||||||
|
expect(code).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("--help flag on groups", () => {
|
||||||
|
test("workflow --help returns 0", async () => {
|
||||||
|
const code = await runCli(STORAGE_ROOT, ["workflow", "--help"]);
|
||||||
|
expect(code).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("thread --help returns 0", async () => {
|
||||||
|
const code = await runCli(STORAGE_ROOT, ["thread", "--help"]);
|
||||||
|
expect(code).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("cas --help returns 0", async () => {
|
||||||
|
const code = await runCli(STORAGE_ROOT, ["cas", "--help"]);
|
||||||
|
expect(code).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("init --help returns 0", async () => {
|
||||||
|
const code = await runCli(STORAGE_ROOT, ["init", "--help"]);
|
||||||
|
expect(code).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getSkillTopics", () => {
|
||||||
|
test("returns all topics", () => {
|
||||||
|
const topics = getSkillTopics();
|
||||||
|
const names = topics.map((t) => t.name);
|
||||||
|
expect(names).toContain("cli");
|
||||||
|
expect(names).toContain("develop");
|
||||||
|
expect(names).toContain("author");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatSkillIndex", () => {
|
||||||
|
test("lists all topics", () => {
|
||||||
|
const idx = formatSkillIndex();
|
||||||
|
expect(idx).toContain("# uncaged-workflow skill");
|
||||||
|
expect(idx).not.toContain("# uncaged-workflow help --skill");
|
||||||
|
expect(idx).toContain("cli");
|
||||||
|
expect(idx).toContain("develop");
|
||||||
|
expect(idx).toContain("author");
|
||||||
|
expect(idx).toContain("skill <topic>");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatCliUsage", () => {
|
||||||
|
test("has tagline, grouped sections, help hint, and env vars", () => {
|
||||||
|
const u = formatCliUsage();
|
||||||
|
expect(u.startsWith("uncaged-workflow — workflow engine CLI")).toBe(true);
|
||||||
|
expect(u).toContain("Workflow registry:");
|
||||||
|
expect(u).toContain("Thread execution:");
|
||||||
|
expect(u).toContain("Content-addressable storage:");
|
||||||
|
expect(u).toContain("Development:");
|
||||||
|
expect(u).toContain("Shortcuts:");
|
||||||
|
expect(u).toContain("Reference:");
|
||||||
|
expect(u).toContain("skill [topic]");
|
||||||
|
expect(u).toContain("Agent-consumable docs");
|
||||||
|
expect(u).toContain("Use <command> --help for subcommand details.");
|
||||||
|
expect(u).toContain("Environment variables:");
|
||||||
|
expect(u).toContain("WORKFLOW_STORAGE_ROOT");
|
||||||
|
expect(u).toContain("UNCAGED_WORKFLOW_STORAGE_ROOT");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("lists commands from registry with descriptions", () => {
|
||||||
|
const u = formatCliUsage();
|
||||||
|
expect(u).toContain("workflow add");
|
||||||
|
expect(u).toContain("Register a workflow bundle in the registry");
|
||||||
|
expect(u).toContain("thread run");
|
||||||
|
expect(u).toContain("Start a new thread executing a workflow");
|
||||||
|
expect(u).toContain("cas gc");
|
||||||
|
expect(u).toContain("Garbage-collect unreferenced CAS entries");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const cliSkillDoc = formatSkillTopic("cli");
|
||||||
|
if (cliSkillDoc === null) {
|
||||||
|
throw new Error("BUG: cli skill topic missing");
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("formatSkillTopic('cli')", () => {
|
||||||
|
const doc = cliSkillDoc;
|
||||||
|
|
||||||
|
test("contains title", () => {
|
||||||
|
expect(doc).toContain("# uncaged-workflow CLI Reference");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("contains all command group headers", () => {
|
||||||
|
expect(doc).toContain("### workflow");
|
||||||
|
expect(doc).toContain("### thread");
|
||||||
|
expect(doc).toContain("### cas");
|
||||||
|
expect(doc).toContain("### init");
|
||||||
|
expect(doc).toContain("### Top-level shortcuts");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("contains core concepts", () => {
|
||||||
|
expect(doc).toContain("## Core Concepts");
|
||||||
|
expect(doc).toContain("Workflow");
|
||||||
|
expect(doc).toContain("Bundle");
|
||||||
|
expect(doc).toContain("Thread");
|
||||||
|
expect(doc).toContain("CAS");
|
||||||
|
expect(doc).toContain("Registry");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("mentions all workflow subcommands", () => {
|
||||||
|
for (const sub of ["add", "list", "show", "rm", "history", "rollback"]) {
|
||||||
|
expect(doc).toContain(`workflow ${sub}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("mentions all thread subcommands", () => {
|
||||||
|
for (const sub of [
|
||||||
|
"run",
|
||||||
|
"list",
|
||||||
|
"show",
|
||||||
|
"rm",
|
||||||
|
"fork",
|
||||||
|
"ps",
|
||||||
|
"kill",
|
||||||
|
"live",
|
||||||
|
"pause",
|
||||||
|
"resume",
|
||||||
|
]) {
|
||||||
|
expect(doc).toContain(`thread ${sub}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("mentions all cas subcommands", () => {
|
||||||
|
for (const sub of ["get", "put", "list", "rm", "gc"]) {
|
||||||
|
expect(doc).toContain(`cas ${sub}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("contains exit codes section", () => {
|
||||||
|
expect(doc).toContain("## Exit Codes");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("contains environment variables section", () => {
|
||||||
|
expect(doc).toContain("## Environment Variables");
|
||||||
|
expect(doc).toContain("UNCAGED_WORKFLOW_STORAGE_ROOT");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("contains typical workflow section", () => {
|
||||||
|
expect(doc).toContain("## Typical Workflow");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatSkillTopic('develop')", () => {
|
||||||
|
const doc = formatSkillTopic("develop");
|
||||||
|
|
||||||
|
test("returns non-null", () => {
|
||||||
|
expect(doc).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("contains thread ID info", () => {
|
||||||
|
expect(doc).toContain("Thread ID");
|
||||||
|
expect(doc).toContain("Crockford Base32");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("contains CAS commands", () => {
|
||||||
|
expect(doc).toContain("cas put");
|
||||||
|
expect(doc).toContain("cas get");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("contains meta output section", () => {
|
||||||
|
expect(doc).toContain("Meta Output");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatSkillTopic('author')", () => {
|
||||||
|
const doc = formatSkillTopic("author");
|
||||||
|
|
||||||
|
test("returns non-null", () => {
|
||||||
|
expect(doc).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("contains bundle structure", () => {
|
||||||
|
expect(doc).toContain("Bundle Structure");
|
||||||
|
expect(doc).toContain(".esm.js");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("contains descriptor info", () => {
|
||||||
|
expect(doc).toContain("WorkflowDescriptor");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("contains role definition", () => {
|
||||||
|
expect(doc).toContain("Role Definition");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatSkillTopic unknown", () => {
|
||||||
|
test("returns null for unknown topic", () => {
|
||||||
|
expect(formatSkillTopic("nonexistent")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||||
|
import { mkdir, readFile, rm } from "node:fs/promises";
|
||||||
|
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 { pathExists } from "../src/fs-utils.js";
|
||||||
|
|
||||||
|
describe("init template", () => {
|
||||||
|
let parent: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
parent = join(
|
||||||
|
tmpdir(),
|
||||||
|
`wf-init-template-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||||
|
);
|
||||||
|
await mkdir(parent, { recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await rm(parent, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("creates templates/<name> with expected files", async () => {
|
||||||
|
const ws = await cmdInitWorkspace(parent, "my-workflows");
|
||||||
|
expect(ws.ok).toBe(true);
|
||||||
|
if (!ws.ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const root = ws.value.rootPath;
|
||||||
|
|
||||||
|
const created = await cmdInitTemplate(root, "review-pr");
|
||||||
|
expect(created.ok).toBe(true);
|
||||||
|
if (!created.ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tdir = join(root, "templates", "review-pr");
|
||||||
|
expect(created.value.templatePath).toBe(tdir);
|
||||||
|
expect(await pathExists(join(tdir, "package.json"))).toBe(true);
|
||||||
|
expect(await pathExists(join(tdir, "tsconfig.json"))).toBe(true);
|
||||||
|
expect(await pathExists(join(tdir, "src", "roles.ts"))).toBe(true);
|
||||||
|
expect(await pathExists(join(tdir, "src", "moderator.ts"))).toBe(true);
|
||||||
|
expect(await pathExists(join(tdir, "src", "index.ts"))).toBe(true);
|
||||||
|
|
||||||
|
const pkg = JSON.parse(await readFile(join(tdir, "package.json"), "utf8")) as {
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
dependencies: Record<string, string>;
|
||||||
|
};
|
||||||
|
expect(pkg.type).toBe("module");
|
||||||
|
expect(pkg.dependencies["@uncaged/workflow-runtime"]).toBeDefined();
|
||||||
|
expect(pkg.dependencies.zod).toBeDefined();
|
||||||
|
expect(pkg.name).toContain("review-pr");
|
||||||
|
|
||||||
|
const idx = await readFile(join(tdir, "src", "index.ts"), "utf8");
|
||||||
|
expect(idx).toContain("WorkflowDefinition");
|
||||||
|
|
||||||
|
const roles = await readFile(join(tdir, "src", "roles.ts"), "utf8");
|
||||||
|
expect(roles).not.toContain("interface ");
|
||||||
|
expect(roles).not.toContain("?:");
|
||||||
|
expect(roles).not.toContain("export default");
|
||||||
|
|
||||||
|
const moder = await readFile(join(tdir, "src", "moderator.ts"), "utf8");
|
||||||
|
expect(moder).not.toContain("export default");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("finds workspace walking up from nested cwd", async () => {
|
||||||
|
const ws = await cmdInitWorkspace(parent, "ws");
|
||||||
|
expect(ws.ok).toBe(true);
|
||||||
|
if (!ws.ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const root = ws.value.rootPath;
|
||||||
|
const nested = join(root, "a", "b");
|
||||||
|
await mkdir(nested, { recursive: true });
|
||||||
|
|
||||||
|
const created = await cmdInitTemplate(nested, "nested-tpl");
|
||||||
|
expect(created.ok).toBe(true);
|
||||||
|
if (!created.ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
expect(await pathExists(join(root, "templates", "nested-tpl", "src", "index.ts"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("errors when not inside a workflow workspace", async () => {
|
||||||
|
const orphan = join(parent, "nowhere");
|
||||||
|
await mkdir(orphan, { recursive: true });
|
||||||
|
const r = await cmdInitTemplate(orphan, "x");
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
if (!r.ok) {
|
||||||
|
expect(r.error).toContain("templates/*");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("errors when template directory already exists", async () => {
|
||||||
|
const ws = await cmdInitWorkspace(parent, "ws");
|
||||||
|
expect(ws.ok).toBe(true);
|
||||||
|
if (!ws.ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const root = ws.value.rootPath;
|
||||||
|
|
||||||
|
const first = await cmdInitTemplate(root, "dup");
|
||||||
|
expect(first.ok).toBe(true);
|
||||||
|
|
||||||
|
const second = await cmdInitTemplate(root, "dup");
|
||||||
|
expect(second.ok).toBe(false);
|
||||||
|
if (!second.ok) {
|
||||||
|
expect(second.error).toContain("already exists");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("errors on invalid template name", async () => {
|
||||||
|
const ws = await cmdInitWorkspace(parent, "ws");
|
||||||
|
expect(ws.ok).toBe(true);
|
||||||
|
if (!ws.ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const bad = await cmdInitTemplate(ws.value.rootPath, "a/b");
|
||||||
|
expect(bad.ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.serial("runCli init template uses cwd and succeeds in workspace", async () => {
|
||||||
|
const ws = await cmdInitWorkspace(parent, "cli-ws");
|
||||||
|
expect(ws.ok).toBe(true);
|
||||||
|
if (!ws.ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const root = ws.value.rootPath;
|
||||||
|
const prev = process.cwd();
|
||||||
|
try {
|
||||||
|
process.chdir(root);
|
||||||
|
const code = await runCli(join(parent, "_storage"), ["init", "template", "from-cli"]);
|
||||||
|
expect(code).toBe(0);
|
||||||
|
expect(await pathExists(join(root, "templates", "from-cli", "package.json"))).toBe(true);
|
||||||
|
} finally {
|
||||||
|
process.chdir(prev);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||||
|
import { mkdir, readFile, rm } from "node:fs/promises";
|
||||||
|
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 { pathExists } from "../src/fs-utils.js";
|
||||||
|
|
||||||
|
describe("init workspace", () => {
|
||||||
|
let parent: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
parent = join(tmpdir(), `wf-init-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||||
|
await mkdir(parent, { recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await rm(parent, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("creates expected files and directories", async () => {
|
||||||
|
const created = await cmdInitWorkspace(parent, "my-workflows");
|
||||||
|
expect(created.ok).toBe(true);
|
||||||
|
if (!created.ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = created.value.rootPath;
|
||||||
|
expect(await pathExists(join(root, "package.json"))).toBe(true);
|
||||||
|
expect(await pathExists(join(root, "biome.json"))).toBe(true);
|
||||||
|
expect(await pathExists(join(root, "tsconfig.json"))).toBe(true);
|
||||||
|
expect(await pathExists(join(root, "AGENTS.md"))).toBe(true);
|
||||||
|
expect(await pathExists(join(root, "README.md"))).toBe(true);
|
||||||
|
expect(await pathExists(join(root, "templates"))).toBe(true);
|
||||||
|
expect(await pathExists(join(root, "templates", ".gitkeep"))).toBe(true);
|
||||||
|
expect(await pathExists(join(root, "workflows", "package.json"))).toBe(true);
|
||||||
|
|
||||||
|
const rootPkg = JSON.parse(await readFile(join(root, "package.json"), "utf8")) as {
|
||||||
|
workspaces: string[];
|
||||||
|
};
|
||||||
|
expect(rootPkg.workspaces).toEqual(["templates/*", "workflows"]);
|
||||||
|
|
||||||
|
const wfPkg = JSON.parse(await readFile(join(root, "workflows", "package.json"), "utf8")) as {
|
||||||
|
type: string;
|
||||||
|
dependencies: Record<string, string>;
|
||||||
|
};
|
||||||
|
expect(wfPkg.type).toBe("module");
|
||||||
|
expect(wfPkg.dependencies["@uncaged/workflow-runtime"]).toBeDefined();
|
||||||
|
expect(wfPkg.dependencies.zod).toBeDefined();
|
||||||
|
|
||||||
|
const tsconfig = JSON.parse(await readFile(join(root, "tsconfig.json"), "utf8")) as {
|
||||||
|
compilerOptions: { strict: boolean; module: string; target: string };
|
||||||
|
};
|
||||||
|
expect(tsconfig.compilerOptions.strict).toBe(true);
|
||||||
|
expect(tsconfig.compilerOptions.module).toBe("ESNext");
|
||||||
|
expect(tsconfig.compilerOptions.target).toBe("ESNext");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("AGENTS.md contains coding agent guide sections and terms", async () => {
|
||||||
|
const created = await cmdInitWorkspace(parent, "my-workflows");
|
||||||
|
expect(created.ok).toBe(true);
|
||||||
|
if (!created.ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const agentsPath = join(created.value.rootPath, "AGENTS.md");
|
||||||
|
const body = await readFile(agentsPath, "utf8");
|
||||||
|
|
||||||
|
for (const section of [
|
||||||
|
"项目结构",
|
||||||
|
"核心概念",
|
||||||
|
"开发流程",
|
||||||
|
"编码规范",
|
||||||
|
"Template",
|
||||||
|
"Build",
|
||||||
|
"常见陷阱",
|
||||||
|
]) {
|
||||||
|
expect(body).toContain(section);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const term of [
|
||||||
|
"RoleDefinition",
|
||||||
|
"WorkflowDefinition",
|
||||||
|
"Moderator",
|
||||||
|
"AgentFn",
|
||||||
|
"ExtractFn",
|
||||||
|
"RoleMeta",
|
||||||
|
]) {
|
||||||
|
expect(body).toContain(term);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(body).toMatch(/type[\s\S]*interface/i);
|
||||||
|
expect(body).toMatch(/function[\s\S]*class/i);
|
||||||
|
expect(body).toContain("Crockford Base32");
|
||||||
|
expect(body).toMatch(/no[\s\S]*default export/i);
|
||||||
|
expect(body).toMatch(/no[\s\S]*console/i);
|
||||||
|
expect(body).toMatch(/no[\s\S]*dynamic import/i);
|
||||||
|
|
||||||
|
expect(body).toContain("bun run check");
|
||||||
|
expect(body).toContain("bun test");
|
||||||
|
expect(body).toContain("uncaged-workflow");
|
||||||
|
expect(body).toContain("bun build");
|
||||||
|
expect(body).toContain("CLAUDE.md");
|
||||||
|
expect(body).toContain("docs/architecture.md");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("errors when directory already exists", async () => {
|
||||||
|
const first = await cmdInitWorkspace(parent, "dup");
|
||||||
|
expect(first.ok).toBe(true);
|
||||||
|
|
||||||
|
const second = await cmdInitWorkspace(parent, "dup");
|
||||||
|
expect(second.ok).toBe(false);
|
||||||
|
if (!second.ok) {
|
||||||
|
expect(second.error).toContain("already exists");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("errors on invalid workspace name", async () => {
|
||||||
|
const slash = await cmdInitWorkspace(parent, "a/b");
|
||||||
|
expect(slash.ok).toBe(false);
|
||||||
|
|
||||||
|
const dots = await cmdInitWorkspace(parent, "..");
|
||||||
|
expect(dots.ok).toBe(false);
|
||||||
|
|
||||||
|
const empty = await cmdInitWorkspace(parent, "");
|
||||||
|
expect(empty.ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("usage lists init subcommands", () => {
|
||||||
|
const u = formatCliUsage();
|
||||||
|
expect(u).toContain("init workspace <name>");
|
||||||
|
expect(u).toContain("init template <name>");
|
||||||
|
expect(u).toContain("Development:");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("runCli rejects unknown init subcommand", async () => {
|
||||||
|
const code = await runCli(join(parent, "_storage"), ["init", "bogus", "name"]);
|
||||||
|
expect(code).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.serial("runCli init workspace uses cwd", async () => {
|
||||||
|
const prev = process.cwd();
|
||||||
|
try {
|
||||||
|
process.chdir(parent);
|
||||||
|
const code = await runCli(join(parent, "_storage"), ["init", "workspace", "from-cli"]);
|
||||||
|
expect(code).toBe(0);
|
||||||
|
expect(await pathExists(join(parent, "from-cli", "workflows", "package.json"))).toBe(true);
|
||||||
|
} finally {
|
||||||
|
process.chdir(prev);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,370 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||||
|
import { spawn, spawnSync } from "node:child_process";
|
||||||
|
import { cp, mkdir, mkdtemp, readFile, 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 {
|
||||||
|
formatLiveDebugLine,
|
||||||
|
formatLiveTimeLabel,
|
||||||
|
LIVE_CONTENT_MAX_LINES,
|
||||||
|
type LiveRoleRow,
|
||||||
|
renderLiveRoleStepLines,
|
||||||
|
} from "../src/commands/thread/index.js";
|
||||||
|
import { parseLiveArgv } from "../src/live-argv.js";
|
||||||
|
|
||||||
|
const cliEntryPath = fileURLToPath(new URL("../src/cli.ts", import.meta.url));
|
||||||
|
const fixtureRoot = fileURLToPath(new URL("./fixtures/live", import.meta.url));
|
||||||
|
|
||||||
|
/** Bodies for Merkle content nodes; hashes must match `.data.jsonl` fixtures. */
|
||||||
|
const LIVE_FIXTURE_PLANNER_BODY =
|
||||||
|
"alpha\nbeta\ngamma\nLINE4\nLINE5\nLINE6\nLINE7\nLINE8\nLINE9\nLINE10\nLINE11";
|
||||||
|
|
||||||
|
describe("live helpers", () => {
|
||||||
|
test("formatLiveTimeLabel pads HH:MM:SS", () => {
|
||||||
|
const label = formatLiveTimeLabel(new Date("2024-06-01T09:08:07.000Z").getTime());
|
||||||
|
expect(label).toMatch(/^\d{2}:\d{2}:\d{2}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("formatLiveDebugLine flattens newlines in message", () => {
|
||||||
|
const line = formatLiveDebugLine(0, "TAG1", "a\nb");
|
||||||
|
expect(line).toContain("[TAG1]");
|
||||||
|
expect(line).toContain("a b");
|
||||||
|
expect(line).not.toContain("\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renderLiveRoleStepLines truncates content to LIVE_CONTENT_MAX_LINES", () => {
|
||||||
|
const lines = Array.from({ length: LIVE_CONTENT_MAX_LINES + 3 }, (_, i) => `L${i + 1}`);
|
||||||
|
const row: LiveRoleRow = {
|
||||||
|
role: "r",
|
||||||
|
content: lines.join("\n"),
|
||||||
|
meta: { k: "v" },
|
||||||
|
timestamp: 0,
|
||||||
|
};
|
||||||
|
const out = renderLiveRoleStepLines(row, "r");
|
||||||
|
const body = out.filter((l) => l.startsWith(" L"));
|
||||||
|
expect(body.length).toBe(LIVE_CONTENT_MAX_LINES);
|
||||||
|
expect(out.some((l) => l.includes("more line"))).toBe(true);
|
||||||
|
expect(out.some((l) => l.startsWith(" meta: "))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseLiveArgv", () => {
|
||||||
|
test("parses thread id and flags in any order", () => {
|
||||||
|
const a = parseLiveArgv(["01ABC", "--debug", "--role", "planner"]);
|
||||||
|
expect(a.ok).toBe(true);
|
||||||
|
if (a.ok) {
|
||||||
|
expect(a.value.threadId).toBe("01ABC");
|
||||||
|
expect(a.value.latest).toBe(false);
|
||||||
|
expect(a.value.debug).toBe(true);
|
||||||
|
expect(a.value.role).toBe("planner");
|
||||||
|
}
|
||||||
|
const b = parseLiveArgv(["--latest", "--role", "x"]);
|
||||||
|
expect(b.ok).toBe(true);
|
||||||
|
if (b.ok) {
|
||||||
|
expect(b.value.latest).toBe(true);
|
||||||
|
expect(b.value.threadId).toBe(null);
|
||||||
|
expect(b.value.role).toBe("x");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects --latest with thread id", () => {
|
||||||
|
const r = parseLiveArgv(["--latest", "01ABC"]);
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("live CLI", () => {
|
||||||
|
let prevEnv: string | undefined;
|
||||||
|
let storageRoot: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
prevEnv = process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
|
||||||
|
storageRoot = await mkdtemp(join(tmpdir(), "uncaged-wf-live-"));
|
||||||
|
process.env.UNCAGED_WORKFLOW_STORAGE_ROOT = storageRoot;
|
||||||
|
await mkdir(join(storageRoot, "logs", "C9NMV6V2TQT81"), { recursive: true });
|
||||||
|
await cp(
|
||||||
|
join(fixtureRoot, "logs", "C9NMV6V2TQT81", "01LIVECMPLT01DDDDDDDDDDDDG.data.jsonl"),
|
||||||
|
join(storageRoot, "logs", "C9NMV6V2TQT81", "01LIVECMPLT01DDDDDDDDDDDDG.data.jsonl"),
|
||||||
|
);
|
||||||
|
await cp(
|
||||||
|
join(fixtureRoot, "logs", "C9NMV6V2TQT81", "01LIVECMPLT01DDDDDDDDDDDDG.info.jsonl"),
|
||||||
|
join(storageRoot, "logs", "C9NMV6V2TQT81", "01LIVECMPLT01DDDDDDDDDDDDG.info.jsonl"),
|
||||||
|
);
|
||||||
|
await cp(
|
||||||
|
join(fixtureRoot, "logs", "C9NMV6V2TQT81", "01LIVEINFLY01DDDDDDDDDDDDG.data.jsonl"),
|
||||||
|
join(storageRoot, "logs", "C9NMV6V2TQT81", "01LIVEINFLY01DDDDDDDDDDDDG.data.jsonl"),
|
||||||
|
);
|
||||||
|
await cp(
|
||||||
|
join(fixtureRoot, "logs", "C9NMV6V2TQT81", "01LIVEOLDER01DDDDDDDDDDDDG.data.jsonl"),
|
||||||
|
join(storageRoot, "logs", "C9NMV6V2TQT81", "01LIVEOLDER01DDDDDDDDDDDDG.data.jsonl"),
|
||||||
|
);
|
||||||
|
|
||||||
|
const cas = createCasStore(getGlobalCasDir(storageRoot));
|
||||||
|
await putContentMerkleNode(cas, LIVE_FIXTURE_PLANNER_BODY);
|
||||||
|
await putContentMerkleNode(cas, "patch");
|
||||||
|
await putContentMerkleNode(cas, "still running");
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (prevEnv === undefined) {
|
||||||
|
delete process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
|
||||||
|
} else {
|
||||||
|
process.env.UNCAGED_WORKFLOW_STORAGE_ROOT = prevEnv;
|
||||||
|
}
|
||||||
|
await rm(storageRoot, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("prints role steps and summary for a completed thread", async () => {
|
||||||
|
const env = { ...process.env, UNCAGED_WORKFLOW_STORAGE_ROOT: storageRoot };
|
||||||
|
const proc = spawn(process.execPath, [cliEntryPath, "live", "01LIVECMPLT01DDDDDDDDDDDDG"], {
|
||||||
|
env,
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
const stdout = await new Promise<string>((resolve, reject) => {
|
||||||
|
let buf = "";
|
||||||
|
proc.stdout?.on("data", (c: Buffer) => {
|
||||||
|
buf += c.toString("utf8");
|
||||||
|
});
|
||||||
|
proc.stderr?.on("data", (c: Buffer) => {
|
||||||
|
buf += c.toString("utf8");
|
||||||
|
});
|
||||||
|
proc.on("error", reject);
|
||||||
|
proc.on("exit", (code: number | null) => {
|
||||||
|
if (code === 0) {
|
||||||
|
resolve(buf);
|
||||||
|
} else {
|
||||||
|
reject(new Error(`exit ${code}: ${buf}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(stdout).toContain("planner");
|
||||||
|
expect(stdout).toContain("coder");
|
||||||
|
expect(stdout).toContain("meta:");
|
||||||
|
expect(stdout).toContain('"phase":"plan"');
|
||||||
|
expect(stdout).toContain("LINE10");
|
||||||
|
expect(stdout).not.toContain("LINE11");
|
||||||
|
expect(stdout).toContain("more line");
|
||||||
|
expect(stdout).toContain("completed: returnCode=0");
|
||||||
|
expect(stdout).toContain("fixture completed");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("--latest tails the newest thread by start timestamp", async () => {
|
||||||
|
const env = { ...process.env, UNCAGED_WORKFLOW_STORAGE_ROOT: storageRoot };
|
||||||
|
const proc = spawn(process.execPath, [cliEntryPath, "live", "--latest"], {
|
||||||
|
env,
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
const stdout = await new Promise<string>((resolve, reject) => {
|
||||||
|
let buf = "";
|
||||||
|
proc.stdout?.on("data", (c: Buffer) => {
|
||||||
|
buf += c.toString("utf8");
|
||||||
|
});
|
||||||
|
proc.stderr?.on("data", (c: Buffer) => {
|
||||||
|
buf += c.toString("utf8");
|
||||||
|
});
|
||||||
|
proc.on("error", reject);
|
||||||
|
proc.on("exit", (code: number | null) => {
|
||||||
|
if (code === 0) {
|
||||||
|
resolve(buf);
|
||||||
|
} else {
|
||||||
|
reject(new Error(`exit ${code}: ${buf}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(stdout).toContain("fixture completed");
|
||||||
|
expect(stdout).not.toContain("older thread");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("--debug prints .info.jsonl records after data output", async () => {
|
||||||
|
const env = { ...process.env, UNCAGED_WORKFLOW_STORAGE_ROOT: storageRoot };
|
||||||
|
const proc = spawn(
|
||||||
|
process.execPath,
|
||||||
|
[cliEntryPath, "live", "01LIVECMPLT01DDDDDDDDDDDDG", "--debug"],
|
||||||
|
{
|
||||||
|
env,
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const stdout = await new Promise<string>((resolve, reject) => {
|
||||||
|
let buf = "";
|
||||||
|
proc.stdout?.on("data", (c: Buffer) => {
|
||||||
|
buf += c.toString("utf8");
|
||||||
|
});
|
||||||
|
proc.stderr?.on("data", (c: Buffer) => {
|
||||||
|
buf += c.toString("utf8");
|
||||||
|
});
|
||||||
|
proc.on("error", reject);
|
||||||
|
proc.on("exit", (code: number | null) => {
|
||||||
|
if (code === 0) {
|
||||||
|
resolve(buf);
|
||||||
|
} else {
|
||||||
|
reject(new Error(`exit ${code}: ${buf}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(stdout).toContain("[DEBUGTAG1]");
|
||||||
|
expect(stdout).toContain("bundle loaded");
|
||||||
|
expect(stdout).toContain("[DEBUGTAG2]");
|
||||||
|
expect(stdout).toContain("multi line");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("--role filters out non-matching roles", async () => {
|
||||||
|
const env = { ...process.env, UNCAGED_WORKFLOW_STORAGE_ROOT: storageRoot };
|
||||||
|
const proc = spawn(
|
||||||
|
process.execPath,
|
||||||
|
[cliEntryPath, "live", "01LIVECMPLT01DDDDDDDDDDDDG", "--role", "planner"],
|
||||||
|
{
|
||||||
|
env,
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const stdout = await new Promise<string>((resolve, reject) => {
|
||||||
|
let buf = "";
|
||||||
|
proc.stdout?.on("data", (c: Buffer) => {
|
||||||
|
buf += c.toString("utf8");
|
||||||
|
});
|
||||||
|
proc.stderr?.on("data", (c: Buffer) => {
|
||||||
|
buf += c.toString("utf8");
|
||||||
|
});
|
||||||
|
proc.on("error", reject);
|
||||||
|
proc.on("exit", (code: number | null) => {
|
||||||
|
if (code === 0) {
|
||||||
|
resolve(buf);
|
||||||
|
} else {
|
||||||
|
reject(new Error(`exit ${code}: ${buf}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(stdout).toContain("planner");
|
||||||
|
expect(stdout).not.toContain("patch");
|
||||||
|
expect(stdout).toContain("completed: returnCode=0");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("--latest --debug --role combine", async () => {
|
||||||
|
const env = { ...process.env, UNCAGED_WORKFLOW_STORAGE_ROOT: storageRoot };
|
||||||
|
const proc = spawn(
|
||||||
|
process.execPath,
|
||||||
|
[cliEntryPath, "live", "--latest", "--debug", "--role", "planner"],
|
||||||
|
{
|
||||||
|
env,
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const stdout = await new Promise<string>((resolve, reject) => {
|
||||||
|
let buf = "";
|
||||||
|
proc.stdout?.on("data", (c: Buffer) => {
|
||||||
|
buf += c.toString("utf8");
|
||||||
|
});
|
||||||
|
proc.stderr?.on("data", (c: Buffer) => {
|
||||||
|
buf += c.toString("utf8");
|
||||||
|
});
|
||||||
|
proc.on("error", reject);
|
||||||
|
proc.on("exit", (code: number | null) => {
|
||||||
|
if (code === 0) {
|
||||||
|
resolve(buf);
|
||||||
|
} else {
|
||||||
|
reject(new Error(`exit ${code}: ${buf}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(stdout).toContain("[DEBUGTAG1]");
|
||||||
|
expect(stdout).toContain("planner");
|
||||||
|
expect(stdout).not.toContain("patch");
|
||||||
|
expect(stdout).toContain("fixture completed");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("unknown thread id exits 1", () => {
|
||||||
|
const env = { ...process.env, UNCAGED_WORKFLOW_STORAGE_ROOT: storageRoot };
|
||||||
|
const r = spawnSync(process.execPath, [cliEntryPath, "live", "01UNKNOWNXXXXXXXXXXXXXXXXX"], {
|
||||||
|
env,
|
||||||
|
encoding: "utf8",
|
||||||
|
});
|
||||||
|
expect(r.status).toBe(1);
|
||||||
|
expect(String(r.stderr ?? "")).toContain("thread not found");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("follows file until WorkflowResult is appended", async () => {
|
||||||
|
const env = { ...process.env, UNCAGED_WORKFLOW_STORAGE_ROOT: storageRoot };
|
||||||
|
const dataPath = join(
|
||||||
|
storageRoot,
|
||||||
|
"logs",
|
||||||
|
"C9NMV6V2TQT81",
|
||||||
|
"01LIVEINFLY01DDDDDDDDDDDDG.data.jsonl",
|
||||||
|
);
|
||||||
|
|
||||||
|
const proc = spawn(process.execPath, [cliEntryPath, "live", "01LIVEINFLY01DDDDDDDDDDDDG"], {
|
||||||
|
env,
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise((r) => setTimeout(r, 120));
|
||||||
|
const prior = await readFile(dataPath, "utf8");
|
||||||
|
await writeFile(
|
||||||
|
dataPath,
|
||||||
|
`${prior.replace(/\s*$/, "")}\n${JSON.stringify({ returnCode: 0, summary: "caught up" })}\n`,
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
|
||||||
|
const stdout = await new Promise<string>((resolve, reject) => {
|
||||||
|
let buf = "";
|
||||||
|
proc.stdout?.on("data", (c: Buffer) => {
|
||||||
|
buf += c.toString("utf8");
|
||||||
|
});
|
||||||
|
proc.stderr?.on("data", (c: Buffer) => {
|
||||||
|
buf += c.toString("utf8");
|
||||||
|
});
|
||||||
|
proc.on("error", reject);
|
||||||
|
proc.on("exit", (code: number | null) => {
|
||||||
|
if (code === 0) {
|
||||||
|
resolve(buf);
|
||||||
|
} else {
|
||||||
|
reject(new Error(`exit ${code}: ${buf}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(stdout).toContain("planner");
|
||||||
|
expect(stdout).toContain("completed: returnCode=0");
|
||||||
|
expect(stdout).toContain("caught up");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("live --latest with empty storage", () => {
|
||||||
|
let prevEnv: string | undefined;
|
||||||
|
let emptyRoot: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
prevEnv = process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
|
||||||
|
emptyRoot = await mkdtemp(join(tmpdir(), "uncaged-wf-live-empty-"));
|
||||||
|
process.env.UNCAGED_WORKFLOW_STORAGE_ROOT = emptyRoot;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (prevEnv === undefined) {
|
||||||
|
delete process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
|
||||||
|
} else {
|
||||||
|
process.env.UNCAGED_WORKFLOW_STORAGE_ROOT = prevEnv;
|
||||||
|
}
|
||||||
|
await rm(emptyRoot, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("exits 1 when no threads exist", () => {
|
||||||
|
const env = { ...process.env, UNCAGED_WORKFLOW_STORAGE_ROOT: emptyRoot };
|
||||||
|
const r = spawnSync(process.execPath, [cliEntryPath, "live", "--latest"], {
|
||||||
|
env,
|
||||||
|
encoding: "utf8",
|
||||||
|
});
|
||||||
|
expect(r.status).toBe(1);
|
||||||
|
expect(String(r.stderr ?? "")).toContain("no threads");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||||
|
import { getDefaultWorkflowStorageRoot } from "@uncaged/workflow-util";
|
||||||
|
import { resolveWorkflowStorageRoot } from "../src/storage-env.js";
|
||||||
|
|
||||||
|
describe("resolveWorkflowStorageRoot", () => {
|
||||||
|
let savedInternal: string | undefined;
|
||||||
|
let savedUser: string | undefined;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
savedInternal = process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
|
||||||
|
savedUser = process.env.WORKFLOW_STORAGE_ROOT;
|
||||||
|
delete process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
|
||||||
|
delete process.env.WORKFLOW_STORAGE_ROOT;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (savedInternal === undefined) {
|
||||||
|
delete process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
|
||||||
|
} else {
|
||||||
|
process.env.UNCAGED_WORKFLOW_STORAGE_ROOT = savedInternal;
|
||||||
|
}
|
||||||
|
if (savedUser === undefined) {
|
||||||
|
delete process.env.WORKFLOW_STORAGE_ROOT;
|
||||||
|
} else {
|
||||||
|
process.env.WORKFLOW_STORAGE_ROOT = savedUser;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns default when no env vars are set", () => {
|
||||||
|
expect(resolveWorkflowStorageRoot()).toBe(getDefaultWorkflowStorageRoot());
|
||||||
|
});
|
||||||
|
|
||||||
|
test("WORKFLOW_STORAGE_ROOT overrides default", () => {
|
||||||
|
process.env.WORKFLOW_STORAGE_ROOT = "/tmp/custom-storage";
|
||||||
|
expect(resolveWorkflowStorageRoot()).toBe("/tmp/custom-storage");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("UNCAGED_WORKFLOW_STORAGE_ROOT takes priority over WORKFLOW_STORAGE_ROOT", () => {
|
||||||
|
process.env.WORKFLOW_STORAGE_ROOT = "/tmp/user-path";
|
||||||
|
process.env.UNCAGED_WORKFLOW_STORAGE_ROOT = "/tmp/internal-path";
|
||||||
|
expect(resolveWorkflowStorageRoot()).toBe("/tmp/internal-path");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ignores empty WORKFLOW_STORAGE_ROOT", () => {
|
||||||
|
process.env.WORKFLOW_STORAGE_ROOT = "";
|
||||||
|
expect(resolveWorkflowStorageRoot()).toBe(getDefaultWorkflowStorageRoot());
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ignores empty UNCAGED_WORKFLOW_STORAGE_ROOT and falls through to WORKFLOW_STORAGE_ROOT", () => {
|
||||||
|
process.env.UNCAGED_WORKFLOW_STORAGE_ROOT = "";
|
||||||
|
process.env.WORKFLOW_STORAGE_ROOT = "/tmp/user-fallback";
|
||||||
|
expect(resolveWorkflowStorageRoot()).toBe("/tmp/user-fallback");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,16 +4,25 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { cmdAdd } from "../src/cmd-add.js";
|
import { getGlobalCasDir } from "@uncaged/workflow-util";
|
||||||
import { cmdKill } from "../src/cmd-kill.js";
|
import { cmdCasPut } from "../src/commands/cas/index.js";
|
||||||
import { cmdPause } from "../src/cmd-pause.js";
|
import {
|
||||||
import { cmdPs } from "../src/cmd-ps.js";
|
cmdKill,
|
||||||
import { cmdResume } from "../src/cmd-resume.js";
|
cmdPause,
|
||||||
import { cmdRun } from "../src/cmd-run.js";
|
cmdPs,
|
||||||
import { cmdThreadRemove, cmdThreadShow } from "../src/cmd-thread.js";
|
cmdResume,
|
||||||
import { cmdThreads } from "../src/cmd-threads.js";
|
cmdRun,
|
||||||
import { pathExists } from "../src/fs-utils.js";
|
cmdThreadRemove,
|
||||||
|
cmdThreadShow,
|
||||||
|
cmdThreads,
|
||||||
|
} from "../src/commands/thread/index.js";
|
||||||
|
import { cmdAdd } from "../src/commands/workflow/index.js";
|
||||||
|
import { pathExists, readTextFileIfExists } from "../src/fs-utils.js";
|
||||||
import { addCliArgs } from "./bundle-fixture.js";
|
import { addCliArgs } from "./bundle-fixture.js";
|
||||||
|
import { ensureTestWorkflowRegistryConfig } from "./workflow-registry-fixture.js";
|
||||||
|
|
||||||
|
const wfPutImport = `import { putContentMerkleNode } from "@uncaged/workflow-cas";
|
||||||
|
`;
|
||||||
|
|
||||||
const threadFixtureDescriptor = `export const descriptor = {
|
const threadFixtureDescriptor = `export const descriptor = {
|
||||||
description: "thread-cli",
|
description: "thread-cli",
|
||||||
@@ -29,18 +38,26 @@ const threadFixtureDescriptor = `export const descriptor = {
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
const fastBundleSource = `${threadFixtureDescriptor}
|
const fastBundleSource = `${threadFixtureDescriptor}
|
||||||
export const run = async function* (input) {
|
${wfPutImport}
|
||||||
yield { role: "planner", content: "plan", meta: { plan: input.prompt } };
|
export const run = async function* (input, options) {
|
||||||
yield { role: "coder", content: "code", meta: { diff: "y" } };
|
const cas = options.cas;
|
||||||
|
let h = await putContentMerkleNode(cas, "plan");
|
||||||
|
yield { role: "planner", contentHash: h, meta: { plan: input.prompt }, refs: [h] };
|
||||||
|
h = await putContentMerkleNode(cas, "code");
|
||||||
|
yield { role: "coder", contentHash: h, meta: { diff: "y" }, refs: [h] };
|
||||||
return { returnCode: 0, summary: "done" };
|
return { returnCode: 0, summary: "done" };
|
||||||
};
|
};
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const slowPlannerBundleSource = `${threadFixtureDescriptor}
|
const slowPlannerBundleSource = `${threadFixtureDescriptor}
|
||||||
export const run = async function* (input) {
|
${wfPutImport}
|
||||||
|
export const run = async function* (input, options) {
|
||||||
await new Promise((r) => setTimeout(r, 400));
|
await new Promise((r) => setTimeout(r, 400));
|
||||||
yield { role: "planner", content: "plan", meta: { plan: input.prompt } };
|
const cas = options.cas;
|
||||||
yield { role: "coder", content: "code", meta: { diff: "y" } };
|
let h = await putContentMerkleNode(cas, "plan");
|
||||||
|
yield { role: "planner", contentHash: h, meta: { plan: input.prompt }, refs: [h] };
|
||||||
|
h = await putContentMerkleNode(cas, "code");
|
||||||
|
yield { role: "coder", contentHash: h, meta: { diff: "y" }, refs: [h] };
|
||||||
return { returnCode: 0, summary: "done" };
|
return { returnCode: 0, summary: "done" };
|
||||||
};
|
};
|
||||||
`;
|
`;
|
||||||
@@ -48,27 +65,38 @@ export const run = async function* (input) {
|
|||||||
const cliEntryPath = fileURLToPath(new URL("../src/cli.ts", import.meta.url));
|
const cliEntryPath = fileURLToPath(new URL("../src/cli.ts", import.meta.url));
|
||||||
|
|
||||||
const abortablePlannerBundleSource = `${threadFixtureDescriptor}
|
const abortablePlannerBundleSource = `${threadFixtureDescriptor}
|
||||||
export const run = async function* (input) {
|
${wfPutImport}
|
||||||
|
export const run = async function* (input, options) {
|
||||||
await new Promise((r) => setTimeout(r, 600));
|
await new Promise((r) => setTimeout(r, 600));
|
||||||
yield { role: "planner", content: "plan", meta: { plan: input.prompt } };
|
const cas = options.cas;
|
||||||
yield { role: "coder", content: "code", meta: { diff: "y" } };
|
let h = await putContentMerkleNode(cas, "plan");
|
||||||
|
yield { role: "planner", contentHash: h, meta: { plan: input.prompt }, refs: [h] };
|
||||||
|
h = await putContentMerkleNode(cas, "code");
|
||||||
|
yield { role: "coder", contentHash: h, meta: { diff: "y" }, refs: [h] };
|
||||||
return { returnCode: 0, summary: "done" };
|
return { returnCode: 0, summary: "done" };
|
||||||
};
|
};
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const pauseResumeBundleSource = `${threadFixtureDescriptor}
|
const pauseResumeBundleSource = `${threadFixtureDescriptor}
|
||||||
export const run = async function* (input) {
|
${wfPutImport}
|
||||||
yield { role: "first", content: "f", meta: {} };
|
export const run = async function* (_input, options) {
|
||||||
|
const cas = options.cas;
|
||||||
|
let h = await putContentMerkleNode(cas, "f");
|
||||||
|
yield { role: "first", contentHash: h, meta: {}, refs: [h] };
|
||||||
await new Promise((r) => setTimeout(r, 1500));
|
await new Promise((r) => setTimeout(r, 1500));
|
||||||
yield { role: "second", content: "s", meta: {} };
|
h = await putContentMerkleNode(cas, "s");
|
||||||
|
yield { role: "second", contentHash: h, meta: {}, refs: [h] };
|
||||||
return { returnCode: 0, summary: "done" };
|
return { returnCode: 0, summary: "done" };
|
||||||
};
|
};
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const delayedFirstYieldBundleSource = `${threadFixtureDescriptor}
|
const delayedFirstYieldBundleSource = `${threadFixtureDescriptor}
|
||||||
export const run = async function* (input) {
|
${wfPutImport}
|
||||||
|
export const run = async function* (_input, options) {
|
||||||
await new Promise((r) => setTimeout(r, 900));
|
await new Promise((r) => setTimeout(r, 900));
|
||||||
yield { role: "only", content: "x", meta: {} };
|
const cas = options.cas;
|
||||||
|
const h = await putContentMerkleNode(cas, "x");
|
||||||
|
yield { role: "only", contentHash: h, meta: {}, refs: [h] };
|
||||||
return { returnCode: 0, summary: "done" };
|
return { returnCode: 0, summary: "done" };
|
||||||
};
|
};
|
||||||
`;
|
`;
|
||||||
@@ -115,6 +143,7 @@ describe("cli thread commands", () => {
|
|||||||
prevEnv = process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
|
prevEnv = process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
|
||||||
storageRoot = await mkdtemp(join(tmpdir(), "uncaged-wf-thread-"));
|
storageRoot = await mkdtemp(join(tmpdir(), "uncaged-wf-thread-"));
|
||||||
process.env.UNCAGED_WORKFLOW_STORAGE_ROOT = storageRoot;
|
process.env.UNCAGED_WORKFLOW_STORAGE_ROOT = storageRoot;
|
||||||
|
await ensureTestWorkflowRegistryConfig(storageRoot);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
@@ -175,15 +204,67 @@ describe("cli thread commands", () => {
|
|||||||
expect(await pathExists(dataPath)).toBe(false);
|
expect(await pathExists(dataPath)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("thread rm runs GC and removes CAS blobs not referenced by any remaining thread", async () => {
|
||||||
|
const bundleDir = join(storageRoot, "src");
|
||||||
|
await mkdir(bundleDir, { recursive: true });
|
||||||
|
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||||
|
await writeFile(bundlePath, fastBundleSource, "utf8");
|
||||||
|
|
||||||
|
const added = await cmdAdd(storageRoot, addCliArgs("solve-issue", bundlePath));
|
||||||
|
expect(added.ok).toBe(true);
|
||||||
|
if (!added.ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ran = await cmdRun(storageRoot, "solve-issue", "hello", 5);
|
||||||
|
expect(ran.ok).toBe(true);
|
||||||
|
if (!ran.ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const threadId = ran.value.threadId;
|
||||||
|
|
||||||
|
let threads = await cmdThreads(storageRoot, []);
|
||||||
|
for (
|
||||||
|
let attempt = 0;
|
||||||
|
attempt < 50 && threads.ok && !threads.value.some((l) => l.includes(threadId));
|
||||||
|
attempt++
|
||||||
|
) {
|
||||||
|
await new Promise((r) => setTimeout(r, 20));
|
||||||
|
threads = await cmdThreads(storageRoot, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataPath = join(storageRoot, "logs", added.value.hash, `${threadId}.data.jsonl`);
|
||||||
|
const runningPath = join(dirname(dataPath), `${threadId}.running`);
|
||||||
|
await waitUntilRunningFileAbsent(runningPath, 120);
|
||||||
|
|
||||||
|
const put = await cmdCasPut(storageRoot, "keep-after-thread-rm");
|
||||||
|
expect(put.ok).toBe(true);
|
||||||
|
if (!put.ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const hash = put.value;
|
||||||
|
const casBlob = join(getGlobalCasDir(storageRoot), `${hash}.txt`);
|
||||||
|
|
||||||
|
const removed = await cmdThreadRemove(storageRoot, threadId);
|
||||||
|
expect(removed.ok).toBe(true);
|
||||||
|
|
||||||
|
const stillThere = await readTextFileIfExists(casBlob);
|
||||||
|
expect(stillThere).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
test("cli entrypoint dispatches threads / ps (spawn)", () => {
|
test("cli entrypoint dispatches threads / ps (spawn)", () => {
|
||||||
const env = { ...process.env, UNCAGED_WORKFLOW_STORAGE_ROOT: storageRoot };
|
const env = { ...process.env, UNCAGED_WORKFLOW_STORAGE_ROOT: storageRoot };
|
||||||
const threads = spawnSync(process.execPath, [cliEntryPath, "threads"], {
|
const threads = spawnSync(process.execPath, [cliEntryPath, "thread", "list"], {
|
||||||
env,
|
env,
|
||||||
encoding: "utf8",
|
encoding: "utf8",
|
||||||
});
|
});
|
||||||
expect(threads.status).toBe(0);
|
expect(threads.status).toBe(0);
|
||||||
|
|
||||||
const ps = spawnSync(process.execPath, [cliEntryPath, "ps"], { env, encoding: "utf8" });
|
const ps = spawnSync(process.execPath, [cliEntryPath, "thread", "ps"], {
|
||||||
|
env,
|
||||||
|
encoding: "utf8",
|
||||||
|
});
|
||||||
expect(ps.status).toBe(0);
|
expect(ps.status).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -250,7 +331,7 @@ describe("cli thread commands", () => {
|
|||||||
.trim()
|
.trim()
|
||||||
.split("\n")
|
.split("\n")
|
||||||
.filter((l) => l !== "");
|
.filter((l) => l !== "");
|
||||||
expect(lines.length).toBe(2);
|
expect(lines.length).toBe(3);
|
||||||
|
|
||||||
const runningPath = join(dirname(dataPath), `${threadId}.running`);
|
const runningPath = join(dirname(dataPath), `${threadId}.running`);
|
||||||
expect(await pathExists(runningPath)).toBe(false);
|
expect(await pathExists(runningPath)).toBe(false);
|
||||||
@@ -289,8 +370,8 @@ describe("cli thread commands", () => {
|
|||||||
const resumed = await cmdResume(storageRoot, threadId);
|
const resumed = await cmdResume(storageRoot, threadId);
|
||||||
expect(resumed.ok).toBe(true);
|
expect(resumed.ok).toBe(true);
|
||||||
|
|
||||||
await waitUntilMinDataLines(dataPath, 3, 120);
|
await waitUntilMinDataLines(dataPath, 4, 120);
|
||||||
expect(await countDataJsonlLines(dataPath)).toBe(3);
|
expect(await countDataJsonlLines(dataPath)).toBe(4);
|
||||||
|
|
||||||
const runningPath = join(dirname(dataPath), `${threadId}.running`);
|
const runningPath = join(dirname(dataPath), `${threadId}.running`);
|
||||||
await waitUntilRunningFileAbsent(runningPath, 100);
|
await waitUntilRunningFileAbsent(runningPath, 100);
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
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,16 +1,21 @@
|
|||||||
{
|
{
|
||||||
"name": "@uncaged/cli-workflow",
|
"name": "@uncaged/cli-workflow",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"bin": {
|
"bin": {
|
||||||
"uncaged-workflow": "src/cli.ts"
|
"uncaged-workflow": "src/cli.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@uncaged/workflow": "workspace:*",
|
"@uncaged/workflow-protocol": "workspace:*",
|
||||||
|
"@uncaged/workflow-util": "workspace:*",
|
||||||
|
"@uncaged/workflow-cas": "workspace:*",
|
||||||
|
"@uncaged/workflow-execute": "workspace:*",
|
||||||
|
"@uncaged/workflow-register": "workspace:*",
|
||||||
|
"@uncaged/workflow-runtime": "workspace:*",
|
||||||
|
"hono": "^4.12.18",
|
||||||
"yaml": "^2.8.4"
|
"yaml": "^2.8.4"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "echo 'TODO'",
|
|
||||||
"test": "bun test"
|
"test": "bun test"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,9 @@
|
|||||||
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
|
||||||
import { err, ok, type Result } from "@uncaged/workflow";
|
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||||
|
|
||||||
async function pathExists(path: string): Promise<boolean> {
|
import { pathExists } from "./fs-utils.js";
|
||||||
try {
|
|
||||||
await stat(path);
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type BundleFileSource = { kind: "text"; text: string } | { kind: "path"; path: string };
|
export type BundleFileSource = { kind: "text"; text: string } | { kind: "path"; path: string };
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
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`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
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,315 +1,97 @@
|
|||||||
import { printCliError, printCliLine, printCliWarn } from "./cli-output.js";
|
import type { CommandEntry, DispatchFn } from "./cli-command-types.js";
|
||||||
import { cmdAdd, formatAddSuccess, parseAddArgv } from "./cmd-add.js";
|
import { printCliError, printCliLine } from "./cli-output.js";
|
||||||
import { cmdFork, parseForkArgv } from "./cmd-fork.js";
|
import { getCommandRegistry } from "./cli-registry.js";
|
||||||
import { cmdHistory } from "./cmd-history.js";
|
import { formatCliUsage as formatCliUsageWithGroups } from "./cli-usage.js";
|
||||||
import { cmdKill } from "./cmd-kill.js";
|
import { createCasDispatcher } from "./commands/cas/index.js";
|
||||||
import { cmdList, formatListLines } from "./cmd-list.js";
|
import { createInitDispatcher } from "./commands/init/index.js";
|
||||||
import { cmdPause } from "./cmd-pause.js";
|
import { dispatchServe } from "./commands/serve/index.js";
|
||||||
import { cmdPs } from "./cmd-ps.js";
|
import { createThreadDispatcher, dispatchLive, dispatchRun } from "./commands/thread/index.js";
|
||||||
import { cmdRemove } from "./cmd-remove.js";
|
import { createWorkflowDispatcher } from "./commands/workflow/index.js";
|
||||||
import { cmdResume } from "./cmd-resume.js";
|
import { formatSkillIndex, formatSkillTopic, getSkillTopics } from "./skill.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 { parseRunArgv } from "./run-argv.js";
|
|
||||||
|
|
||||||
function usage(): string {
|
export type { CommandEntry, CommandGroup, DispatchFn } from "./cli-command-types.js";
|
||||||
return [
|
export { getCommandRegistry } from "./cli-registry.js";
|
||||||
"Usage:",
|
|
||||||
" uncaged-workflow add <name> <file.esm.js> [--types <path>]",
|
function dispatchGroup(
|
||||||
" uncaged-workflow list",
|
tableName: string,
|
||||||
" uncaged-workflow show <name>",
|
table: Record<string, CommandEntry>,
|
||||||
" uncaged-workflow remove <name>",
|
storageRoot: string,
|
||||||
" uncaged-workflow run <name> [--prompt <text>] [--max-rounds N]",
|
argv: string[],
|
||||||
" uncaged-workflow ps",
|
): Promise<number> | null {
|
||||||
" uncaged-workflow kill <thread-id>",
|
const sub = argv[0];
|
||||||
" uncaged-workflow history <name>",
|
if (sub === undefined || sub === "--help" || sub === "-h") {
|
||||||
" uncaged-workflow rollback <name> [hash]",
|
const entries = Object.entries(table);
|
||||||
" uncaged-workflow pause <thread-id>",
|
const lines = [`${tableName} subcommands:\n`];
|
||||||
" uncaged-workflow resume <thread-id>",
|
for (const [name, e] of entries) {
|
||||||
" uncaged-workflow threads [name]",
|
const args = e.args ? ` ${e.args}` : "";
|
||||||
" uncaged-workflow thread <id>",
|
lines.push(` uncaged-workflow ${tableName} ${name}${args}`);
|
||||||
" uncaged-workflow thread rm <id>",
|
lines.push(` ${e.description}\n`);
|
||||||
" uncaged-workflow fork <thread-id> [--from-role <role>]",
|
}
|
||||||
].join("\n");
|
printCliLine(lines.join("\n"));
|
||||||
|
return Promise.resolve(sub === undefined ? 1 : 0);
|
||||||
|
}
|
||||||
|
const entry = table[sub];
|
||||||
|
if (entry === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return entry.handler(storageRoot, argv.slice(1));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function dispatchAdd(storageRoot: string, argv: string[]): Promise<number> {
|
export function formatCliUsage(): string {
|
||||||
const parsed = parseAddArgv(argv);
|
return formatCliUsageWithGroups(getCommandRegistry(), getSkillTopics());
|
||||||
if (!parsed.ok) {
|
}
|
||||||
printCliError(`${usage()}\n\nerror: ${parsed.error}`);
|
|
||||||
|
const dispatchWorkflow = createWorkflowDispatcher({ dispatchGroup });
|
||||||
|
const dispatchThread = createThreadDispatcher({ dispatchGroup });
|
||||||
|
const dispatchCas = createCasDispatcher({ dispatchGroup });
|
||||||
|
const dispatchInit = createInitDispatcher({ dispatchGroup });
|
||||||
|
|
||||||
|
async function showSkillDocOrIndex(topic: string | undefined): Promise<number> {
|
||||||
|
if (topic === undefined) {
|
||||||
|
printCliLine(formatSkillIndex());
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const doc = formatSkillTopic(topic);
|
||||||
|
if (doc === null) {
|
||||||
|
printCliError(`unknown skill topic: ${topic}\n\n${formatSkillIndex()}`);
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
const result = await cmdAdd(storageRoot, parsed.value);
|
printCliLine(doc);
|
||||||
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;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function dispatchList(storageRoot: string, argv: string[]): Promise<number> {
|
async function dispatchSkill(_storageRoot: string, argv: string[]): Promise<number> {
|
||||||
if (argv.length > 0) {
|
return showSkillDocOrIndex(argv[0]);
|
||||||
printCliError(`${usage()}\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(`${usage()}\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(`${usage()}\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(`${usage()}\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(`${usage()}\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(`${usage()}\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 dispatchHistory(storageRoot: string, argv: string[]): Promise<number> {
|
|
||||||
const name = argv[0];
|
|
||||||
if (name === undefined || argv.length > 1) {
|
|
||||||
printCliError(`${usage()}\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(`${usage()}\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(`${usage()}\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(`${usage()}\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 dispatchThreads(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 dispatchThread(storageRoot: string, argv: string[]): Promise<number> {
|
|
||||||
const id = argv[0];
|
|
||||||
if (id === undefined || argv.length > 1) {
|
|
||||||
printCliError(`${usage()}\n\nerror: thread 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(`${usage()}\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 dispatchThreadBranch(storageRoot: string, rest: string[]): Promise<number> {
|
|
||||||
const sub = rest[0];
|
|
||||||
if (sub === "rm") {
|
|
||||||
return dispatchThreadRm(storageRoot, rest.slice(1));
|
|
||||||
}
|
|
||||||
return dispatchThread(storageRoot, rest);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function dispatchFork(storageRoot: string, argv: string[]): Promise<number> {
|
|
||||||
const parsed = parseForkArgv(argv);
|
|
||||||
if (!parsed.ok) {
|
|
||||||
printCliError(`${usage()}\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;
|
|
||||||
}
|
|
||||||
|
|
||||||
type DispatchFn = (storageRoot: string, argv: string[]) => Promise<number>;
|
|
||||||
|
|
||||||
const COMMAND_TABLE: Record<string, DispatchFn> = {
|
const COMMAND_TABLE: Record<string, DispatchFn> = {
|
||||||
add: dispatchAdd,
|
workflow: dispatchWorkflow,
|
||||||
list: dispatchList,
|
thread: dispatchThread,
|
||||||
show: dispatchShow,
|
cas: dispatchCas,
|
||||||
remove: dispatchRemove,
|
init: dispatchInit,
|
||||||
|
skill: dispatchSkill,
|
||||||
run: dispatchRun,
|
run: dispatchRun,
|
||||||
ps: dispatchPs,
|
live: dispatchLive,
|
||||||
kill: dispatchKill,
|
serve: dispatchServe,
|
||||||
history: dispatchHistory,
|
|
||||||
rollback: dispatchRollback,
|
|
||||||
pause: dispatchPause,
|
|
||||||
resume: dispatchResume,
|
|
||||||
threads: dispatchThreads,
|
|
||||||
thread: dispatchThreadBranch,
|
|
||||||
fork: dispatchFork,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function runCli(storageRoot: string, argv: string[]): Promise<number> {
|
export async function runCli(storageRoot: string, argv: string[]): Promise<number> {
|
||||||
if (argv.length === 0) {
|
if (argv.length === 0) {
|
||||||
printCliError(usage());
|
printCliLine(formatCliUsage());
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
const command = argv[0];
|
const command = argv[0];
|
||||||
if (command === undefined) {
|
if (command === undefined) {
|
||||||
printCliError(usage());
|
printCliLine(formatCliUsage());
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
const rest = argv.slice(1);
|
const rest = argv.slice(1);
|
||||||
|
|
||||||
const dispatch = COMMAND_TABLE[command];
|
const dispatch = COMMAND_TABLE[command];
|
||||||
if (dispatch === undefined) {
|
if (dispatch !== undefined) {
|
||||||
printCliError(`${usage()}\n\nerror: unknown command ${command}`);
|
return dispatch(storageRoot, rest);
|
||||||
return 1;
|
|
||||||
}
|
}
|
||||||
return dispatch(storageRoot, rest);
|
|
||||||
|
printCliError(`${formatCliUsage()}\n\nerror: unknown command ${command}`);
|
||||||
|
return 1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
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());
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
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");
|
||||||
|
}
|
||||||
Regular → Executable
@@ -1,43 +0,0 @@
|
|||||||
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 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
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,43 +0,0 @@
|
|||||||
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 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
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;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
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";
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import type { DispatchGroupFn } from "../../cli-command-types.js";
|
||||||
|
|
||||||
|
export type CasDispatchDeps = {
|
||||||
|
dispatchGroup: DispatchGroupFn;
|
||||||
|
};
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
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;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
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";
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
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 });
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
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,
|
||||||
|
};
|
||||||
|
`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import type { DispatchGroupFn } from "../../cli-command-types.js";
|
||||||
|
|
||||||
|
export type CmdInitTemplateSuccess = {
|
||||||
|
templatePath: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CmdInitWorkspaceSuccess = {
|
||||||
|
rootPath: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type InitDispatchDeps = {
|
||||||
|
dispatchGroup: DispatchGroupFn;
|
||||||
|
};
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
import { mkdir, writeFile } from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||||
|
|
||||||
|
import { pathExists } from "../../fs-utils.js";
|
||||||
|
import type { CmdInitWorkspaceSuccess } from "./types.js";
|
||||||
|
import { validateWorkspaceSegment } from "./validate.js";
|
||||||
|
|
||||||
|
function rootPackageJson(workspaceName: string): string {
|
||||||
|
return `${JSON.stringify(
|
||||||
|
{
|
||||||
|
name: workspaceName,
|
||||||
|
private: true,
|
||||||
|
type: "module",
|
||||||
|
workspaces: ["templates/*", "workflows"],
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
)}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function workflowsPackageJson(): string {
|
||||||
|
return `${JSON.stringify(
|
||||||
|
{
|
||||||
|
name: "workflows",
|
||||||
|
version: "0.0.0",
|
||||||
|
private: true,
|
||||||
|
type: "module",
|
||||||
|
dependencies: {
|
||||||
|
"@uncaged/workflow-runtime": "^0.1.0",
|
||||||
|
zod: "^4.0.0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
)}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function biomeJson(): string {
|
||||||
|
return `${JSON.stringify(
|
||||||
|
{
|
||||||
|
$schema: "https://biomejs.dev/schemas/2.4.14/schema.json",
|
||||||
|
files: {
|
||||||
|
includes: ["**", "!**/node_modules", "!**/dist"],
|
||||||
|
},
|
||||||
|
formatter: {
|
||||||
|
indentWidth: 2,
|
||||||
|
},
|
||||||
|
linter: {
|
||||||
|
enabled: true,
|
||||||
|
rules: {
|
||||||
|
recommended: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
)}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tsconfigJson(): string {
|
||||||
|
return `${JSON.stringify(
|
||||||
|
{
|
||||||
|
compilerOptions: {
|
||||||
|
strict: true,
|
||||||
|
target: "ESNext",
|
||||||
|
module: "ESNext",
|
||||||
|
moduleResolution: "Bundler",
|
||||||
|
skipLibCheck: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
)}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function agentsMd(): string {
|
||||||
|
return `# AGENTS — Workflow 工作区开发指南
|
||||||
|
|
||||||
|
面向在本仓库中编写 workflow 的 coding agent。引擎层术语与架构细节与 **@uncaged/workflow** 上游文档一致,编写时可对照 \`CLAUDE.md\` 与 \`docs/architecture.md\`。
|
||||||
|
|
||||||
|
## 1. 项目结构(workspace / template / workflow instance)
|
||||||
|
|
||||||
|
| 层级 | 目录 / 产物 | 职责 |
|
||||||
|
|------|----------------|------|
|
||||||
|
| **Workspace** | 仓库根(\`package.json\` 含 \`workspaces: ["templates/*", "workflows"]\`) | Bun monorepo:统一管理本地模板包与 workflow 实例 |
|
||||||
|
| **Template** | \`templates/<name>/\`(如 \`src/roles.ts\`、\`src/moderator.ts\`、\`src/index.ts\`) | 纯数据:**WorkflowDefinition**(各 **RoleDefinition** + **Moderator**),**不绑定**具体 Agent |
|
||||||
|
| **Workflow instance** | \`workflows/\`(或单独包) | 把模板与运行时 **AgentFn** / **ExtractFn** 组合,产出可注册的 **单文件 ESM bundle**(\`run\` + \`descriptor\` 命名导出) |
|
||||||
|
|
||||||
|
Init 生成的骨架:\`templates/\` 下放可复用定义,\`workflows/\` 下放绑定与打包入口。
|
||||||
|
|
||||||
|
## 2. 核心概念
|
||||||
|
|
||||||
|
- **RoleMeta**:\`Record<string, Record<string, unknown>>\`,角色名 → 该角色结构化 meta 的形状约定。
|
||||||
|
- **RoleDefinition<Meta>**:纯数据——\`description\`、\`systemPrompt\`、\`extractPrompt\`、\`schema\`(Zod v4)。不含执行逻辑。
|
||||||
|
- **WorkflowDefinition<M extends RoleMeta>**:\`description\` + \`roles\`(各角色定义)+ **Moderator**。
|
||||||
|
- **Moderator**:\`(ctx: ModeratorContext<M>) => (角色名) | END\`。同步、纯函数,只做路由。
|
||||||
|
- **AgentFn**:\`(ctx: AgentContext) => Promise<string>\`,原始文本输出;从上下文读取当前角色的 \`systemPrompt\`。
|
||||||
|
- **ExtractFn**:从上下文与 prompt 解析结构化数据(引擎与 Agent 都可使用)。
|
||||||
|
|
||||||
|
引擎循环简述:**Moderator** → 选角色 → **Agent** 产出文本 → **Extract** 写入 **meta** → 追加 step,重复直至 **END**。详见 \`docs/architecture.md\` 中的三阶段说明。
|
||||||
|
|
||||||
|
## 3. 开发流程
|
||||||
|
|
||||||
|
1. **定义 RoleMeta**:为每个角色约定 meta 的 TypeScript 类型(与 Zod schema 对齐)。
|
||||||
|
2. **编写 RoleDefinition**:为每个角色写 Zod \`schema\`,补齐 \`systemPrompt\` / \`extractPrompt\` / \`description\`。
|
||||||
|
3. **编写 Moderator**:根据 \`ctx.steps\` 与业务状态返回下一个角色名或 \`END\`。
|
||||||
|
4. **组装 WorkflowDefinition**:在模板 \`index\` 中导出 definition(以及必要的角色 / moderator 导出)。
|
||||||
|
5. **实例化**:在 workflow 包中使用 \`createWorkflow(def, binding)\`(或项目约定的封装)绑定 **AgentFn**;**ExtractFn** 由引擎从 **workflow.yaml** 注入 \`WorkflowRuntime\`。
|
||||||
|
6. **构建**:打包为单个 **.esm.js** bundle,使用 **uncaged-workflow add** 注册。
|
||||||
|
|
||||||
|
## 4. 编码规范
|
||||||
|
|
||||||
|
与 **CLAUDE.md** 对齐,摘要如下:
|
||||||
|
|
||||||
|
- **Functional-first**:优先 \`function\` + \`type\`,避免面向对象业务模型。
|
||||||
|
- **type 而非 interface**:类型别名一律用 \`type\`,不要使用 \`interface\`。
|
||||||
|
- **显式可空**:不要用 \`?:\`;可空字段写成 \`T | null\`。
|
||||||
|
- **function 而非 class**:不用 class(第三方库要求或 \`Error\` 子类除外)。
|
||||||
|
- **Crockford Base32**:日志 tag、bundle hash、thread id 等标识约定(引擎侧);工作区内自定义日志若沿用引擎 logger,tag 为 8 字符 Crockford Base32,且每个调用点唯一。
|
||||||
|
- **Named exports only**:不要使用 **default export**;workflow bundle 须 **export const run** 与 **export const descriptor**。
|
||||||
|
- **No console.log**:库代码用结构化 logger;CLI 用户输出可按项目 Biome 规则例外标注。
|
||||||
|
- **No dynamic import**:业务与 bundle 内禁止 \`import()\`;例外仅限「运行时路径由用户提供的 bundle 加载器」(引擎内部)。
|
||||||
|
|
||||||
|
## 5. Template 复用
|
||||||
|
|
||||||
|
- **已发布模板**:可通过 npm 依赖 \`@uncaged/workflow-template-*\` 等包,在 workflow 实例中 import 其 **WorkflowDefinition** 再绑定 Agent。
|
||||||
|
- **本地模板**:放在本仓库 \`templates/<name>/\`,由 workspace 协议引用(如 \`"template-foo": "workspace:*"\` 或相对路径),便于同源修改与版本控制。
|
||||||
|
|
||||||
|
选择模板时保持 **definition 与 agent 绑定分离**:模板只描述「做什么、顺序如何」,实例决定「谁执行、如何抽取 meta」。
|
||||||
|
|
||||||
|
## 6. Build and Test
|
||||||
|
|
||||||
|
日常命令:
|
||||||
|
|
||||||
|
\`\`\`sh
|
||||||
|
bun install
|
||||||
|
bun run check # Biome:lint + format
|
||||||
|
bun test
|
||||||
|
bun build # 若包内配置了 build 脚本则用于产出 dist / bundle
|
||||||
|
uncaged-workflow add <name> <path/to/bundle.esm.js>
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
提交前至少运行 **bun run check** 与 **bun test**;registry 与本地运行流参见 README 与 CLI 文档。
|
||||||
|
|
||||||
|
## 7. 常见陷阱
|
||||||
|
|
||||||
|
- **No dynamic import**:bundle 须静态可分析;动态 \`import()\` 会破坏哈希与加载约束。
|
||||||
|
- **No default export**:引擎只接受命名导出 \`run\` / \`descriptor\`。
|
||||||
|
- **No console.log**:避免在可被 Biome \`noConsole\` 规则覆盖的代码路径直接使用 console。
|
||||||
|
- **Single-file ESM bundle**:交付物是单一 \`.esm.js\`;静态 import 仅限 Node 内置(见 architecture 文档中的 Bundle Contract)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
编写新 workflow 时,先对齐 **RoleMeta → RoleDefinition(Zod)→ Moderator → 绑定 → 单文件 bundle**,再对照本节规范自检。
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readmeMd(workspaceName: string): string {
|
||||||
|
return `# ${workspaceName}
|
||||||
|
|
||||||
|
Local workflow development workspace (Bun monorepo).
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
- \`templates/\` — reusable workflow definition packages (roles + moderator), no agent binding
|
||||||
|
- \`workflows/\` — workflow instances that bind templates to agents and export \`run\` + \`descriptor\`
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
\`\`\`sh
|
||||||
|
bun install
|
||||||
|
bun run check # after you add scripts / Biome
|
||||||
|
uncaged-workflow add <name> <bundle.esm.js>
|
||||||
|
uncaged-workflow run <name>
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
Create this skeleton with:
|
||||||
|
|
||||||
|
\`\`\`sh
|
||||||
|
uncaged-workflow init workspace ${workspaceName}
|
||||||
|
\`\`\`
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cmdInitWorkspace(
|
||||||
|
parentDir: string,
|
||||||
|
workspaceName: string,
|
||||||
|
): Promise<Result<CmdInitWorkspaceSuccess, string>> {
|
||||||
|
const validated = validateWorkspaceSegment(workspaceName);
|
||||||
|
if (!validated.ok) {
|
||||||
|
return validated;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rootPath = join(parentDir, workspaceName);
|
||||||
|
if (await pathExists(rootPath)) {
|
||||||
|
return err(`directory already exists: ${rootPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await mkdir(rootPath, { recursive: false });
|
||||||
|
await mkdir(join(rootPath, "templates"), { recursive: false });
|
||||||
|
await mkdir(join(rootPath, "workflows"), { recursive: false });
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
writeFile(join(rootPath, "package.json"), rootPackageJson(workspaceName), "utf8"),
|
||||||
|
writeFile(join(rootPath, "biome.json"), biomeJson(), "utf8"),
|
||||||
|
writeFile(join(rootPath, "tsconfig.json"), tsconfigJson(), "utf8"),
|
||||||
|
writeFile(join(rootPath, "AGENTS.md"), agentsMd(), "utf8"),
|
||||||
|
writeFile(join(rootPath, "README.md"), readmeMd(workspaceName), "utf8"),
|
||||||
|
writeFile(join(rootPath, "templates", ".gitkeep"), "", "utf8"),
|
||||||
|
writeFile(join(rootPath, "workflows", "package.json"), workflowsPackageJson(), "utf8"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return ok({ rootPath });
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { createApp } from "./app.js";
|
||||||
|
export { dispatchServe, startServer } from "./serve.js";
|
||||||
|
export type { ServeOptions } from "./types.js";
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export type ServeOptions = {
|
||||||
|
port: number;
|
||||||
|
hostname: string;
|
||||||
|
};
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
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");
|
||||||
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
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;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
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 });
|
||||||
|
}
|
||||||
+8
-32
@@ -1,37 +1,12 @@
|
|||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
|
||||||
import { buildForkPlan, err, generateUlid, ok, type Result } from "@uncaged/workflow";
|
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||||
|
import { generateUlid } from "@uncaged/workflow-util";
|
||||||
|
import { buildForkPlan } from "@uncaged/workflow-execute";
|
||||||
|
|
||||||
import { pathExists, readTextFileIfExists } from "./fs-utils.js";
|
import { pathExists, readTextFileIfExists } from "../../fs-utils.js";
|
||||||
import { resolveThreadDataPath } from "./thread-scan.js";
|
import { resolveThreadDataPath } from "../../thread-scan.js";
|
||||||
import { ensureWorkerForHash, sendWorkerTcpCommand } from "./worker-spawn.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(
|
export async function cmdFork(
|
||||||
storageRoot: string,
|
storageRoot: string,
|
||||||
@@ -65,8 +40,9 @@ export async function cmdFork(
|
|||||||
const newThreadId = generateUlid(Date.now());
|
const newThreadId = generateUlid(Date.now());
|
||||||
const stepsOnWire = plan.value.historicalSteps.map((s) => ({
|
const stepsOnWire = plan.value.historicalSteps.map((s) => ({
|
||||||
role: s.role,
|
role: s.role,
|
||||||
content: s.content,
|
contentHash: s.contentHash,
|
||||||
meta: s.meta,
|
meta: s.meta,
|
||||||
|
refs: s.refs,
|
||||||
timestamp: s.timestamp,
|
timestamp: s.timestamp,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
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";
|
||||||
+3
-3
@@ -1,7 +1,7 @@
|
|||||||
import { err, ok, type Result } from "@uncaged/workflow";
|
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||||
|
|
||||||
import { listHistoricalThreads } from "./thread-scan.js";
|
import { listHistoricalThreads } from "../../thread-scan.js";
|
||||||
import { validateCliWorkflowName } from "./workflow-name.js";
|
import { validateCliWorkflowName } from "../../workflow-name.js";
|
||||||
|
|
||||||
export async function cmdThreads(
|
export async function cmdThreads(
|
||||||
storageRoot: string,
|
storageRoot: string,
|
||||||
@@ -0,0 +1,435 @@
|
|||||||
|
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 { 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";
|
||||||
|
|
||||||
|
export const LIVE_CONTENT_MAX_LINES = 10;
|
||||||
|
|
||||||
|
export function formatLiveTimeLabel(timestampMs: number): string {
|
||||||
|
const d = new Date(timestampMs);
|
||||||
|
const hh = String(d.getHours()).padStart(2, "0");
|
||||||
|
const mm = String(d.getMinutes()).padStart(2, "0");
|
||||||
|
const ss = String(d.getSeconds()).padStart(2, "0");
|
||||||
|
return `${hh}:${mm}:${ss}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatLiveDebugLine(timestampMs: number, tag: string, message: string): string {
|
||||||
|
const label = `[${formatLiveTimeLabel(timestampMs)}] [${tag}] ${message.replace(/\n/g, " ")}`;
|
||||||
|
return dimGreyLine(label);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderLiveRoleStepLines(row: LiveRoleRow, roleDisplay: string): string[] {
|
||||||
|
const header = `[${formatLiveTimeLabel(row.timestamp)}] ▶ ${roleDisplay}`;
|
||||||
|
const lines: string[] = [header];
|
||||||
|
const parts = row.content.split("\n");
|
||||||
|
const shown = parts.slice(0, LIVE_CONTENT_MAX_LINES);
|
||||||
|
for (const ln of shown) {
|
||||||
|
lines.push(` ${ln}`);
|
||||||
|
}
|
||||||
|
const omitted = parts.length - shown.length;
|
||||||
|
if (omitted > 0) {
|
||||||
|
lines.push(` … (${omitted} more line${omitted === 1 ? "" : "s"})`);
|
||||||
|
}
|
||||||
|
lines.push(` meta: ${JSON.stringify(row.meta)}`);
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
function printSummary(result: WorkflowCompletion): void {
|
||||||
|
printCliLine(`completed: returnCode=${result.returnCode} — ${result.summary}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
type LiveSessionState = {
|
||||||
|
sawStart: boolean;
|
||||||
|
completed: boolean;
|
||||||
|
carry: string;
|
||||||
|
contentOffset: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type InfoLiveState = {
|
||||||
|
carry: string;
|
||||||
|
contentOffset: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function tryParseInfoRecord(obj: Record<string, unknown>): {
|
||||||
|
tag: string;
|
||||||
|
content: string;
|
||||||
|
timestamp: number;
|
||||||
|
} | null {
|
||||||
|
const tag = obj.tag;
|
||||||
|
const content = obj.content;
|
||||||
|
const timestamp = obj.timestamp;
|
||||||
|
if (
|
||||||
|
typeof tag !== "string" ||
|
||||||
|
typeof content !== "string" ||
|
||||||
|
typeof timestamp !== "number" ||
|
||||||
|
!Number.isFinite(timestamp)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { tag, content, timestamp };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleJsonlLine(
|
||||||
|
rawLine: string,
|
||||||
|
state: LiveSessionState,
|
||||||
|
roleFilter: string | null,
|
||||||
|
cas: CasStore,
|
||||||
|
): Promise<{ parseError: string | null; workflowResult: WorkflowCompletion | null }> {
|
||||||
|
const trimmed = rawLine.trim();
|
||||||
|
if (trimmed === "") {
|
||||||
|
return { parseError: null, workflowResult: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
let rec: unknown;
|
||||||
|
try {
|
||||||
|
rec = JSON.parse(trimmed) as unknown;
|
||||||
|
} catch {
|
||||||
|
return { parseError: "invalid JSON in thread data file", workflowResult: null };
|
||||||
|
}
|
||||||
|
if (rec === null || typeof rec !== "object") {
|
||||||
|
return { parseError: "invalid record in thread data file", workflowResult: null };
|
||||||
|
}
|
||||||
|
const obj = rec as Record<string, unknown>;
|
||||||
|
|
||||||
|
if (!state.sawStart) {
|
||||||
|
state.sawStart = true;
|
||||||
|
return { parseError: null, workflowResult: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const wf = tryParseWorkflowResultRecord(obj);
|
||||||
|
if (wf !== null) {
|
||||||
|
state.completed = true;
|
||||||
|
return { parseError: null, workflowResult: wf };
|
||||||
|
}
|
||||||
|
|
||||||
|
const roleRow = tryParseRoleStepRecord(obj);
|
||||||
|
if (roleRow === null) {
|
||||||
|
return {
|
||||||
|
parseError: "unrecognized record in thread data (expected role step or result)",
|
||||||
|
workflowResult: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (roleFilter !== null && roleRow.role !== roleFilter) {
|
||||||
|
return { parseError: null, workflowResult: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await getContentMerklePayload(cas, roleRow.contentHash);
|
||||||
|
const content =
|
||||||
|
payload !== null ? payload : `(content not in CAS; contentHash=${roleRow.contentHash})`;
|
||||||
|
|
||||||
|
const row: LiveRoleRow = {
|
||||||
|
role: roleRow.role,
|
||||||
|
content,
|
||||||
|
meta: roleRow.meta,
|
||||||
|
timestamp: roleRow.timestamp,
|
||||||
|
};
|
||||||
|
for (const outLine of renderLiveRoleStepLines(row, highlightLiveRole(row.role))) {
|
||||||
|
printCliLine(outLine);
|
||||||
|
}
|
||||||
|
return { parseError: null, workflowResult: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pumpNewContent(
|
||||||
|
dataPath: string,
|
||||||
|
state: LiveSessionState,
|
||||||
|
roleFilter: string | null,
|
||||||
|
cas: CasStore,
|
||||||
|
): Promise<number | null> {
|
||||||
|
let text: string;
|
||||||
|
try {
|
||||||
|
text = await readFile(dataPath, "utf8");
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text.length < state.contentOffset) {
|
||||||
|
state.contentOffset = 0;
|
||||||
|
state.carry = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunk = text.slice(state.contentOffset);
|
||||||
|
state.contentOffset = text.length;
|
||||||
|
state.carry += chunk;
|
||||||
|
|
||||||
|
const parts = state.carry.split("\n");
|
||||||
|
state.carry = parts.pop() ?? "";
|
||||||
|
|
||||||
|
for (const line of parts) {
|
||||||
|
const { parseError, workflowResult } = await handleJsonlLine(line, state, roleFilter, cas);
|
||||||
|
if (parseError !== null) {
|
||||||
|
printCliError(parseError);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if (workflowResult !== null) {
|
||||||
|
printSummary(workflowResult);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pumpNewInfoContent(infoPath: string, state: InfoLiveState): Promise<void> {
|
||||||
|
let text: string;
|
||||||
|
try {
|
||||||
|
text = await readFile(infoPath, "utf8");
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text.length < state.contentOffset) {
|
||||||
|
state.contentOffset = 0;
|
||||||
|
state.carry = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunk = text.slice(state.contentOffset);
|
||||||
|
state.contentOffset = text.length;
|
||||||
|
state.carry += chunk;
|
||||||
|
|
||||||
|
const parts = state.carry.split("\n");
|
||||||
|
state.carry = parts.pop() ?? "";
|
||||||
|
|
||||||
|
for (const line of parts) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (trimmed === "") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let rec: unknown;
|
||||||
|
try {
|
||||||
|
rec = JSON.parse(trimmed) as unknown;
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (rec === null || typeof rec !== "object") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const parsed = tryParseInfoRecord(rec as Record<string, unknown>);
|
||||||
|
if (parsed === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
printCliLine(formatLiveDebugLine(parsed.timestamp, parsed.tag, parsed.content));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type WatchPumpTask = {
|
||||||
|
path: string;
|
||||||
|
pump: () => Promise<number | null>;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function runWatchPumpStep(
|
||||||
|
settled: () => boolean,
|
||||||
|
pump: () => Promise<number | null>,
|
||||||
|
closeAll: () => void,
|
||||||
|
finish: (code: number) => void,
|
||||||
|
): Promise<void> {
|
||||||
|
if (settled()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const code = await pump();
|
||||||
|
if (code !== null) {
|
||||||
|
closeAll();
|
||||||
|
finish(code);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
closeAll();
|
||||||
|
throw e instanceof Error ? e : new Error(String(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function watchLivePaths(params: { tasks: WatchPumpTask[]; signal: AbortSignal }): Promise<number> {
|
||||||
|
const { tasks, signal } = params;
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let settled = false;
|
||||||
|
const finish = (code: number): void => {
|
||||||
|
if (settled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
settled = true;
|
||||||
|
resolve(code);
|
||||||
|
};
|
||||||
|
|
||||||
|
const pumpChains = new Map<string, Promise<void>>();
|
||||||
|
for (const t of tasks) {
|
||||||
|
pumpChains.set(t.path, Promise.resolve());
|
||||||
|
}
|
||||||
|
|
||||||
|
const watchers: ReturnType<typeof watch>[] = [];
|
||||||
|
|
||||||
|
const closeAll = (): void => {
|
||||||
|
for (const w of watchers) {
|
||||||
|
w.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function schedulePump(path: string, pump: () => Promise<number | null>): void {
|
||||||
|
const prev = pumpChains.get(path) ?? Promise.resolve();
|
||||||
|
const next = (async () => {
|
||||||
|
await prev;
|
||||||
|
await runWatchPumpStep(() => settled, pump, closeAll, finish);
|
||||||
|
})();
|
||||||
|
pumpChains.set(path, next);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const { path, pump } of tasks) {
|
||||||
|
const watcher = watch(path, (eventType) => {
|
||||||
|
if (eventType === "rename") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
schedulePump(path, pump);
|
||||||
|
});
|
||||||
|
watchers.push(watcher);
|
||||||
|
watcher.on("error", (err: Error) => {
|
||||||
|
closeAll();
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const onAbort = (): void => {
|
||||||
|
closeAll();
|
||||||
|
finish(0);
|
||||||
|
};
|
||||||
|
signal.addEventListener("abort", onAbort, { once: true });
|
||||||
|
|
||||||
|
for (const { path, pump } of tasks) {
|
||||||
|
schedulePump(path, pump);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
type LiveThreadTarget = {
|
||||||
|
threadId: string;
|
||||||
|
dataPath: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function resolveLiveThreadTarget(
|
||||||
|
storageRoot: string,
|
||||||
|
parsed: ParsedLiveArgv,
|
||||||
|
): Promise<LiveThreadTarget | null> {
|
||||||
|
if (parsed.latest) {
|
||||||
|
const found = await findLatestThreadDataPath(storageRoot);
|
||||||
|
if (found === null) {
|
||||||
|
printCliError("live: no threads found");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = parsed.threadId;
|
||||||
|
if (id === null) {
|
||||||
|
printCliError("live: internal error: missing thread id");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const resolved = await resolveThreadDataPath(storageRoot, id);
|
||||||
|
if (resolved === null) {
|
||||||
|
printCliError(`thread not found: ${id}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { threadId: id, dataPath: resolved };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildLiveWatchTasks(params: {
|
||||||
|
dataPath: string;
|
||||||
|
infoPath: string;
|
||||||
|
debug: boolean;
|
||||||
|
dataState: LiveSessionState;
|
||||||
|
infoState: InfoLiveState;
|
||||||
|
roleFilter: string | null;
|
||||||
|
cas: CasStore;
|
||||||
|
}): Promise<WatchPumpTask[]> {
|
||||||
|
const { dataPath, infoPath, debug, dataState, infoState, roleFilter, cas } = params;
|
||||||
|
const tasks: WatchPumpTask[] = [
|
||||||
|
{
|
||||||
|
path: dataPath,
|
||||||
|
pump: () => pumpNewContent(dataPath, dataState, roleFilter, cas),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (debug && (await pathExists(infoPath))) {
|
||||||
|
tasks.push({
|
||||||
|
path: infoPath,
|
||||||
|
pump: async () => {
|
||||||
|
await pumpNewInfoContent(infoPath, infoState);
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return tasks;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cmdLive(storageRoot: string, parsed: ParsedLiveArgv): Promise<number> {
|
||||||
|
const target = await resolveLiveThreadTarget(storageRoot, parsed);
|
||||||
|
if (target === null) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { threadId, dataPath } = target;
|
||||||
|
const roleFilter = parsed.role;
|
||||||
|
const infoPath = join(dirname(dataPath), `${threadId}.info.jsonl`);
|
||||||
|
const cas = createCasStore(getGlobalCasDir(storageRoot));
|
||||||
|
|
||||||
|
const dataState: LiveSessionState = {
|
||||||
|
sawStart: false,
|
||||||
|
completed: false,
|
||||||
|
carry: "",
|
||||||
|
contentOffset: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const infoState: InfoLiveState = {
|
||||||
|
carry: "",
|
||||||
|
contentOffset: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const onSigInt = (): void => {
|
||||||
|
controller.abort();
|
||||||
|
};
|
||||||
|
process.on("SIGINT", onSigInt);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const firstData = await pumpNewContent(dataPath, dataState, roleFilter, cas);
|
||||||
|
if (firstData === 1) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.debug && (await pathExists(infoPath))) {
|
||||||
|
await pumpNewInfoContent(infoPath, infoState);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (firstData === 0 || dataState.completed) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tasks = await buildLiveWatchTasks({
|
||||||
|
dataPath,
|
||||||
|
infoPath,
|
||||||
|
debug: parsed.debug,
|
||||||
|
dataState,
|
||||||
|
infoState,
|
||||||
|
roleFilter,
|
||||||
|
cas,
|
||||||
|
});
|
||||||
|
|
||||||
|
return await watchLivePaths({ tasks, signal: controller.signal });
|
||||||
|
} catch (e) {
|
||||||
|
const message = e instanceof Error ? e.message : String(e);
|
||||||
|
printCliError(`live: ${message}`);
|
||||||
|
return 1;
|
||||||
|
} finally {
|
||||||
|
process.off("SIGINT", onSigInt);
|
||||||
|
}
|
||||||
|
}
|
||||||
+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[]> {
|
export async function cmdPs(storageRoot: string): Promise<string[]> {
|
||||||
const rows = await listRunningThreads(storageRoot);
|
const rows = await listRunningThreads(storageRoot);
|
||||||
+5
-18
@@ -1,25 +1,10 @@
|
|||||||
import { unlink } from "node:fs/promises";
|
import { unlink } from "node:fs/promises";
|
||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
|
|
||||||
import { err, ok, type Result } from "@uncaged/workflow";
|
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||||
|
import { garbageCollectCas } from "@uncaged/workflow-execute";
|
||||||
|
|
||||||
import { readTextFileIfExists } from "./fs-utils.js";
|
import { resolveThreadDataPath } from "../../thread-scan.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(
|
export async function cmdThreadRemove(
|
||||||
storageRoot: string,
|
storageRoot: string,
|
||||||
@@ -38,5 +23,7 @@ export async function cmdThreadRemove(
|
|||||||
await unlink(infoPath).catch(() => {});
|
await unlink(infoPath).catch(() => {});
|
||||||
await unlink(runningPath).catch(() => {});
|
await unlink(runningPath).catch(() => {});
|
||||||
|
|
||||||
|
await garbageCollectCas(storageRoot);
|
||||||
|
|
||||||
return ok(undefined);
|
return ok(undefined);
|
||||||
}
|
}
|
||||||
+6
-11
@@ -1,15 +1,10 @@
|
|||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
|
||||||
import {
|
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||||
err,
|
import { generateUlid } from "@uncaged/workflow-util";
|
||||||
generateUlid,
|
import { getRegisteredWorkflow, readWorkflowRegistry } from "@uncaged/workflow-register";
|
||||||
getRegisteredWorkflow,
|
import { ensureWorkerForHash, sendWorkerTcpCommand } from "../../worker-spawn.js";
|
||||||
ok,
|
import { validateCliWorkflowName } from "../../workflow-name.js";
|
||||||
type Result,
|
|
||||||
readWorkflowRegistry,
|
|
||||||
} from "@uncaged/workflow";
|
|
||||||
import { ensureWorkerForHash, sendWorkerTcpCommand } from "./worker-spawn.js";
|
|
||||||
import { validateCliWorkflowName } from "./workflow-name.js";
|
|
||||||
|
|
||||||
export async function cmdRun(
|
export async function cmdRun(
|
||||||
storageRoot: string,
|
storageRoot: string,
|
||||||
@@ -46,7 +41,7 @@ export async function cmdRun(
|
|||||||
threadId,
|
threadId,
|
||||||
workflowName: name,
|
workflowName: name,
|
||||||
prompt,
|
prompt,
|
||||||
options: { maxRounds },
|
options: { maxRounds, depth: 0 },
|
||||||
},
|
},
|
||||||
{ awaitResponseLine: false },
|
{ awaitResponseLine: false },
|
||||||
);
|
);
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
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;
|
||||||
|
};
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
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 });
|
||||||
|
}
|
||||||
+7
-88
@@ -1,33 +1,21 @@
|
|||||||
import { readFile, stat } from "node:fs/promises";
|
import { readFile, stat } from "node:fs/promises";
|
||||||
import { basename, resolve } from "node:path";
|
import { basename, resolve } from "node:path";
|
||||||
|
|
||||||
|
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||||
|
import { hashWorkflowBundleBytes } from "@uncaged/workflow-cas";
|
||||||
import {
|
import {
|
||||||
err,
|
|
||||||
extractBundleExports,
|
extractBundleExports,
|
||||||
hashWorkflowBundleBytes,
|
|
||||||
ok,
|
|
||||||
type Result,
|
|
||||||
readWorkflowRegistry,
|
readWorkflowRegistry,
|
||||||
registerWorkflowVersion,
|
registerWorkflowVersion,
|
||||||
stringifyWorkflowDescriptor,
|
stringifyWorkflowDescriptor,
|
||||||
validateWorkflowBundle,
|
validateWorkflowBundle,
|
||||||
writeWorkflowRegistry,
|
writeWorkflowRegistry,
|
||||||
} from "@uncaged/workflow";
|
} from "@uncaged/workflow-register";
|
||||||
|
|
||||||
import { storeWorkflowBundleArtifacts } from "./bundle-store.js";
|
import { storeWorkflowBundleArtifacts } from "../../bundle-store.js";
|
||||||
import { validateCliWorkflowName } from "./workflow-name.js";
|
import { validateCliWorkflowName } from "../../workflow-name.js";
|
||||||
|
|
||||||
export type ParsedAddArgv = {
|
import type { CmdAddSuccess, ParsedAddArgv } from "./types.js";
|
||||||
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 {
|
function isEsmBundle(path: string): boolean {
|
||||||
return path.endsWith(".esm.js");
|
return path.endsWith(".esm.js");
|
||||||
@@ -37,75 +25,6 @@ function defaultTypesPath(bundlePath: string): string {
|
|||||||
return bundlePath.replace(/\.esm\.js$/i, ".d.ts");
|
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(
|
async function registerHash(
|
||||||
storageRoot: string,
|
storageRoot: string,
|
||||||
name: string,
|
name: string,
|
||||||
@@ -192,7 +111,7 @@ export async function cmdAdd(
|
|||||||
return validated;
|
return validated;
|
||||||
}
|
}
|
||||||
|
|
||||||
const extracted = await extractBundleExports(resolvedPath);
|
const extracted = await extractBundleExports(resolvedPath, { storageRoot });
|
||||||
if (!extracted.ok) {
|
if (!extracted.ok) {
|
||||||
return extracted;
|
return extracted;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
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;
|
||||||
|
};
|
||||||
|
}
|
||||||
+3
-8
@@ -1,12 +1,7 @@
|
|||||||
import {
|
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||||
err,
|
import { getRegisteredWorkflow, readWorkflowRegistry } from "@uncaged/workflow-register";
|
||||||
getRegisteredWorkflow,
|
|
||||||
ok,
|
|
||||||
type Result,
|
|
||||||
readWorkflowRegistry,
|
|
||||||
} from "@uncaged/workflow";
|
|
||||||
|
|
||||||
import { validateCliWorkflowName } from "./workflow-name.js";
|
import { validateCliWorkflowName } from "../../workflow-name.js";
|
||||||
|
|
||||||
export async function cmdHistory(
|
export async function cmdHistory(
|
||||||
storageRoot: string,
|
storageRoot: string,
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
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";
|
||||||
+2
-4
@@ -1,11 +1,9 @@
|
|||||||
|
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||||
import {
|
import {
|
||||||
err,
|
|
||||||
listRegisteredWorkflowNames,
|
listRegisteredWorkflowNames,
|
||||||
ok,
|
|
||||||
type Result,
|
|
||||||
readWorkflowRegistry,
|
readWorkflowRegistry,
|
||||||
type WorkflowRegistryFile,
|
type WorkflowRegistryFile,
|
||||||
} from "@uncaged/workflow";
|
} from "@uncaged/workflow-register";
|
||||||
|
|
||||||
export async function cmdList(storageRoot: string): Promise<Result<WorkflowRegistryFile, string>> {
|
export async function cmdList(storageRoot: string): Promise<Result<WorkflowRegistryFile, string>> {
|
||||||
const reg = await readWorkflowRegistry(storageRoot);
|
const reg = await readWorkflowRegistry(storageRoot);
|
||||||
+3
-5
@@ -1,13 +1,11 @@
|
|||||||
|
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||||
import {
|
import {
|
||||||
err,
|
|
||||||
ok,
|
|
||||||
type Result,
|
|
||||||
readWorkflowRegistry,
|
readWorkflowRegistry,
|
||||||
unregisterWorkflow,
|
unregisterWorkflow,
|
||||||
writeWorkflowRegistry,
|
writeWorkflowRegistry,
|
||||||
} from "@uncaged/workflow";
|
} from "@uncaged/workflow-register";
|
||||||
|
|
||||||
import { validateCliWorkflowName } from "./workflow-name.js";
|
import { validateCliWorkflowName } from "../../workflow-name.js";
|
||||||
|
|
||||||
export async function cmdRemove(storageRoot: string, name: string): Promise<Result<void, string>> {
|
export async function cmdRemove(storageRoot: string, name: string): Promise<Result<void, string>> {
|
||||||
const nameOk = validateCliWorkflowName(name);
|
const nameOk = validateCliWorkflowName(name);
|
||||||
+5
-6
@@ -1,17 +1,15 @@
|
|||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||||
import {
|
import {
|
||||||
err,
|
|
||||||
getRegisteredWorkflow,
|
getRegisteredWorkflow,
|
||||||
ok,
|
|
||||||
type Result,
|
|
||||||
readWorkflowRegistry,
|
readWorkflowRegistry,
|
||||||
rollbackWorkflowToHistoryHash,
|
rollbackWorkflowToHistoryHash,
|
||||||
writeWorkflowRegistry,
|
writeWorkflowRegistry,
|
||||||
} from "@uncaged/workflow";
|
} from "@uncaged/workflow-register";
|
||||||
|
|
||||||
import { pathExists } from "./fs-utils.js";
|
import { pathExists } from "../../fs-utils.js";
|
||||||
import { validateCliWorkflowName } from "./workflow-name.js";
|
import { validateCliWorkflowName } from "../../workflow-name.js";
|
||||||
|
|
||||||
export async function cmdRollback(
|
export async function cmdRollback(
|
||||||
storageRoot: string,
|
storageRoot: string,
|
||||||
@@ -44,6 +42,7 @@ export async function cmdRollback(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const nextRegistry = {
|
const nextRegistry = {
|
||||||
|
config: reg.value.config,
|
||||||
workflows: { ...reg.value.workflows, [name]: rolled.value },
|
workflows: { ...reg.value.workflows, [name]: rolled.value },
|
||||||
};
|
};
|
||||||
const written = await writeWorkflowRegistry(storageRoot, nextRegistry);
|
const written = await writeWorkflowRegistry(storageRoot, nextRegistry);
|
||||||
+3
-5
@@ -1,14 +1,12 @@
|
|||||||
|
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||||
import {
|
import {
|
||||||
err,
|
|
||||||
getRegisteredWorkflow,
|
getRegisteredWorkflow,
|
||||||
ok,
|
|
||||||
type Result,
|
|
||||||
readWorkflowRegistry,
|
readWorkflowRegistry,
|
||||||
type WorkflowRegistryEntry,
|
type WorkflowRegistryEntry,
|
||||||
} from "@uncaged/workflow";
|
} from "@uncaged/workflow-register";
|
||||||
import { stringify } from "yaml";
|
import { stringify } from "yaml";
|
||||||
|
|
||||||
import { validateCliWorkflowName } from "./workflow-name.js";
|
import { validateCliWorkflowName } from "../../workflow-name.js";
|
||||||
|
|
||||||
export async function cmdShow(
|
export async function cmdShow(
|
||||||
storageRoot: string,
|
storageRoot: string,
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
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;
|
||||||
|
};
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||||
|
|
||||||
|
export type ParsedLiveArgv = {
|
||||||
|
threadId: string | null;
|
||||||
|
latest: boolean;
|
||||||
|
debug: boolean;
|
||||||
|
role: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type LiveArgvScan = {
|
||||||
|
latest: boolean;
|
||||||
|
debug: boolean;
|
||||||
|
role: string | null;
|
||||||
|
threadId: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function applyLiveArgvToken(argv: string[], i: number, s: LiveArgvScan): Result<number, string> {
|
||||||
|
const a = argv[i];
|
||||||
|
if (a === "--latest") {
|
||||||
|
s.latest = true;
|
||||||
|
return ok(i + 1);
|
||||||
|
}
|
||||||
|
if (a === "--debug") {
|
||||||
|
s.debug = true;
|
||||||
|
return ok(i + 1);
|
||||||
|
}
|
||||||
|
if (a === "--role") {
|
||||||
|
const v = argv[i + 1];
|
||||||
|
if (v === undefined || v.startsWith("--")) {
|
||||||
|
return err("missing value for --role");
|
||||||
|
}
|
||||||
|
s.role = v;
|
||||||
|
return ok(i + 2);
|
||||||
|
}
|
||||||
|
if (a.startsWith("--")) {
|
||||||
|
return err(`unknown live flag: ${a}`);
|
||||||
|
}
|
||||||
|
if (s.threadId !== null) {
|
||||||
|
return err("unexpected extra argument");
|
||||||
|
}
|
||||||
|
s.threadId = a;
|
||||||
|
return ok(i + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseLiveArgv(argv: string[]): Result<ParsedLiveArgv, string> {
|
||||||
|
const s: LiveArgvScan = {
|
||||||
|
latest: false,
|
||||||
|
debug: false,
|
||||||
|
role: null,
|
||||||
|
threadId: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
let i = 0;
|
||||||
|
while (i < argv.length) {
|
||||||
|
const step = applyLiveArgvToken(argv, i, s);
|
||||||
|
if (!step.ok) {
|
||||||
|
return step;
|
||||||
|
}
|
||||||
|
i = step.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (s.latest && s.threadId !== null) {
|
||||||
|
return err("live --latest does not take <thread-id>");
|
||||||
|
}
|
||||||
|
if (!s.latest && s.threadId === null) {
|
||||||
|
return err("live requires <thread-id> or --latest");
|
||||||
|
}
|
||||||
|
|
||||||
|
return ok({
|
||||||
|
threadId: s.threadId,
|
||||||
|
latest: s.latest,
|
||||||
|
debug: s.debug,
|
||||||
|
role: s.role,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { err, ok, type Result } from "@uncaged/workflow";
|
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||||
|
|
||||||
export type ParsedRunArgv = {
|
export type ParsedRunArgv = {
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -0,0 +1,230 @@
|
|||||||
|
import { getCommandRegistry } from "./cli-registry.js";
|
||||||
|
|
||||||
|
type SkillTopic = {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
format: () => string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SKILL_TOPICS: ReadonlyArray<SkillTopic> = [
|
||||||
|
{ name: "cli", description: "Full CLI command reference", format: formatSkillCli },
|
||||||
|
{
|
||||||
|
name: "develop",
|
||||||
|
description: "Guide for agents executing roles inside a workflow",
|
||||||
|
format: formatSkillDevelop,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "author",
|
||||||
|
description: "Guide for building and publishing workflow bundles",
|
||||||
|
format: formatSkillAuthor,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function getSkillTopics(): ReadonlyArray<{ name: string; description: string }> {
|
||||||
|
return SKILL_TOPICS.map((t) => ({ name: t.name, description: t.description }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatSkillTopic(topic: string): string | null {
|
||||||
|
const entry = SKILL_TOPICS.find((t) => t.name === topic);
|
||||||
|
if (entry === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return entry.format();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatSkillIndex(): string {
|
||||||
|
const rows = SKILL_TOPICS.map((t) => `| \`${t.name}\` | ${t.description} |`);
|
||||||
|
return `# uncaged-workflow skill
|
||||||
|
|
||||||
|
Available topics:
|
||||||
|
|
||||||
|
| Topic | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
${rows.join("\n")}
|
||||||
|
|
||||||
|
Usage: \`uncaged-workflow skill <topic>\`
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── cli topic (existing full reference) ────────────────────────────────
|
||||||
|
|
||||||
|
function formatSkillCli(): string {
|
||||||
|
const groups = getCommandRegistry();
|
||||||
|
|
||||||
|
const commandSections: string[] = [];
|
||||||
|
for (const group of groups) {
|
||||||
|
const rows = group.commands.map((cmd) => {
|
||||||
|
const args = cmd.args ? `\`${cmd.args}\`` : "(none)";
|
||||||
|
return `| \`${group.name} ${cmd.name}\` | ${args} | ${cmd.description} |`;
|
||||||
|
});
|
||||||
|
commandSections.push(
|
||||||
|
`### ${group.name}\n\n| Command | Args | Description |\n|---------|------|-------------|\n${rows.join("\n")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return `# uncaged-workflow CLI Reference
|
||||||
|
|
||||||
|
## Core Concepts
|
||||||
|
|
||||||
|
| Concept | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| **Workflow** | A single-file ESM bundle (\`.esm.js\`) that exports \`run\` and \`descriptor\`. Identified by name and XXH64 hash. |
|
||||||
|
| **Bundle** | The physical \`.esm.js\` file stored in the bundles directory. Immutable once written. |
|
||||||
|
| **Thread** | A single execution of a workflow, identified by a ULID. Persists state as JSONL files. |
|
||||||
|
| **CAS** | Content-Addressable Storage. Per-thread key-value store keyed by content hash. |
|
||||||
|
| **Registry** | \`workflow.yaml\` — maps workflow names to their current and historical bundle hashes. |
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
${commandSections.join("\n\n")}
|
||||||
|
|
||||||
|
### Top-level shortcuts
|
||||||
|
|
||||||
|
| Command | Equivalent | Description |
|
||||||
|
|---------|------------|-------------|
|
||||||
|
| \`run\` | \`thread run\` | Shortcut to start a thread |
|
||||||
|
| \`live\` | \`thread live\` | Shortcut to attach to a thread |
|
||||||
|
|
||||||
|
## Typical Workflow
|
||||||
|
|
||||||
|
1. \`uncaged-workflow workflow add my-wf ./my-wf.esm.js\` — register a workflow
|
||||||
|
2. \`uncaged-workflow run my-wf --prompt "do the thing"\` — start a thread
|
||||||
|
3. \`uncaged-workflow live --latest\` — attach and watch output
|
||||||
|
4. \`uncaged-workflow thread show <thread-id>\` — inspect completed thread
|
||||||
|
|
||||||
|
## Exit Codes
|
||||||
|
|
||||||
|
| Code | Meaning |
|
||||||
|
|------|---------|
|
||||||
|
| 0 | Success |
|
||||||
|
| 1 | Error |
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| \`UNCAGED_WORKFLOW_STORAGE_ROOT\` | Override the default storage directory for all workflow data |
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── develop topic (for agents inside a workflow) ───────────────────────
|
||||||
|
|
||||||
|
function formatSkillDevelop(): string {
|
||||||
|
return `# Workflow Role Guide
|
||||||
|
|
||||||
|
Reference for agents executing roles (planner, coder, reviewer, etc.) inside a running workflow thread.
|
||||||
|
|
||||||
|
## Thread ID
|
||||||
|
|
||||||
|
Every thread has a 26-character Crockford Base32 ULID (e.g. \`06F03H5V6JTMDST6P3TVH42RWM\`).
|
||||||
|
|
||||||
|
It appears in the **first message** of the conversation. If unsure:
|
||||||
|
|
||||||
|
\`\`\`
|
||||||
|
uncaged-workflow thread list
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
## CAS (Content-Addressable Storage)
|
||||||
|
|
||||||
|
Store and retrieve content by hash in workflow storage (global CAS directory).
|
||||||
|
|
||||||
|
| Operation | Command |
|
||||||
|
|-----------|---------|
|
||||||
|
| **Store** | \`uncaged-workflow cas put '<content>'\` → prints hash |
|
||||||
|
| **Read** | \`uncaged-workflow cas get <HASH>\` → prints content |
|
||||||
|
| **List** | \`uncaged-workflow cas list\` |
|
||||||
|
|
||||||
|
CAS is the **only** supported way to persist structured data (phase plans, review notes, etc.) within a thread. Do not use temp files.
|
||||||
|
|
||||||
|
## Meta Output
|
||||||
|
|
||||||
|
Each role must produce structured output that the moderator extracts. The exact schema depends on the role, but the pattern is:
|
||||||
|
|
||||||
|
1. Do your work (write code, run tests, etc.)
|
||||||
|
2. Output a compact JSON object matching the role's schema
|
||||||
|
3. The moderator extracts and validates it automatically
|
||||||
|
|
||||||
|
## Thread Context
|
||||||
|
|
||||||
|
The conversation history contains outputs from previous roles. Read it to understand:
|
||||||
|
- What task was requested (from the initial prompt)
|
||||||
|
- What previous roles produced (plans, code changes, review results)
|
||||||
|
- What the moderator decided (which phase to work on, whether to retry)
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── author topic (for workflow developers) ─────────────────────────────
|
||||||
|
|
||||||
|
function formatSkillAuthor(): string {
|
||||||
|
return `# Workflow Authoring Guide
|
||||||
|
|
||||||
|
How to build, test, and publish workflow bundles for uncaged-workflow.
|
||||||
|
|
||||||
|
## Bundle Structure
|
||||||
|
|
||||||
|
A workflow bundle is a single ESM file (\`.esm.js\`) that exports:
|
||||||
|
|
||||||
|
\`\`\`typescript
|
||||||
|
// Required exports
|
||||||
|
export const descriptor: WorkflowDescriptor;
|
||||||
|
export const run: WorkflowRun;
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
## WorkflowDescriptor
|
||||||
|
|
||||||
|
Defines the workflow's metadata and role sequence:
|
||||||
|
|
||||||
|
\`\`\`typescript
|
||||||
|
type WorkflowDescriptor = {
|
||||||
|
name: string; // verb-first kebab-case, e.g. "solve-issue"
|
||||||
|
description: string; // one-line summary
|
||||||
|
roles: string[]; // ordered role names, e.g. ["planner", "coder", "reviewer"]
|
||||||
|
};
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
## WorkflowRun
|
||||||
|
|
||||||
|
The main function that creates and returns a moderator:
|
||||||
|
|
||||||
|
\`\`\`typescript
|
||||||
|
type WorkflowRun = (ctx: WorkflowContext) => Moderator;
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
The **Moderator** controls the flow — it decides which role runs next, handles retries, and determines when the workflow is complete.
|
||||||
|
|
||||||
|
## Role Definition
|
||||||
|
|
||||||
|
Each role has:
|
||||||
|
|
||||||
|
| Field | Type | Purpose |
|
||||||
|
|-------|------|---------|
|
||||||
|
| \`description\` | string | What the role does |
|
||||||
|
| \`systemPrompt\` | string | System prompt for the agent |
|
||||||
|
| \`extractPrompt\` | string | Instruction for extracting structured meta |
|
||||||
|
| \`schema\` | ZodSchema | Validates the extracted meta |
|
||||||
|
| \`extractRefs\` | fn or null | Extracts CAS hashes from meta for DAG linking |
|
||||||
|
|
||||||
|
## Development Workflow
|
||||||
|
|
||||||
|
\`\`\`bash
|
||||||
|
# 1. Initialize a workspace
|
||||||
|
uncaged-workflow init workspace my-workflow
|
||||||
|
|
||||||
|
# 2. Write your template (roles + moderator + descriptor)
|
||||||
|
|
||||||
|
# 3. Build the ESM bundle
|
||||||
|
bun run build
|
||||||
|
|
||||||
|
# 4. Register locally
|
||||||
|
uncaged-workflow workflow add my-workflow ./dist/my-workflow.esm.js
|
||||||
|
|
||||||
|
# 5. Test
|
||||||
|
uncaged-workflow run my-workflow --prompt "test task"
|
||||||
|
uncaged-workflow live --latest
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
## Versioning
|
||||||
|
|
||||||
|
Bundles are immutable and identified by XXH64 hash. Re-registering a workflow with a new bundle creates a new version. Use \`workflow history\` and \`workflow rollback\` to manage versions.
|
||||||
|
`;
|
||||||
|
}
|
||||||
@@ -1,10 +1,21 @@
|
|||||||
import { getDefaultWorkflowStorageRoot } from "@uncaged/workflow";
|
import { getDefaultWorkflowStorageRoot } from "@uncaged/workflow-util";
|
||||||
|
|
||||||
/** Resolve storage root, honoring `UNCAGED_WORKFLOW_STORAGE_ROOT` for tests/tools. */
|
/**
|
||||||
|
* Resolve storage root with env var override support.
|
||||||
|
*
|
||||||
|
* Priority (highest first):
|
||||||
|
* 1. `UNCAGED_WORKFLOW_STORAGE_ROOT` — internal/test override
|
||||||
|
* 2. `WORKFLOW_STORAGE_ROOT` — user-facing override
|
||||||
|
* 3. Default (`~/.uncaged/workflow`)
|
||||||
|
*/
|
||||||
export function resolveWorkflowStorageRoot(): string {
|
export function resolveWorkflowStorageRoot(): string {
|
||||||
const override = process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
|
const internal = process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
|
||||||
if (override !== undefined && override !== "") {
|
if (internal !== undefined && internal !== "") {
|
||||||
return override;
|
return internal;
|
||||||
|
}
|
||||||
|
const userOverride = process.env.WORKFLOW_STORAGE_ROOT;
|
||||||
|
if (userOverride !== undefined && userOverride !== "") {
|
||||||
|
return userOverride;
|
||||||
}
|
}
|
||||||
return getDefaultWorkflowStorageRoot();
|
return getDefaultWorkflowStorageRoot();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,9 @@
|
|||||||
import { readdir } from "node:fs/promises";
|
import { readdir, stat } from "node:fs/promises";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
|
||||||
import { pathExists, readTextFileIfExists } from "./fs-utils.js";
|
import { pathExists, readTextFileIfExists } from "./fs-utils.js";
|
||||||
|
|
||||||
export type RunningThreadRow = {
|
function parseFirstJsonLineObject(text: string): Record<string, unknown> | null {
|
||||||
threadId: string;
|
|
||||||
hash: string;
|
|
||||||
workflowName: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type HistoricalThreadRow = {
|
|
||||||
threadId: string;
|
|
||||||
hash: string;
|
|
||||||
workflowName: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
async function readWorkflowNameFromDataJsonl(dataPath: string): Promise<string | null> {
|
|
||||||
const text = await readTextFileIfExists(dataPath);
|
|
||||||
if (text === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const firstLine = text.split("\n")[0];
|
const firstLine = text.split("\n")[0];
|
||||||
if (firstLine === undefined || firstLine.trim() === "") {
|
if (firstLine === undefined || firstLine.trim() === "") {
|
||||||
return null;
|
return null;
|
||||||
@@ -33,7 +17,44 @@ async function readWorkflowNameFromDataJsonl(dataPath: string): Promise<string |
|
|||||||
if (parsed === null || typeof parsed !== "object") {
|
if (parsed === null || typeof parsed !== "object") {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const name = (parsed as Record<string, unknown>).name;
|
return parsed as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RunningThreadRow = {
|
||||||
|
threadId: string;
|
||||||
|
hash: string;
|
||||||
|
workflowName: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HistoricalThreadRow = {
|
||||||
|
threadId: string;
|
||||||
|
hash: string;
|
||||||
|
workflowName: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function readThreadStartTimestampMs(dataPath: string): Promise<number | null> {
|
||||||
|
const text = await readTextFileIfExists(dataPath);
|
||||||
|
if (text === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const parsed = parseFirstJsonLineObject(text);
|
||||||
|
if (parsed === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const ts = parsed.timestamp;
|
||||||
|
return typeof ts === "number" && Number.isFinite(ts) ? ts : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readWorkflowNameFromDataJsonl(dataPath: string): Promise<string | null> {
|
||||||
|
const text = await readTextFileIfExists(dataPath);
|
||||||
|
if (text === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const parsed = parseFirstJsonLineObject(text);
|
||||||
|
if (parsed === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const name = parsed.name;
|
||||||
return typeof name === "string" ? name : null;
|
return typeof name === "string" ? name : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,6 +145,50 @@ export async function listHistoricalThreads(
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Picks the thread whose `.data.jsonl` is newest by start-record `timestamp`,
|
||||||
|
* falling back to file `mtime` when the timestamp is missing.
|
||||||
|
* Tie-breaker: larger `mtime` wins when start timestamps are equal.
|
||||||
|
*/
|
||||||
|
export async function findLatestThreadDataPath(
|
||||||
|
storageRoot: string,
|
||||||
|
): Promise<{ threadId: string; dataPath: string } | null> {
|
||||||
|
const threads = await listHistoricalThreads(storageRoot, null);
|
||||||
|
if (threads.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let best: {
|
||||||
|
threadId: string;
|
||||||
|
dataPath: string;
|
||||||
|
primary: number;
|
||||||
|
secondary: number;
|
||||||
|
} | null = null;
|
||||||
|
|
||||||
|
for (const t of threads) {
|
||||||
|
const dataPath = join(storageRoot, "logs", t.hash, `${t.threadId}.data.jsonl`);
|
||||||
|
let mtimeMs = 0;
|
||||||
|
try {
|
||||||
|
const st = await stat(dataPath);
|
||||||
|
mtimeMs = st.mtimeMs;
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const startTs = await readThreadStartTimestampMs(dataPath);
|
||||||
|
const primary = startTs !== null ? startTs : mtimeMs;
|
||||||
|
const secondary = mtimeMs;
|
||||||
|
if (
|
||||||
|
best === null ||
|
||||||
|
primary > best.primary ||
|
||||||
|
(primary === best.primary && secondary > best.secondary)
|
||||||
|
) {
|
||||||
|
best = { threadId: t.threadId, dataPath, primary, secondary };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return best === null ? null : { threadId: best.threadId, dataPath: best.dataPath };
|
||||||
|
}
|
||||||
|
|
||||||
export async function resolveThreadDataPath(
|
export async function resolveThreadDataPath(
|
||||||
storageRoot: string,
|
storageRoot: string,
|
||||||
threadId: string,
|
threadId: string,
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import { mkdir, readdir, unlink, writeFile } from "node:fs/promises";
|
|||||||
import { createConnection } from "node:net";
|
import { createConnection } from "node:net";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
|
||||||
import { err, getWorkerHostScriptPath, ok, type Result } from "@uncaged/workflow";
|
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||||
|
import { getWorkerHostScriptPath } from "@uncaged/workflow-execute";
|
||||||
|
|
||||||
import { pathExists, readTextFileIfExists } from "./fs-utils.js";
|
import { pathExists, readTextFileIfExists } from "./fs-utils.js";
|
||||||
|
|
||||||
@@ -237,6 +238,30 @@ 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(
|
export async function resolveRunningHashForThread(
|
||||||
storageRoot: string,
|
storageRoot: string,
|
||||||
threadId: string,
|
threadId: string,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { err, ok, type Result } from "@uncaged/workflow";
|
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||||
|
|
||||||
const WORKFLOW_NAME_RE = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
|
const WORKFLOW_NAME_RE = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,13 @@
|
|||||||
"rootDir": "src",
|
"rootDir": "src",
|
||||||
"types": ["bun-types"]
|
"types": ["bun-types"]
|
||||||
},
|
},
|
||||||
"references": [{ "path": "../workflow" }],
|
"references": [
|
||||||
|
{ "path": "../workflow-runtime" },
|
||||||
|
{ "path": "../workflow-protocol" },
|
||||||
|
{ "path": "../workflow-util" },
|
||||||
|
{ "path": "../workflow-cas" },
|
||||||
|
{ "path": "../workflow-execute" },
|
||||||
|
{ "path": "../workflow-register" }
|
||||||
|
],
|
||||||
"include": ["src/**/*.ts"]
|
"include": ["src/**/*.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# @uncaged/workflow-dashboard
|
||||||
|
|
||||||
|
Web dashboard for the Uncaged Workflow engine. Connects to the local
|
||||||
|
`uncaged-workflow serve` API to display threads, workflows, and CAS data.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start the local API server (in another terminal)
|
||||||
|
uncaged-workflow serve
|
||||||
|
|
||||||
|
# Start the dashboard dev server
|
||||||
|
bun run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Opens at http://localhost:5173. Vite proxies `/api/*` to `localhost:7860`.
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Output goes to `dist/` — static files ready for CF Pages or any host.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Workflow Dashboard</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "@uncaged/workflow-dashboard",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^19.2.6",
|
||||||
|
"react-dom": "^19.2.6"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.2.4",
|
||||||
|
"@types/react": "^19.2.14",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
|
"tailwindcss": "^4.2.4",
|
||||||
|
"typescript": "^6.0.3",
|
||||||
|
"vite": "^8.0.11"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
const BASE = "/api";
|
||||||
|
|
||||||
|
async function postJson<T>(path: string, body: unknown): Promise<T> {
|
||||||
|
const res = await fetch(`${BASE}${path}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = (await res.json().catch(() => ({ error: res.statusText }))) as { error: string };
|
||||||
|
throw new Error(err.error || `API ${res.status}`);
|
||||||
|
}
|
||||||
|
return res.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchJson<T>(path: string): Promise<T> {
|
||||||
|
const res = await fetch(`${BASE}${path}`);
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`API ${res.status}: ${path}`);
|
||||||
|
}
|
||||||
|
return res.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WorkflowSummary = {
|
||||||
|
name: string;
|
||||||
|
currentHash: string;
|
||||||
|
versions: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ThreadSummary = {
|
||||||
|
threadId: string;
|
||||||
|
workflow: string | null;
|
||||||
|
hash: string | null;
|
||||||
|
startedAt: string | null;
|
||||||
|
status: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ThreadRecord = {
|
||||||
|
type: string;
|
||||||
|
role: string | null;
|
||||||
|
content: string | null;
|
||||||
|
timestamp: number | null;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function listWorkflows(): Promise<{ workflows: WorkflowSummary[] }> {
|
||||||
|
return fetchJson("/workflows");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listThreads(): Promise<{ threads: ThreadSummary[] }> {
|
||||||
|
return fetchJson("/threads");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listRunningThreads(): Promise<{ threads: ThreadSummary[] }> {
|
||||||
|
return fetchJson("/threads/running");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getThread(id: string): Promise<{ records: ThreadRecord[] }> {
|
||||||
|
return fetchJson(`/threads/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runThread(
|
||||||
|
workflow: string,
|
||||||
|
prompt: string,
|
||||||
|
maxRounds: number = 10,
|
||||||
|
): Promise<{ threadId: string }> {
|
||||||
|
return postJson("/threads", { workflow, prompt, maxRounds });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function killThread(threadId: string): Promise<{ ok: boolean }> {
|
||||||
|
return postJson(`/threads/${threadId}/kill`, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pauseThread(threadId: string): Promise<{ ok: boolean }> {
|
||||||
|
return postJson(`/threads/${threadId}/pause`, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resumeThread(threadId: string): Promise<{ ok: boolean }> {
|
||||||
|
return postJson(`/threads/${threadId}/resume`, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getHealth(): Promise<{ ok: boolean }> {
|
||||||
|
return fetchJson("/healthz");
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { RunDialog } from "./components/run-dialog.tsx";
|
||||||
|
import { Sidebar } from "./components/sidebar.tsx";
|
||||||
|
import { StatusBar } from "./components/status-bar.tsx";
|
||||||
|
import { ThreadDetail } from "./components/thread-detail.tsx";
|
||||||
|
import { ThreadList } from "./components/thread-list.tsx";
|
||||||
|
import { WorkflowList } from "./components/workflow-list.tsx";
|
||||||
|
import { useHashRoute } from "./use-hash-route.ts";
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
const { view, threadId, setView, setThreadId } = useHashRoute();
|
||||||
|
const [showRun, setShowRun] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen">
|
||||||
|
<Sidebar view={view} onViewChange={setView} />
|
||||||
|
<main className="flex-1 overflow-hidden flex flex-col">
|
||||||
|
<StatusBar onRun={() => setShowRun(true)} />
|
||||||
|
<div className="flex-1 overflow-auto p-6">
|
||||||
|
{view === "threads" && threadId === null && <ThreadList onSelect={setThreadId} />}
|
||||||
|
{view === "threads" && threadId !== null && (
|
||||||
|
<ThreadDetail threadId={threadId} onBack={() => setThreadId(null)} />
|
||||||
|
)}
|
||||||
|
{view === "workflows" && <WorkflowList />}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
{showRun && (
|
||||||
|
<RunDialog
|
||||||
|
onClose={() => setShowRun(false)}
|
||||||
|
onCreated={(id) => {
|
||||||
|
setShowRun(false);
|
||||||
|
setThreadId(id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { listWorkflows, runThread } from "../api.ts";
|
||||||
|
import { useFetch } from "../hooks.ts";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onClose: () => void;
|
||||||
|
onCreated: (threadId: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function RunDialog({ onClose, onCreated }: Props) {
|
||||||
|
const workflows = useFetch(() => listWorkflows(), []);
|
||||||
|
const [workflow, setWorkflow] = useState("");
|
||||||
|
const [prompt, setPrompt] = useState("");
|
||||||
|
const [maxRounds, setMaxRounds] = useState(10);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!workflow || !prompt) return;
|
||||||
|
setSubmitting(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const result = await runThread(workflow, prompt, maxRounds);
|
||||||
|
onCreated(result.threadId);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 flex items-center justify-center z-50"
|
||||||
|
style={{ background: "rgba(0,0,0,0.6)" }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-full max-w-lg p-6 rounded-lg border"
|
||||||
|
style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }}
|
||||||
|
>
|
||||||
|
<h3 className="text-lg font-semibold mb-4">Run Thread</h3>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="run-workflow"
|
||||||
|
className="text-sm block mb-1"
|
||||||
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
|
>
|
||||||
|
Workflow
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="run-workflow"
|
||||||
|
value={workflow}
|
||||||
|
onChange={(e) => setWorkflow(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 rounded border text-sm"
|
||||||
|
style={{
|
||||||
|
background: "var(--color-bg)",
|
||||||
|
borderColor: "var(--color-border)",
|
||||||
|
color: "var(--color-text)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">Select a workflow...</option>
|
||||||
|
{workflows.status === "ok" &&
|
||||||
|
workflows.data.workflows.map((w) => (
|
||||||
|
<option key={w.name} value={w.name}>
|
||||||
|
{w.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="run-prompt"
|
||||||
|
className="text-sm block mb-1"
|
||||||
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
|
>
|
||||||
|
Prompt
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="run-prompt"
|
||||||
|
value={prompt}
|
||||||
|
onChange={(e) => setPrompt(e.target.value)}
|
||||||
|
rows={4}
|
||||||
|
className="w-full px-3 py-2 rounded border text-sm"
|
||||||
|
style={{
|
||||||
|
background: "var(--color-bg)",
|
||||||
|
borderColor: "var(--color-border)",
|
||||||
|
color: "var(--color-text)",
|
||||||
|
}}
|
||||||
|
placeholder="Enter the task prompt..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="run-max-rounds"
|
||||||
|
className="text-sm block mb-1"
|
||||||
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
|
>
|
||||||
|
Max Rounds
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="run-max-rounds"
|
||||||
|
type="number"
|
||||||
|
value={maxRounds}
|
||||||
|
onChange={(e) => setMaxRounds(Number(e.target.value))}
|
||||||
|
min={1}
|
||||||
|
max={100}
|
||||||
|
className="w-24 px-3 py-2 rounded border text-sm"
|
||||||
|
style={{
|
||||||
|
background: "var(--color-bg)",
|
||||||
|
borderColor: "var(--color-border)",
|
||||||
|
color: "var(--color-text)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm" style={{ color: "var(--color-error)" }}>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 text-sm rounded border"
|
||||||
|
style={{ borderColor: "var(--color-border)", color: "var(--color-text-muted)" }}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={submitting || !workflow || !prompt}
|
||||||
|
className="px-4 py-2 text-sm rounded"
|
||||||
|
style={{
|
||||||
|
background: submitting ? "var(--color-accent-dim)" : "var(--color-accent)",
|
||||||
|
color: "#fff",
|
||||||
|
opacity: !workflow || !prompt ? 0.5 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{submitting ? "Starting..." : "Run"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
type Props = {
|
||||||
|
view: "threads" | "workflows";
|
||||||
|
onViewChange: (v: "threads" | "workflows") => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Sidebar({ view, onViewChange }: Props) {
|
||||||
|
const items = [
|
||||||
|
{ key: "threads" as const, label: "Threads", icon: "⚡" },
|
||||||
|
{ key: "workflows" as const, label: "Workflows", icon: "📦" },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
className="w-56 border-r flex flex-col"
|
||||||
|
style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }}
|
||||||
|
>
|
||||||
|
<div className="p-4 border-b" style={{ borderColor: "var(--color-border)" }}>
|
||||||
|
<h1 className="text-lg font-semibold" style={{ color: "var(--color-accent)" }}>
|
||||||
|
⚙ Workflow
|
||||||
|
</h1>
|
||||||
|
<p className="text-xs mt-1" style={{ color: "var(--color-text-muted)" }}>
|
||||||
|
Dashboard
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<nav className="flex-1 p-2 space-y-1">
|
||||||
|
{items.map((item) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={item.key}
|
||||||
|
onClick={() => onViewChange(item.key)}
|
||||||
|
className="w-full text-left px-3 py-2 rounded text-sm transition-colors"
|
||||||
|
style={{
|
||||||
|
background: view === item.key ? "var(--color-accent-dim)" : "transparent",
|
||||||
|
color: view === item.key ? "#fff" : "var(--color-text-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.icon} {item.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { getHealth } from "../api.ts";
|
||||||
|
|
||||||
|
type HealthStatus = "connected" | "disconnected" | "reconnecting";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onRun: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function statusLabel(status: HealthStatus): { text: string; color: string } {
|
||||||
|
if (status === "connected") {
|
||||||
|
return { text: "● Connected", color: "var(--color-success)" };
|
||||||
|
}
|
||||||
|
if (status === "reconnecting") {
|
||||||
|
return { text: "● Reconnecting...", color: "var(--color-warning, #f59e0b)" };
|
||||||
|
}
|
||||||
|
return { text: "● Offline", color: "var(--color-error)" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatusBar({ onRun }: Props) {
|
||||||
|
const [status, setStatus] = useState<HealthStatus>("disconnected");
|
||||||
|
const wasConnectedRef = useRef(false);
|
||||||
|
|
||||||
|
const checkHealth = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await getHealth();
|
||||||
|
wasConnectedRef.current = true;
|
||||||
|
setStatus("connected");
|
||||||
|
} catch {
|
||||||
|
if (wasConnectedRef.current) {
|
||||||
|
setStatus("reconnecting");
|
||||||
|
} else {
|
||||||
|
setStatus("disconnected");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
checkHealth();
|
||||||
|
const interval = setInterval(checkHealth, 10_000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [checkHealth]);
|
||||||
|
|
||||||
|
const label = statusLabel(status);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between px-6 py-2 text-xs border-b"
|
||||||
|
style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span style={{ color: "var(--color-text-muted)" }}>Local API: 127.0.0.1:7860</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onRun}
|
||||||
|
className="px-3 py-1 rounded text-xs font-medium"
|
||||||
|
style={{ background: "var(--color-accent)", color: "#fff" }}
|
||||||
|
>
|
||||||
|
▶ Run Thread
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<span style={{ color: label.color }}>{label.text}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { getThread, killThread, pauseThread, resumeThread } from "../api.ts";
|
||||||
|
import { useFetch } from "../hooks.ts";
|
||||||
|
import { useSSE } from "../use-sse.ts";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
threadId: string;
|
||||||
|
onBack: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ThreadDetail({ threadId, onBack }: Props) {
|
||||||
|
const sse = useSSE(threadId);
|
||||||
|
const { status, data, error } = useFetch(() => getThread(threadId), [threadId]);
|
||||||
|
const [actionStatus, setActionStatus] = useState<string | null>(null);
|
||||||
|
const recordsEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const liveActive = sse.connected && !sse.completed;
|
||||||
|
const records = liveActive
|
||||||
|
? sse.records
|
||||||
|
: status === "ok"
|
||||||
|
? data.records
|
||||||
|
: ([] as typeof sse.records);
|
||||||
|
|
||||||
|
// biome-ignore lint/correctness/useExhaustiveDependencies: scroll when the rendered record list grows
|
||||||
|
useEffect(() => {
|
||||||
|
recordsEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||||
|
}, [records.length]);
|
||||||
|
|
||||||
|
async function handleAction(action: "kill" | "pause" | "resume") {
|
||||||
|
setActionStatus(`${action}ing...`);
|
||||||
|
try {
|
||||||
|
const fn = action === "kill" ? killThread : action === "pause" ? pauseThread : resumeThread;
|
||||||
|
await fn(threadId);
|
||||||
|
setActionStatus(`${action} sent ✓`);
|
||||||
|
} catch (e) {
|
||||||
|
setActionStatus(`${action} failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onBack}
|
||||||
|
className="text-sm hover:underline"
|
||||||
|
style={{ color: "var(--color-accent)" }}
|
||||||
|
>
|
||||||
|
← Back to threads
|
||||||
|
</button>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleAction("pause")}
|
||||||
|
className="px-3 py-1 text-xs rounded border"
|
||||||
|
style={{ borderColor: "var(--color-warning)", color: "var(--color-warning)" }}
|
||||||
|
>
|
||||||
|
⏸ Pause
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleAction("resume")}
|
||||||
|
className="px-3 py-1 text-xs rounded border"
|
||||||
|
style={{ borderColor: "var(--color-success)", color: "var(--color-success)" }}
|
||||||
|
>
|
||||||
|
▶ Resume
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleAction("kill")}
|
||||||
|
className="px-3 py-1 text-xs rounded border"
|
||||||
|
style={{ borderColor: "var(--color-error)", color: "var(--color-error)" }}
|
||||||
|
>
|
||||||
|
✕ Kill
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="text-xl font-semibold mb-2 font-mono flex items-center gap-2 flex-wrap">
|
||||||
|
<span>{threadId}</span>
|
||||||
|
{sse.connected && (
|
||||||
|
<span
|
||||||
|
className="text-xs font-medium px-2 py-0.5 rounded"
|
||||||
|
style={{ background: "var(--color-success)", color: "var(--color-bg)" }}
|
||||||
|
>
|
||||||
|
Live
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</h2>
|
||||||
|
{actionStatus && (
|
||||||
|
<p className="text-xs mb-4" style={{ color: "var(--color-text-muted)" }}>
|
||||||
|
{actionStatus}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === "loading" && !liveActive && records.length === 0 && (
|
||||||
|
<p style={{ color: "var(--color-text-muted)" }}>Loading...</p>
|
||||||
|
)}
|
||||||
|
{status === "error" && !liveActive && (
|
||||||
|
<p style={{ color: "var(--color-error)" }}>Error: {error}</p>
|
||||||
|
)}
|
||||||
|
{(status === "ok" || liveActive || records.length > 0) && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{records.map((r) => (
|
||||||
|
<div
|
||||||
|
key={`${threadId}-${r.type}-${String(r.timestamp)}-${r.role ?? ""}-${r.content ?? ""}`}
|
||||||
|
className="p-3 rounded border text-sm"
|
||||||
|
style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span
|
||||||
|
className="text-xs px-1.5 py-0.5 rounded font-mono"
|
||||||
|
style={{ background: "var(--color-border)", color: "var(--color-accent)" }}
|
||||||
|
>
|
||||||
|
{r.type}
|
||||||
|
</span>
|
||||||
|
{r.role && (
|
||||||
|
<span className="text-xs" style={{ color: "var(--color-text-muted)" }}>
|
||||||
|
{r.role}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{r.timestamp !== null && (
|
||||||
|
<span className="text-xs ml-auto" style={{ color: "var(--color-text-muted)" }}>
|
||||||
|
{new Date(r.timestamp).toLocaleTimeString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{r.content && (
|
||||||
|
<pre
|
||||||
|
className="whitespace-pre-wrap text-xs mt-1"
|
||||||
|
style={{ color: "var(--color-text)" }}
|
||||||
|
>
|
||||||
|
{typeof r.content === "string" ? r.content : JSON.stringify(r.content, null, 2)}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div ref={recordsEndRef} aria-hidden />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user