diff --git a/packages/cli-workflow/src/__tests__/thread-show-status.test.ts b/packages/cli-workflow/src/__tests__/thread-show-status.test.ts new file mode 100644 index 0000000..7f8360c --- /dev/null +++ b/packages/cli-workflow/src/__tests__/thread-show-status.test.ts @@ -0,0 +1,227 @@ +import { mkdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { ThreadId } from "@uncaged/workflow-protocol"; +import { describe, expect, test } from "vitest"; +import { createMarker, deleteMarker } from "../background/index.js"; +import { cmdThreadShow, cmdThreadStart } from "../commands/thread.js"; +import { appendThreadHistory, loadThreadsIndex } from "../store.js"; + +const TEST_WORKFLOW_YAML = ` +name: test-status +description: Test workflow for status field +roles: + planner: + description: Plans the work + goal: Plan implementation + capabilities: ["planning"] + procedure: Plan + output: | + $status: "ready" + frontmatter: + type: object + required: ["$status"] + properties: + $status: { type: string } +graph: + $START: + _: + role: planner + prompt: "Plan the work" + location: null + planner: + _: + role: $END + prompt: "Done" + location: null +`; + +describe("thread show status field", () => { + let tmpDir: string; + let storageRoot: string; + + async function setupTestEnv() { + tmpDir = join(tmpdir(), `uwf-test-status-${Date.now()}`); + storageRoot = join(tmpDir, "storage"); + await mkdir(storageRoot, { recursive: true }); + } + + async function teardown() { + if (tmpDir) { + await rm(tmpDir, { recursive: true, force: true }); + } + } + + test("active idle thread shows status 'idle'", async () => { + await setupTestEnv(); + + const workflowPath = join(tmpDir, "test-status.yaml"); + await writeFile(workflowPath, TEST_WORKFLOW_YAML, "utf8"); + + // Create a thread + const startResult = await cmdThreadStart(storageRoot, workflowPath, "test prompt", tmpDir); + const threadId = startResult.thread as ThreadId; + + // Show the thread (should be idle) + const result = await cmdThreadShow(storageRoot, threadId); + + expect(result.status).toBe("idle"); + expect(result.done).toBe(false); + expect(result.background).toBe(null); + expect(result.thread).toBe(threadId); + + await teardown(); + }); + + test("active running thread shows status 'running'", async () => { + await setupTestEnv(); + + const workflowPath = join(tmpDir, "test-status.yaml"); + await writeFile(workflowPath, TEST_WORKFLOW_YAML, "utf8"); + + // Create a thread + const startResult = await cmdThreadStart(storageRoot, workflowPath, "test prompt", tmpDir); + const threadId = startResult.thread as ThreadId; + const workflow = startResult.workflow; + + // Create a running marker + await createMarker(storageRoot, { + thread: threadId, + workflow, + pid: process.pid, + startedAt: Date.now(), + }); + + try { + const result = await cmdThreadShow(storageRoot, threadId); + + expect(result.status).toBe("running"); + expect(result.done).toBe(false); + expect(result.background).toBe(null); + expect(result.thread).toBe(threadId); + } finally { + // Cleanup: delete marker + await deleteMarker(storageRoot, threadId); + await teardown(); + } + }); + + test("completed thread shows status 'completed'", async () => { + await setupTestEnv(); + + const workflowPath = join(tmpDir, "test-status.yaml"); + await writeFile(workflowPath, TEST_WORKFLOW_YAML, "utf8"); + + // Create a thread + const startResult = await cmdThreadStart(storageRoot, workflowPath, "test prompt", tmpDir); + const threadId = startResult.thread as ThreadId; + const workflow = startResult.workflow; + + // Get the head hash before moving to history + const index = await loadThreadsIndex(storageRoot); + const head = index[threadId]; + if (!head) throw new Error("Thread not found in index"); + + // Move thread to history with reason 'completed' + const { saveThreadsIndex } = await import("../store.js"); + const newIndex = { ...index }; + delete newIndex[threadId]; + await saveThreadsIndex(storageRoot, newIndex); + + await appendThreadHistory(storageRoot, { + thread: threadId, + workflow, + head, + completedAt: Date.now(), + reason: "completed", + }); + + const result = await cmdThreadShow(storageRoot, threadId); + + expect(result.status).toBe("completed"); + expect(result.done).toBe(true); + expect(result.background).toBe(null); + expect(result.thread).toBe(threadId); + + await teardown(); + }); + + test("cancelled thread shows status 'cancelled'", async () => { + await setupTestEnv(); + + const workflowPath = join(tmpDir, "test-status.yaml"); + await writeFile(workflowPath, TEST_WORKFLOW_YAML, "utf8"); + + // Create a thread + const startResult = await cmdThreadStart(storageRoot, workflowPath, "test prompt", tmpDir); + const threadId = startResult.thread as ThreadId; + const workflow = startResult.workflow; + + // Get the head hash before moving to history + const index = await loadThreadsIndex(storageRoot); + const head = index[threadId]; + if (!head) throw new Error("Thread not found in index"); + + // Move thread to history with reason 'cancelled' + const { saveThreadsIndex } = await import("../store.js"); + const newIndex = { ...index }; + delete newIndex[threadId]; + await saveThreadsIndex(storageRoot, newIndex); + + await appendThreadHistory(storageRoot, { + thread: threadId, + workflow, + head, + completedAt: Date.now(), + reason: "cancelled", + }); + + const result = await cmdThreadShow(storageRoot, threadId); + + expect(result.status).toBe("cancelled"); + expect(result.done).toBe(true); + expect(result.background).toBe(null); + expect(result.thread).toBe(threadId); + + await teardown(); + }); + + test("legacy completed thread without reason shows status 'completed'", async () => { + await setupTestEnv(); + + const workflowPath = join(tmpDir, "test-status.yaml"); + await writeFile(workflowPath, TEST_WORKFLOW_YAML, "utf8"); + + // Create a thread + const startResult = await cmdThreadStart(storageRoot, workflowPath, "test prompt", tmpDir); + const threadId = startResult.thread as ThreadId; + const workflow = startResult.workflow; + + // Get the head hash before moving to history + const index = await loadThreadsIndex(storageRoot); + const head = index[threadId]; + if (!head) throw new Error("Thread not found in index"); + + // Move thread to history with reason null (legacy format) + const { saveThreadsIndex } = await import("../store.js"); + const newIndex = { ...index }; + delete newIndex[threadId]; + await saveThreadsIndex(storageRoot, newIndex); + + await appendThreadHistory(storageRoot, { + thread: threadId, + workflow, + head, + completedAt: Date.now(), + reason: null, + }); + + const result = await cmdThreadShow(storageRoot, threadId); + + expect(result.status).toBe("completed"); + expect(result.done).toBe(true); + expect(result.background).toBe(null); + + await teardown(); + }); +}); diff --git a/packages/cli-workflow/src/cli.ts b/packages/cli-workflow/src/cli.ts index a351632..6ac27ea 100755 --- a/packages/cli-workflow/src/cli.ts +++ b/packages/cli-workflow/src/cli.ts @@ -1,6 +1,6 @@ #!/usr/bin/env node -import type { CasRef, ThreadId } from "@uncaged/workflow-protocol"; +import type { CasRef, ThreadId, ThreadStatus } from "@uncaged/workflow-protocol"; import { Command } from "commander"; import { cmdCasGet, @@ -38,7 +38,6 @@ import { cmdThreadStart, cmdThreadStop, THREAD_READ_DEFAULT_QUOTA, - type ThreadStatus, } from "./commands/thread.js"; import { parseTimeInput } from "./commands/thread-time-parser.js"; import { cmdWorkflowAdd, cmdWorkflowList, cmdWorkflowShow } from "./commands/workflow.js"; diff --git a/packages/cli-workflow/src/commands/thread.ts b/packages/cli-workflow/src/commands/thread.ts index a5c6b3c..2418321 100644 --- a/packages/cli-workflow/src/commands/thread.ts +++ b/packages/cli-workflow/src/commands/thread.ts @@ -12,6 +12,7 @@ import type { StepOutput, ThreadId, ThreadListItem, + ThreadStatus, ThreadsIndex, WorkflowConfig, WorkflowPayload, @@ -315,10 +316,16 @@ export async function cmdThreadShow(storageRoot: string, threadId: ThreadId): Pr if (workflow === null) { fail(`failed to resolve workflow from head: ${activeHead}`); } + + // Check if thread is running + const runningMarker = await isThreadRunning(storageRoot, threadId); + const status: ThreadStatus = runningMarker !== null ? "running" : "idle"; + return { workflow, thread: threadId, head: activeHead, + status, done: false, background: null, }; @@ -326,10 +333,13 @@ export async function cmdThreadShow(storageRoot: string, threadId: ThreadId): Pr const hist = await findThreadInHistory(storageRoot, threadId); if (hist !== null) { + const status: ThreadStatus = hist.reason === "cancelled" ? "cancelled" : "completed"; + return { workflow: hist.workflow, thread: threadId, head: hist.head, + status, done: true, background: null, }; @@ -338,8 +348,6 @@ export async function cmdThreadShow(storageRoot: string, threadId: ThreadId): Pr fail(`thread not found: ${threadId}`); } -export type ThreadStatus = "idle" | "running" | "completed" | "cancelled"; - export type ThreadListItemWithStatus = ThreadListItem & { status: ThreadStatus; }; @@ -947,6 +955,7 @@ async function cmdThreadStepBackground( workflow: workflowHash, thread: threadId, head: headHash, + status: "running", done: false, background: true, }, @@ -989,6 +998,7 @@ async function cmdThreadStepOnce( workflow: workflowHash, thread: threadId, head: headHash, + status: "completed", done: true, background: null, }; @@ -1041,10 +1051,14 @@ async function cmdThreadStepOnce( await archiveThread(storageRoot, threadId, workflowHash, newHead); } + // Determine status based on whether thread is done and running state + const status: ThreadStatus = done ? "completed" : "idle"; + return { workflow: workflowHash, thread: threadId, head: newHead, + status, done, background: null, }; diff --git a/packages/workflow-protocol/src/index.ts b/packages/workflow-protocol/src/index.ts index 3fe7f2d..30c5829 100644 --- a/packages/workflow-protocol/src/index.ts +++ b/packages/workflow-protocol/src/index.ts @@ -29,6 +29,7 @@ export type { ThreadForkOutput, ThreadId, ThreadListItem, + ThreadStatus, ThreadStepsOutput, ThreadsIndex, WorkflowConfig, diff --git a/packages/workflow-protocol/src/types.ts b/packages/workflow-protocol/src/types.ts index db80654..e26717d 100644 --- a/packages/workflow-protocol/src/types.ts +++ b/packages/workflow-protocol/src/types.ts @@ -76,17 +76,27 @@ export type ModeratorContext = { // ── 4.5 CLI 输出 ──────────────────────────────────────────────────── +/** Thread status — unified status representation */ +export type ThreadStatus = "idle" | "running" | "completed" | "cancelled"; + /** uwf thread start */ export type StartOutput = { workflow: CasRef; thread: ThreadId; }; -/** uwf thread step / uwf thread show */ +/** + * Output from thread show and thread exec commands. + * + * @property status - Current thread status (idle/running/completed/cancelled) + * @property done - @deprecated Use status field instead. True if thread is completed or cancelled. + * @property background - @deprecated Use status field instead. Always null in current implementation. + */ export type StepOutput = { workflow: CasRef; thread: ThreadId; head: CasRef; + status: ThreadStatus; done: boolean; background: boolean | null; };