From d1a0a135d41460c57a40a4c00427b0f1e19c76a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Mon, 18 May 2026 03:04:36 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=207=20=E2=80=94=20CLI=20json-cas?= =?UTF-8?q?=20commands?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - json-cas init/workflow register+show/thread show/node get+list+walk - Nested subcommand dispatch under 'json-cas' group - Default store: ~/.uncaged/workflow/json-cas/ - 15 tests passing, biome clean Closes #304 小橘 --- .../cli-workflow/__tests__/json-cas.test.ts | 183 ++++++++++++++ packages/cli-workflow/package.json | 10 +- packages/cli-workflow/src/cli-dispatch.ts | 3 + packages/cli-workflow/src/cli-registry.ts | 9 + packages/cli-workflow/src/cli-usage.ts | 1 + .../src/commands/json-cas/dispatch.ts | 227 ++++++++++++++++++ .../src/commands/json-cas/index.ts | 25 ++ .../src/commands/json-cas/init.ts | 6 + .../src/commands/json-cas/node-get.ts | 9 + .../src/commands/json-cas/node-list.ts | 7 + .../src/commands/json-cas/node-walk.ts | 50 ++++ .../src/commands/json-cas/store.ts | 23 ++ .../src/commands/json-cas/thread-show.ts | 116 +++++++++ .../src/commands/json-cas/types.ts | 5 + .../commands/json-cas/workflow-register.ts | 21 ++ .../src/commands/json-cas/workflow-show.ts | 38 +++ 16 files changed, 730 insertions(+), 3 deletions(-) create mode 100644 packages/cli-workflow/__tests__/json-cas.test.ts create mode 100644 packages/cli-workflow/src/commands/json-cas/dispatch.ts create mode 100644 packages/cli-workflow/src/commands/json-cas/index.ts create mode 100644 packages/cli-workflow/src/commands/json-cas/init.ts create mode 100644 packages/cli-workflow/src/commands/json-cas/node-get.ts create mode 100644 packages/cli-workflow/src/commands/json-cas/node-list.ts create mode 100644 packages/cli-workflow/src/commands/json-cas/node-walk.ts create mode 100644 packages/cli-workflow/src/commands/json-cas/store.ts create mode 100644 packages/cli-workflow/src/commands/json-cas/thread-show.ts create mode 100644 packages/cli-workflow/src/commands/json-cas/types.ts create mode 100644 packages/cli-workflow/src/commands/json-cas/workflow-register.ts create mode 100644 packages/cli-workflow/src/commands/json-cas/workflow-show.ts diff --git a/packages/cli-workflow/__tests__/json-cas.test.ts b/packages/cli-workflow/__tests__/json-cas.test.ts new file mode 100644 index 0000000..3702ea4 --- /dev/null +++ b/packages/cli-workflow/__tests__/json-cas.test.ts @@ -0,0 +1,183 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + cmdJsonCasInit, + cmdNodeGet, + cmdNodeList, + cmdNodeWalk, + cmdWorkflowRegister, + cmdWorkflowShow, + formatNodeWalk, + formatWorkflowShow, + getJsonCasDir, +} from "../src/commands/json-cas/index.js"; + +const SIMPLE_WORKFLOW = { + name: "test-workflow", + description: "A test workflow for CLI tests", + roles: { + analyst: { + description: "Analyses the input", + systemPrompt: "You are an analyst.", + extractPrompt: "Extract the analysis.", + schema: { type: "object", properties: { result: { type: "string" } } }, + }, + }, + moderator: [{ from: "analyst", to: "__end__", when: null }], +}; + +describe("json-cas CLI commands", () => { + let storageRoot: string; + + beforeEach(async () => { + storageRoot = await mkdtemp(join(tmpdir(), "uncaged-json-cas-")); + }); + + afterEach(async () => { + await rm(storageRoot, { recursive: true, force: true }); + }); + + test("getJsonCasDir returns path under storageRoot", () => { + const dir = getJsonCasDir(storageRoot); + expect(dir).toBe(join(storageRoot, "json-cas")); + }); + + test("init bootstraps the store and returns a workflow type hash", async () => { + const workflowTypeHash = await cmdJsonCasInit(storageRoot); + expect(typeof workflowTypeHash).toBe("string"); + expect(workflowTypeHash.length).toBe(13); + }); + + test("init is idempotent", async () => { + const hash1 = await cmdJsonCasInit(storageRoot); + const hash2 = await cmdJsonCasInit(storageRoot); + expect(hash1).toBe(hash2); + }); + + test("workflow register returns a hash", async () => { + const filePath = join(storageRoot, "wf.json"); + await writeFile(filePath, JSON.stringify(SIMPLE_WORKFLOW), "utf-8"); + + const result = await cmdWorkflowRegister(storageRoot, filePath); + expect(typeof result.hash).toBe("string"); + expect(result.hash.length).toBe(13); + }); + + test("workflow register is idempotent", async () => { + const filePath = join(storageRoot, "wf.json"); + await writeFile(filePath, JSON.stringify(SIMPLE_WORKFLOW), "utf-8"); + + const r1 = await cmdWorkflowRegister(storageRoot, filePath); + const r2 = await cmdWorkflowRegister(storageRoot, filePath); + expect(r1.hash).toBe(r2.hash); + }); + + test("workflow show loads a registered workflow", async () => { + const filePath = join(storageRoot, "wf.json"); + await writeFile(filePath, JSON.stringify(SIMPLE_WORKFLOW), "utf-8"); + + const { hash } = await cmdWorkflowRegister(storageRoot, filePath); + const wf = await cmdWorkflowShow(storageRoot, hash); + + expect(wf).not.toBeNull(); + if (wf === null) return; + + expect(wf.name).toBe("test-workflow"); + expect(wf.description).toBe("A test workflow for CLI tests"); + expect(Object.keys(wf.roles)).toContain("analyst"); + expect(wf.roles.analyst.systemPrompt).toBe("You are an analyst."); + expect(wf.moderator).toHaveLength(1); + expect(wf.moderator[0].from).toBe("analyst"); + }); + + test("workflow show returns null for unknown hash", async () => { + await cmdJsonCasInit(storageRoot); + const result = await cmdWorkflowShow(storageRoot, "AAAAAAAAAAAAA"); + expect(result).toBeNull(); + }); + + test("formatWorkflowShow produces expected output", async () => { + const filePath = join(storageRoot, "wf.json"); + await writeFile(filePath, JSON.stringify(SIMPLE_WORKFLOW), "utf-8"); + + const { hash } = await cmdWorkflowRegister(storageRoot, filePath); + const wf = await cmdWorkflowShow(storageRoot, hash); + if (wf === null) throw new Error("workflow not found"); + + const output = formatWorkflowShow(hash, wf); + expect(output).toContain("test-workflow"); + expect(output).toContain(hash); + expect(output).toContain("analyst"); + expect(output).toContain("moderator:"); + }); + + test("node list returns hashes after registration", async () => { + const filePath = join(storageRoot, "wf.json"); + await writeFile(filePath, JSON.stringify(SIMPLE_WORKFLOW), "utf-8"); + + await cmdWorkflowRegister(storageRoot, filePath); + const hashes = await cmdNodeList(storageRoot); + + expect(hashes.length).toBeGreaterThan(0); + }); + + test("node list returns empty array for empty store", async () => { + const hashes = await cmdNodeList(storageRoot); + expect(hashes).toEqual([]); + }); + + test("node get returns JSON for a known hash", async () => { + const filePath = join(storageRoot, "wf.json"); + await writeFile(filePath, JSON.stringify(SIMPLE_WORKFLOW), "utf-8"); + + const { hash } = await cmdWorkflowRegister(storageRoot, filePath); + const json = await cmdNodeGet(storageRoot, hash); + + expect(json).not.toBeNull(); + if (json === null) return; + + const parsed = JSON.parse(json) as unknown; + expect(parsed).toMatchObject({ payload: expect.anything() }); + }); + + test("node get returns null for unknown hash", async () => { + const result = await cmdNodeGet(storageRoot, "AAAAAAAAAAAAA"); + expect(result).toBeNull(); + }); + + test("node walk traverses the workflow DAG", async () => { + const filePath = join(storageRoot, "wf.json"); + await writeFile(filePath, JSON.stringify(SIMPLE_WORKFLOW), "utf-8"); + + const { hash } = await cmdWorkflowRegister(storageRoot, filePath); + const entries = await cmdNodeWalk(storageRoot, hash); + + expect(entries).not.toBeNull(); + if (entries === null) return; + + // should include at least the workflow node, role node, and role-schema node + expect(entries.length).toBeGreaterThanOrEqual(3); + const hashes = entries.map((e) => e.hash); + expect(hashes).toContain(hash); + }); + + test("node walk returns null for unknown root", async () => { + const result = await cmdNodeWalk(storageRoot, "AAAAAAAAAAAAA"); + expect(result).toBeNull(); + }); + + test("formatNodeWalk produces output with node hashes", async () => { + const filePath = join(storageRoot, "wf.json"); + await writeFile(filePath, JSON.stringify(SIMPLE_WORKFLOW), "utf-8"); + + const { hash } = await cmdWorkflowRegister(storageRoot, filePath); + const entries = await cmdNodeWalk(storageRoot, hash); + if (entries === null) throw new Error("walk failed"); + + const output = formatNodeWalk(hash, entries); + expect(output).toContain(`walk from: ${hash}`); + expect(output).toContain(hash); + }); +}); diff --git a/packages/cli-workflow/package.json b/packages/cli-workflow/package.json index 26ded55..1df1777 100644 --- a/packages/cli-workflow/package.json +++ b/packages/cli-workflow/package.json @@ -11,13 +11,17 @@ "uncaged-workflow": "src/cli.ts" }, "dependencies": { - "@uncaged/workflow-gateway": "workspace:^", - "@uncaged/workflow-protocol": "workspace:^", - "@uncaged/workflow-util": "workspace:^", + "@uncaged/json-cas": "^0.1.1", + "@uncaged/json-cas-fs": "^0.1.1", + "@uncaged/json-cas-workflow": "^0.1.1", "@uncaged/workflow-cas": "workspace:^", "@uncaged/workflow-execute": "workspace:^", + "@uncaged/workflow-gateway": "workspace:^", + "@uncaged/workflow-json-def": "workspace:*", + "@uncaged/workflow-protocol": "workspace:^", "@uncaged/workflow-register": "workspace:^", "@uncaged/workflow-runtime": "workspace:^", + "@uncaged/workflow-util": "workspace:^", "hono": "^4.12.18", "yaml": "^2.8.4" }, diff --git a/packages/cli-workflow/src/cli-dispatch.ts b/packages/cli-workflow/src/cli-dispatch.ts index 3f3cad3..a212c92 100644 --- a/packages/cli-workflow/src/cli-dispatch.ts +++ b/packages/cli-workflow/src/cli-dispatch.ts @@ -5,6 +5,7 @@ import { formatCliUsage as formatCliUsageWithGroups } from "./cli-usage.js"; import { createCasDispatcher } from "./commands/cas/index.js"; import { dispatchConnect } from "./commands/connect/index.js"; import { createInitDispatcher } from "./commands/init/index.js"; +import { createJsonCasDispatcher } from "./commands/json-cas/index.js"; import { dispatchSetup } from "./commands/setup/index.js"; import { createThreadDispatcher, dispatchLive, dispatchRun } from "./commands/thread/index.js"; import { createWorkflowDispatcher } from "./commands/workflow/index.js"; @@ -43,6 +44,7 @@ const dispatchWorkflow = createWorkflowDispatcher({ dispatchGroup }); const dispatchThread = createThreadDispatcher({ dispatchGroup }); const dispatchCas = createCasDispatcher({ dispatchGroup }); const dispatchInit = createInitDispatcher({ dispatchGroup }); +const dispatchJsonCas = createJsonCasDispatcher({ dispatchGroup }); async function showSkillDocOrIndex(topic: string | undefined): Promise { if (topic === undefined) { @@ -72,6 +74,7 @@ const COMMAND_TABLE: Record = { run: dispatchRun, live: dispatchLive, connect: dispatchConnect, + "json-cas": dispatchJsonCas, }; export async function runCli(storageRoot: string, argv: string[]): Promise { diff --git a/packages/cli-workflow/src/cli-registry.ts b/packages/cli-workflow/src/cli-registry.ts index cfc54b9..d0a25d8 100644 --- a/packages/cli-workflow/src/cli-registry.ts +++ b/packages/cli-workflow/src/cli-registry.ts @@ -2,6 +2,7 @@ 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 { JSON_CAS_SUBCOMMAND_TABLE } from "./commands/json-cas/index.js"; import { THREAD_SUBCOMMAND_TABLE } from "./commands/thread/index.js"; import { WORKFLOW_SUBCOMMAND_TABLE } from "./commands/workflow/index.js"; @@ -52,6 +53,14 @@ export function getCommandRegistry(): ReadonlyArray { name: "setup", commands: [...SETUP_USAGE_COMMANDS], }, + { + name: "json-cas", + commands: Object.entries(JSON_CAS_SUBCOMMAND_TABLE).map(([name, e]) => ({ + name, + args: e.args, + description: e.description, + })), + }, ]; } diff --git a/packages/cli-workflow/src/cli-usage.ts b/packages/cli-workflow/src/cli-usage.ts index 2bbb2d5..617ad01 100644 --- a/packages/cli-workflow/src/cli-usage.ts +++ b/packages/cli-workflow/src/cli-usage.ts @@ -13,6 +13,7 @@ const USAGE_SECTION_BY_GROUP: Record = { cas: "Content-addressable storage:", init: "Development:", setup: "Configuration:", + "json-cas": "JSON-CAS engine:", }; export function formatUsageCommandLines( diff --git a/packages/cli-workflow/src/commands/json-cas/dispatch.ts b/packages/cli-workflow/src/commands/json-cas/dispatch.ts new file mode 100644 index 0000000..367c31b --- /dev/null +++ b/packages/cli-workflow/src/commands/json-cas/dispatch.ts @@ -0,0 +1,227 @@ +import type { CommandEntry } from "../../cli-command-types.js"; +import { printCliError, printCliLine } from "../../cli-output.js"; +import { cmdJsonCasInit } from "./init.js"; +import { cmdNodeGet } from "./node-get.js"; +import { cmdNodeList } from "./node-list.js"; +import { cmdNodeWalk, formatNodeWalk } from "./node-walk.js"; +import { getJsonCasDir } from "./store.js"; +import { cmdThreadShow, formatThreadShow } from "./thread-show.js"; +import type { JsonCasDispatchDeps } from "./types.js"; +import { cmdWorkflowRegister } from "./workflow-register.js"; +import { cmdWorkflowShow, formatWorkflowShow } from "./workflow-show.js"; + +// ── node subcommands ───────────────────────────────────────────────────────── + +export async function dispatchNodeGet(storageRoot: string, argv: string[]): Promise { + const hash = argv[0]; + if (hash === undefined || argv.length > 1) { + printCliError("error: json-cas node get requires "); + return 1; + } + const result = await cmdNodeGet(storageRoot, hash); + if (result === null) { + printCliError(`error: node not found: ${hash}`); + return 1; + } + printCliLine(result); + return 0; +} + +export async function dispatchNodeList(storageRoot: string, argv: string[]): Promise { + if (argv.length > 0) { + printCliError("error: json-cas node list takes no arguments"); + return 1; + } + const hashes = await cmdNodeList(storageRoot); + if (hashes.length === 0) { + printCliLine("(no nodes)"); + return 0; + } + for (const hash of hashes) { + printCliLine(hash); + } + return 0; +} + +export async function dispatchNodeWalk(storageRoot: string, argv: string[]): Promise { + const hash = argv[0]; + if (hash === undefined || argv.length > 1) { + printCliError("error: json-cas node walk requires "); + return 1; + } + const entries = await cmdNodeWalk(storageRoot, hash); + if (entries === null) { + printCliError(`error: node not found: ${hash}`); + return 1; + } + printCliLine(formatNodeWalk(hash, entries)); + return 0; +} + +export const JSON_CAS_NODE_TABLE: Record = { + get: { handler: dispatchNodeGet, args: "", description: "Get a CAS node as JSON" }, + list: { handler: dispatchNodeList, args: "", description: "List all hashes in the store" }, + walk: { + handler: dispatchNodeWalk, + args: "", + description: "Walk the DAG from a node, show referenced nodes", + }, +}; + +// ── workflow subcommands ───────────────────────────────────────────────────── + +export async function dispatchWorkflowRegister( + storageRoot: string, + argv: string[], +): Promise { + const file = argv[0]; + if (file === undefined || argv.length > 1) { + printCliError("error: json-cas workflow register requires "); + return 1; + } + const result = await cmdWorkflowRegister(storageRoot, file); + printCliLine(`registered workflow: ${result.hash}`); + return 0; +} + +export async function dispatchWorkflowShow(storageRoot: string, argv: string[]): Promise { + const hash = argv[0]; + if (hash === undefined || argv.length > 1) { + printCliError("error: json-cas workflow show requires "); + return 1; + } + const wf = await cmdWorkflowShow(storageRoot, hash); + if (wf === null) { + printCliError(`error: workflow not found: ${hash}`); + return 1; + } + printCliLine(formatWorkflowShow(hash, wf)); + return 0; +} + +export const JSON_CAS_WORKFLOW_TABLE: Record = { + register: { + handler: dispatchWorkflowRegister, + args: "", + description: "Register a workflow definition from a JSON file", + }, + show: { + handler: dispatchWorkflowShow, + args: "", + description: "Show a workflow by its CAS hash", + }, +}; + +// ── thread subcommands ─────────────────────────────────────────────────────── + +export async function dispatchThreadShow(storageRoot: string, argv: string[]): Promise { + const hash = argv[0]; + if (hash === undefined || argv.length > 1) { + printCliError("error: json-cas thread show requires "); + return 1; + } + const result = await cmdThreadShow(storageRoot, hash); + if (result === null) { + printCliError(`error: thread start node not found: ${hash}`); + return 1; + } + printCliLine(formatThreadShow(result)); + return 0; +} + +export const JSON_CAS_THREAD_TABLE: Record = { + show: { + handler: dispatchThreadShow, + args: "", + description: "Walk and display thread steps from a thread-start hash", + }, +}; + +// ── top-level json-cas subcommands ─────────────────────────────────────────── + +export async function dispatchJsonCasInit(storageRoot: string, argv: string[]): Promise { + if (argv.length > 0) { + printCliError("error: json-cas init takes no arguments"); + return 1; + } + const workflowTypeHash = await cmdJsonCasInit(storageRoot); + const dir = getJsonCasDir(storageRoot); + printCliLine(`initialized json-cas store at ${dir}`); + printCliLine(`workflow type hash: ${workflowTypeHash}`); + return 0; +} + +function printSubgroupHelp( + groupPath: string, + table: Record, + sub: string | undefined, +): number { + if (sub === undefined || sub === "--help" || sub === "-h") { + const lines = [`json-cas ${groupPath} subcommands:\n`]; + for (const [name, e] of Object.entries(table)) { + const args = e.args ? ` ${e.args}` : ""; + lines.push(` uncaged-workflow json-cas ${groupPath} ${name}${args}`); + lines.push(` ${e.description}\n`); + } + printCliLine(lines.join("\n")); + return sub === undefined ? 1 : 0; + } + return -1; +} + +async function dispatchSubgroup( + groupPath: string, + table: Record, + storageRoot: string, + argv: string[], +): Promise { + const sub = argv[0]; + const helpResult = printSubgroupHelp(groupPath, table, sub); + if (helpResult >= 0) return helpResult; + + const entry = table[sub as string]; + if (entry === undefined) { + printCliError(`error: unknown json-cas ${groupPath} subcommand: ${sub}`); + return 1; + } + return entry.handler(storageRoot, argv.slice(1)); +} + +export const JSON_CAS_SUBCOMMAND_TABLE: Record = { + init: { + handler: dispatchJsonCasInit, + args: "", + description: "Initialize json-cas store and register workflow schemas", + }, + workflow: { + handler: (storageRoot, argv) => + dispatchSubgroup("workflow", JSON_CAS_WORKFLOW_TABLE, storageRoot, argv), + args: "", + description: "Manage json-cas workflow definitions", + }, + thread: { + handler: (storageRoot, argv) => + dispatchSubgroup("thread", JSON_CAS_THREAD_TABLE, storageRoot, argv), + args: "", + description: "Inspect json-cas thread execution records", + }, + node: { + handler: (storageRoot, argv) => + dispatchSubgroup("node", JSON_CAS_NODE_TABLE, storageRoot, argv), + args: "", + description: "Low-level access to json-cas store nodes", + }, +}; + +export function createJsonCasDispatcher(deps: JsonCasDispatchDeps) { + const { dispatchGroup } = deps; + return async function dispatchJsonCas(storageRoot: string, argv: string[]): Promise { + const result = dispatchGroup("json-cas", JSON_CAS_SUBCOMMAND_TABLE, storageRoot, argv); + if (result !== null) { + return result; + } + const sub = argv[0]; + printCliError(`error: unknown json-cas subcommand: ${sub}`); + return 1; + }; +} diff --git a/packages/cli-workflow/src/commands/json-cas/index.ts b/packages/cli-workflow/src/commands/json-cas/index.ts new file mode 100644 index 0000000..e2954fa --- /dev/null +++ b/packages/cli-workflow/src/commands/json-cas/index.ts @@ -0,0 +1,25 @@ +export { + createJsonCasDispatcher, + dispatchJsonCasInit, + dispatchNodeGet, + dispatchNodeList, + dispatchNodeWalk, + dispatchThreadShow, + dispatchWorkflowRegister, + dispatchWorkflowShow, + JSON_CAS_NODE_TABLE, + JSON_CAS_SUBCOMMAND_TABLE, + JSON_CAS_THREAD_TABLE, + JSON_CAS_WORKFLOW_TABLE, +} from "./dispatch.js"; +export { cmdJsonCasInit } from "./init.js"; +export { cmdNodeGet } from "./node-get.js"; +export { cmdNodeList } from "./node-list.js"; +export { cmdNodeWalk, formatNodeWalk } from "./node-walk.js"; +export { getJsonCasDir, openStore } from "./store.js"; +export type { ThreadShowResult, ThreadStep } from "./thread-show.js"; +export { cmdThreadShow, formatThreadShow } from "./thread-show.js"; +export type { JsonCasDispatchDeps } from "./types.js"; +export type { WorkflowRegisterResult } from "./workflow-register.js"; +export { cmdWorkflowRegister } from "./workflow-register.js"; +export { cmdWorkflowShow, formatWorkflowShow } from "./workflow-show.js"; diff --git a/packages/cli-workflow/src/commands/json-cas/init.ts b/packages/cli-workflow/src/commands/json-cas/init.ts new file mode 100644 index 0000000..d74c391 --- /dev/null +++ b/packages/cli-workflow/src/commands/json-cas/init.ts @@ -0,0 +1,6 @@ +import { openStore } from "./store.js"; + +export async function cmdJsonCasInit(storageRoot: string): Promise { + const { typeHashes } = await openStore(storageRoot); + return typeHashes.workflow; +} diff --git a/packages/cli-workflow/src/commands/json-cas/node-get.ts b/packages/cli-workflow/src/commands/json-cas/node-get.ts new file mode 100644 index 0000000..6895872 --- /dev/null +++ b/packages/cli-workflow/src/commands/json-cas/node-get.ts @@ -0,0 +1,9 @@ +import { createFsStore } from "@uncaged/json-cas-fs"; +import { getJsonCasDir } from "./store.js"; + +export async function cmdNodeGet(storageRoot: string, hash: string): Promise { + const store = createFsStore(getJsonCasDir(storageRoot)); + const node = store.get(hash); + if (node === null) return null; + return JSON.stringify(node, null, 2); +} diff --git a/packages/cli-workflow/src/commands/json-cas/node-list.ts b/packages/cli-workflow/src/commands/json-cas/node-list.ts new file mode 100644 index 0000000..8c8b791 --- /dev/null +++ b/packages/cli-workflow/src/commands/json-cas/node-list.ts @@ -0,0 +1,7 @@ +import { createFsStore } from "@uncaged/json-cas-fs"; +import { getJsonCasDir } from "./store.js"; + +export async function cmdNodeList(storageRoot: string): Promise { + const store = createFsStore(getJsonCasDir(storageRoot)); + return store.list(); +} diff --git a/packages/cli-workflow/src/commands/json-cas/node-walk.ts b/packages/cli-workflow/src/commands/json-cas/node-walk.ts new file mode 100644 index 0000000..904cb77 --- /dev/null +++ b/packages/cli-workflow/src/commands/json-cas/node-walk.ts @@ -0,0 +1,50 @@ +import type { CasNode, Hash } from "@uncaged/json-cas"; +import { walk } from "@uncaged/json-cas"; +import { createFsStore } from "@uncaged/json-cas-fs"; +import { getJsonCasDir } from "./store.js"; + +export type WalkEntry = { + hash: Hash; + type: Hash; + timestamp: number; + payloadPreview: string; +}; + +export async function cmdNodeWalk( + storageRoot: string, + rootHash: string, +): Promise { + const store = createFsStore(getJsonCasDir(storageRoot)); + + if (!store.has(rootHash)) return null; + + const entries: WalkEntry[] = []; + + walk(store, rootHash, (hash: Hash, node: CasNode) => { + const preview = JSON.stringify(node.payload).slice(0, 100); + entries.push({ + hash, + type: node.type, + timestamp: node.timestamp, + payloadPreview: preview, + }); + }); + + return entries; +} + +export function formatNodeWalk(rootHash: string, entries: WalkEntry[]): string { + const lines: string[] = []; + lines.push(`walk from: ${rootHash}`); + lines.push(`nodes: ${entries.length}`); + + for (const entry of entries) { + lines.push(""); + lines.push(` hash: ${entry.hash}`); + lines.push(` type: ${entry.type}`); + lines.push(` time: ${new Date(entry.timestamp).toISOString()}`); + lines.push(` data: ${entry.payloadPreview}`); + } + + return lines.join("\n"); +} diff --git a/packages/cli-workflow/src/commands/json-cas/store.ts b/packages/cli-workflow/src/commands/json-cas/store.ts new file mode 100644 index 0000000..20de529 --- /dev/null +++ b/packages/cli-workflow/src/commands/json-cas/store.ts @@ -0,0 +1,23 @@ +import { join } from "node:path"; +import type { Store } from "@uncaged/json-cas"; +import { bootstrap } from "@uncaged/json-cas"; +import { createFsStore } from "@uncaged/json-cas-fs"; +import type { WorkflowSchemaHashes } from "@uncaged/json-cas-workflow"; +import { registerWorkflowSchemas } from "@uncaged/json-cas-workflow"; + +export function getJsonCasDir(storageRoot: string): string { + return join(storageRoot, "json-cas"); +} + +export type OpenStoreResult = { + store: Store; + typeHashes: WorkflowSchemaHashes; +}; + +export async function openStore(storageRoot: string): Promise { + const dir = getJsonCasDir(storageRoot); + const store = createFsStore(dir); + await bootstrap(store); + const typeHashes = await registerWorkflowSchemas(store); + return { store, typeHashes }; +} diff --git a/packages/cli-workflow/src/commands/json-cas/thread-show.ts b/packages/cli-workflow/src/commands/json-cas/thread-show.ts new file mode 100644 index 0000000..a2d80a5 --- /dev/null +++ b/packages/cli-workflow/src/commands/json-cas/thread-show.ts @@ -0,0 +1,116 @@ +import type { Hash, Store } from "@uncaged/json-cas"; +import type { + ContentPayload, + ThreadStepPayload, + WorkflowSchemaHashes, +} from "@uncaged/json-cas-workflow"; +import { openStore } from "./store.js"; + +type StepEntry = { + hash: Hash; + payload: ThreadStepPayload; +}; + +function collectStepsInOrder( + store: Store, + typeHashes: WorkflowSchemaHashes, + startHash: Hash, +): StepEntry[] { + const stepMap = new Map(); + + for (const hash of store.list()) { + const node = store.get(hash); + if (node === null || node.type !== typeHashes.threadStep) continue; + const p = node.payload as ThreadStepPayload; + if (p.start === startHash) { + stepMap.set(hash, { hash, payload: p }); + } + } + + if (stepMap.size === 0) return []; + + // Find the terminal step: the one whose hash is not referenced as `previous` by any other step + const previousSet = new Set(); + for (const entry of stepMap.values()) { + if (entry.payload.previous !== null) { + previousSet.add(entry.payload.previous); + } + } + + const terminalEntries = [...stepMap.values()].filter((e) => !previousSet.has(e.hash)); + if (terminalEntries.length === 0) return [...stepMap.values()]; + + // Walk backward from terminal to build ordered list + const ordered: StepEntry[] = []; + let current: StepEntry | undefined = terminalEntries[0]; + while (current !== undefined) { + ordered.unshift(current); + const prevHash = current.payload.previous; + if (prevHash === null) break; + current = stepMap.get(prevHash); + } + + return ordered; +} + +function getContentText(store: Store, contentHash: Hash): string { + const node = store.get(contentHash); + if (node === null) return "(not found)"; + const payload = node.payload as ContentPayload; + return typeof payload.text === "string" ? payload.text : "(no text)"; +} + +export type ThreadStep = { + hash: Hash; + role: string; + meta: Record; + contentPreview: string; +}; + +export type ThreadShowResult = { + startHash: Hash; + steps: ThreadStep[]; +}; + +export async function cmdThreadShow( + storageRoot: string, + startHash: string, +): Promise { + const { store, typeHashes } = await openStore(storageRoot); + + const startNode = store.get(startHash); + if (startNode === null) return null; + + const entries = collectStepsInOrder(store, typeHashes, startHash); + + const steps: ThreadStep[] = entries.map((entry) => { + const rawText = getContentText(store, entry.payload.content); + const preview = rawText.slice(0, 120).replace(/\n/g, " "); + return { + hash: entry.hash, + role: entry.payload.role, + meta: entry.payload.meta, + contentPreview: preview, + }; + }); + + return { startHash, steps }; +} + +export function formatThreadShow(result: ThreadShowResult): string { + const lines: string[] = []; + lines.push(`thread: ${result.startHash}`); + lines.push(`steps: ${result.steps.length}`); + + for (let i = 0; i < result.steps.length; i++) { + const step = result.steps[i]; + lines.push(""); + lines.push(` [${i + 1}] ${step.role} (${step.hash})`); + if (Object.keys(step.meta).length > 0) { + lines.push(` meta: ${JSON.stringify(step.meta)}`); + } + lines.push(` > ${step.contentPreview}`); + } + + return lines.join("\n"); +} diff --git a/packages/cli-workflow/src/commands/json-cas/types.ts b/packages/cli-workflow/src/commands/json-cas/types.ts new file mode 100644 index 0000000..775fddc --- /dev/null +++ b/packages/cli-workflow/src/commands/json-cas/types.ts @@ -0,0 +1,5 @@ +import type { DispatchGroupFn } from "../../cli-command-types.js"; + +export type JsonCasDispatchDeps = { + dispatchGroup: DispatchGroupFn; +}; diff --git a/packages/cli-workflow/src/commands/json-cas/workflow-register.ts b/packages/cli-workflow/src/commands/json-cas/workflow-register.ts new file mode 100644 index 0000000..40619fa --- /dev/null +++ b/packages/cli-workflow/src/commands/json-cas/workflow-register.ts @@ -0,0 +1,21 @@ +import { readFileSync } from "node:fs"; +import type { WorkflowInput } from "@uncaged/workflow-json-def"; +import { registerWorkflow } from "@uncaged/workflow-json-def"; +import { openStore } from "./store.js"; + +export type WorkflowRegisterResult = { + hash: string; +}; + +export async function cmdWorkflowRegister( + storageRoot: string, + filePath: string, +): Promise { + const raw = readFileSync(filePath, "utf-8"); + const workflowDef = JSON.parse(raw) as WorkflowInput; + + const { store, typeHashes } = await openStore(storageRoot); + const hash = await registerWorkflow(store, typeHashes, workflowDef); + + return { hash }; +} diff --git a/packages/cli-workflow/src/commands/json-cas/workflow-show.ts b/packages/cli-workflow/src/commands/json-cas/workflow-show.ts new file mode 100644 index 0000000..171c927 --- /dev/null +++ b/packages/cli-workflow/src/commands/json-cas/workflow-show.ts @@ -0,0 +1,38 @@ +import type { HydratedWorkflow } from "@uncaged/workflow-json-def"; +import { loadWorkflow } from "@uncaged/workflow-json-def"; +import { openStore } from "./store.js"; + +export async function cmdWorkflowShow( + storageRoot: string, + hash: string, +): Promise { + const { store, typeHashes } = await openStore(storageRoot); + return loadWorkflow(store, typeHashes, hash); +} + +export function formatWorkflowShow(hash: string, wf: HydratedWorkflow): string { + const lines: string[] = []; + lines.push(`workflow: ${wf.name}`); + lines.push(`hash: ${hash}`); + lines.push(`desc: ${wf.description}`); + lines.push(""); + + lines.push("roles:"); + for (const [name, role] of Object.entries(wf.roles)) { + lines.push(` ${name}:`); + lines.push(` description: ${role.description}`); + lines.push(` systemPrompt: ${role.systemPrompt.slice(0, 80).replace(/\n/g, " ")}...`); + lines.push(` extractPrompt: ${role.extractPrompt.slice(0, 80).replace(/\n/g, " ")}...`); + } + + if (wf.moderator.length > 0) { + lines.push(""); + lines.push("moderator:"); + for (const rule of wf.moderator) { + const when = rule.when === null ? "(always)" : `when: ${rule.when}`; + lines.push(` ${rule.from} → ${rule.to} [${when}]`); + } + } + + return lines.join("\n"); +} -- 2.43.0