Compare commits

..

3 Commits

Author SHA1 Message Date
xiaoju 984e6ae56d fix: cas has now returns exit 1 for non-existent hashes
Changed the exit behavior of 'uwf cas has' to return exit code 1 when
a hash doesn't exist, while preserving the JSON output {exists:false}.
This enables proper use in shell conditionals like 'if uwf cas has $HASH'.

Fixes #481
2026-05-25 00:47:39 +00:00
xiaoju 92f3b36b10 chore: add uwf script for local dev testing
`bun run uwf -- <args>` runs the local version of uwf CLI,
useful in worktrees to test local changes vs the global install.
2026-05-25 00:03:10 +00:00
xiaoju a4677f8adb docs: sync README files with recent changes 2026-05-24 17:04:09 +00:00
9 changed files with 250 additions and 11 deletions
+6 -5
View File
@@ -62,16 +62,16 @@ See [docs/architecture.md](docs/architecture.md) for the full design — three-p
uwf setup
# 2. Register a workflow from YAML
uwf workflow put examples/solve-issue.yaml
uwf workflow add examples/solve-issue.yaml
# 3. Start a thread (creates head pointer; does not execute)
uwf thread start solve-issue -p "Fix the login redirect bug"
# 4. Execute steps (one at a time, until done)
uwf thread step <thread-id>
uwf thread exec <thread-id>
```
Use `-c, --count <number>` on `thread step` to run multiple steps in one invocation. Override the agent with `--agent <cmd>`.
Use `-c, --count <number>` on `thread exec` to run multiple steps in one invocation. Override the agent with `--agent <cmd>`.
## CLI Reference
@@ -79,8 +79,9 @@ Global options: `-V, --version`, `--format <json|yaml>`, `-h, --help`.
| Group | Commands |
|-------|----------|
| **thread** | `start`, `step`, `show`, `list`, `kill`, `steps`, `read`, `fork`, `step-details` |
| **workflow** | `put`, `show`, `list` |
| **thread** | `start`, `exec`, `show`, `list`, `stop`, `cancel`, `read` |
| **step** | `list`, `show`, `fork` |
| **workflow** | `add`, `show`, `list` |
| **cas** | `get`, `put`, `put-text`, `has`, `refs`, `walk`, `reindex`, `schema list`, `schema get` |
| **setup** | Interactive or `--provider`, `--base-url`, `--api-key`, `--model`, `--agent` |
| **skill** | `cli` — print markdown reference of all uwf commands |
+1
View File
@@ -5,6 +5,7 @@
"packages/*"
],
"scripts": {
"uwf": "bun packages/cli-workflow/src/cli.ts",
"build": "bunx tsc --build",
"check": "bunx tsc --build && biome check . && bash scripts/lint-log-tags.sh",
"typecheck": "bunx tsc --build",
+6 -1
View File
@@ -49,8 +49,10 @@ bun link packages/cli-workflow
| `uwf thread start <workflow> -p <prompt>` | Create a thread without executing |
| `uwf thread exec <thread-id> [--agent <cmd>] [-c <count>] [--background]` | Execute one or more moderator→agent→extract cycles |
| `uwf thread show <thread-id>` | Show thread head pointer |
| `uwf thread list [--status <idle\|running\|completed>]` | List threads, optionally filtered by status |
| `uwf thread list [--status <status>] [--after <date>] [--before <date>] [--skip <n>] [--take <n>]` | List threads filtered by status (idle, running, completed, active, or comma-separated), time range (ISO or relative like '7d'), with pagination |
| `uwf thread read <thread-id> [--quota N] [--before <hash>] [--start]` | Render thread as readable markdown |
`thread read`, `step list`, and `step show` work on both active and completed threads.
| `uwf thread stop <thread-id>` | Stop background execution (keep thread active) |
| `uwf thread cancel <thread-id>` | Cancel thread (stop + archive to history) |
@@ -62,6 +64,9 @@ uwf thread exec 01ARZ3NDEKTSV4RRFFQ69G5FAV
uwf thread exec 01ARZ3NDEKTSV4RRFFQ69G5FAV -c 3 --agent uwf-builtin
uwf thread exec 01ARZ3NDEKTSV4RRFFQ69G5FAV --background
uwf thread list --status running
uwf thread list --status active
uwf thread list --status idle,completed
uwf thread list --after 7d --take 10
uwf thread read 01ARZ3NDEKTSV4RRFFQ69G5FAV --quota 8000
uwf thread stop 01ARZ3NDEKTSV4RRFFQ69G5FAV
```
@@ -0,0 +1,152 @@
import { execSync } from "node:child_process";
import { mkdir, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { cmdCasPutText } from "../commands/cas.js";
let storageRoot: string;
let uwfPath: string;
beforeEach(async () => {
storageRoot = join(
tmpdir(),
`uwf-cas-exit-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
await mkdir(storageRoot, { recursive: true });
// Find the uwf CLI path
uwfPath = join(__dirname, "../../src/cli.ts");
});
afterEach(async () => {
await rm(storageRoot, { recursive: true, force: true });
});
type ExecResult = {
stdout: string;
stderr: string;
exitCode: number;
};
function execUwf(args: string[]): ExecResult {
try {
const stdout = execSync(`bun ${uwfPath} ${args.join(" ")}`, {
env: { ...process.env, WORKFLOW_STORAGE_ROOT: storageRoot },
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
});
return { stdout, stderr: "", exitCode: 0 };
} catch (error: unknown) {
if (
error &&
typeof error === "object" &&
"stdout" in error &&
"stderr" in error &&
"status" in error
) {
return {
stdout: (error.stdout as Buffer | string).toString(),
stderr: (error.stderr as Buffer | string).toString(),
exitCode: error.status as number,
};
}
throw error;
}
}
describe("uwf cas has CLI exit codes", () => {
test("exits 0 when hash exists", async () => {
// Setup: Create a temp storage root, put a text node, capture hash
const putResult = await cmdCasPutText(storageRoot, "test content");
const hash = putResult.hash;
// Execute: uwf cas has <hash>
const result = execUwf(["cas", "has", hash]);
// Assert: stdout contains {"exists":true}, exit code === 0
expect(result.stdout).toContain('"exists":true');
expect(result.exitCode).toBe(0);
});
test("exits 1 when hash does not exist", () => {
// Setup: Create a temp storage root (empty CAS store)
// Execute: uwf cas has NOSUCHHASH123
const result = execUwf(["cas", "has", "NOSUCHHASH123"]);
// Assert: stdout contains {"exists":false}, exit code === 1
expect(result.stdout).toContain('"exists":false');
expect(result.exitCode).toBe(1);
});
test("JSON output format unchanged for exists=true", async () => {
// Setup: Create store, put node
const putResult = await cmdCasPutText(storageRoot, "test");
const hash = putResult.hash;
// Execute: uwf cas has <hash>
const result = execUwf(["cas", "has", hash]);
// Assert: stdout JSON parses correctly to {exists: true}
const parsed = JSON.parse(result.stdout.trim());
expect(parsed).toEqual({ exists: true });
});
test("JSON output format unchanged for exists=false", () => {
// Setup: Create empty store
// Execute: uwf cas has INVALID
const result = execUwf(["cas", "has", "INVALID"]);
// Assert: stdout JSON parses correctly to {exists: false}
const parsed = JSON.parse(result.stdout.trim());
expect(parsed).toEqual({ exists: false });
});
test("YAML output format preserves exit code behavior for exists=true", async () => {
// Setup: Create store with node
const putResult = await cmdCasPutText(storageRoot, "test");
const hash = putResult.hash;
// Execute: uwf --format yaml cas has <hash>
const result = execUwf(["--format", "yaml", "cas", "has", hash]);
// Assert: exit code === 0, output is YAML format
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("exists:");
expect(result.stdout).toContain("true");
});
test("YAML output format preserves exit code behavior for exists=false", () => {
// Setup: Create empty store
// Execute: uwf --format yaml cas has INVALID
const result = execUwf(["--format", "yaml", "cas", "has", "INVALID"]);
// Assert: exit code === 1, output is YAML format
expect(result.exitCode).toBe(1);
expect(result.stdout).toContain("exists:");
expect(result.stdout).toContain("false");
});
});
describe("regression: other cas commands unaffected", () => {
test("uwf cas get still exits 1 on not-found with error message", () => {
// Execute: uwf cas get NOSUCHHASH
const result = execUwf(["cas", "get", "NOSUCHHASH"]);
// Assert: exit code === 1, stderr contains "Node not found"
expect(result.exitCode).toBe(1);
expect(result.stderr).toContain("Node not found");
});
test("uwf cas put-text behavior unchanged", () => {
// Execute: uwf cas put-text "hello"
const result = execUwf(["cas", "put-text", "hello"]);
// Assert: exit code === 0, returns hash
expect(result.exitCode).toBe(0);
const parsed = JSON.parse(result.stdout.trim());
expect(parsed).toHaveProperty("hash");
expect(typeof parsed.hash).toBe("string");
expect(parsed.hash.length).toBe(13); // Crockford Base32 XXH64 hash length
});
});
@@ -0,0 +1,74 @@
import { mkdir, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { cmdCasHas, cmdCasPutText } from "../commands/cas.js";
let storageRoot: string;
beforeEach(async () => {
storageRoot = join(tmpdir(), `uwf-cas-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
await mkdir(storageRoot, { recursive: true });
});
afterEach(async () => {
await rm(storageRoot, { recursive: true, force: true });
});
describe("cmdCasHas", () => {
test("returns {exists: true} for existing hash", async () => {
// Setup: Create a test store, put a node, get its hash
const putResult = await cmdCasPutText(storageRoot, "test content");
const hash = putResult.hash;
// Execute: Call cmdCasHas with the valid hash
const result = await cmdCasHas(storageRoot, hash);
// Assert: Result equals {exists: true}
expect(result).toEqual({ exists: true });
});
test("returns {exists: false} for non-existent hash", async () => {
// Setup: Create an empty test store
// (storageRoot already created in beforeEach)
// Execute: Call cmdCasHas with an invalid hash
const result = await cmdCasHas(storageRoot, "INVALIDHASH12");
// Assert: Result equals {exists: false}
expect(result).toEqual({ exists: false });
});
test("does not throw for non-existent hash", async () => {
// Setup: Create an empty test store
// Execute & Assert: Does not throw, returns {exists: false}
await expect(cmdCasHas(storageRoot, "NOSUCHHASH123")).resolves.toEqual({
exists: false,
});
});
test("handles malformed hash gracefully", async () => {
// Setup: Create a test store
// Execute: Call cmdCasHas with a too-short hash
const result = await cmdCasHas(storageRoot, "xyz");
// Assert: Returns {exists: false} (store.has() returns false)
expect(result).toEqual({ exists: false });
});
test("handles empty hash string", async () => {
// Execute: Call cmdCasHas with an empty string
const result = await cmdCasHas(storageRoot, "");
// Assert: Returns {exists: false}
expect(result).toEqual({ exists: false });
});
test("handles hash with special characters", async () => {
// Execute: Call cmdCasHas with special characters
const result = await cmdCasHas(storageRoot, "HASH!@#");
// Assert: Returns {exists: false}
expect(result).toEqual({ exists: false });
});
});
+5 -1
View File
@@ -549,7 +549,11 @@ cas
.action((hash: string) => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
writeOutput(await cmdCasHas(storageRoot, hash));
const result = await cmdCasHas(storageRoot, hash);
writeOutput(result);
if (!result.exists) {
process.exit(1);
}
});
});
+4 -3
View File
@@ -83,9 +83,10 @@ Requires `UWF_EDGE_PROMPT` in the environment (set by `uwf thread step`).
function buildRolePrompt(role: RoleDefinition): string
function buildOutputFormatInstruction(schema: JSONSchema): string
function buildContinuationPrompt(
ctx: AgentContext,
priorOutput: string,
instruction: string,
steps: StepContext[],
role: string,
edgePrompt: string,
options?: { includeContent?: boolean; quota?: number },
): string
```
+1 -1
View File
@@ -92,7 +92,7 @@ type StepNodePayload = StepRecord & {
### Moderator context
```typescript
type StepContext = Omit<StepRecord, "output"> & { output: unknown };
type StepContext = Omit<StepRecord, "output"> & { output: unknown; content: string | null };
type ModeratorContext = {
start: StartNodePayload;
+1
View File
@@ -23,6 +23,7 @@ All exports come from `src/index.ts`.
```typescript
function encodeUint64AsCrockford(value: bigint): string
function generateUlid(nowMs: number): string
function extractUlidTimestamp(ulid: string): number | null
```
### Logging