feat(cli): add status field to thread show output
- Add ThreadStatus type to workflow-protocol - Update StepOutput type to include status field alongside deprecated done/background fields - Implement status computation in cmdThreadShow (idle/running/completed/cancelled) - Update cmdThreadStepOnce to include status in return values - Add comprehensive test suite for thread show status scenarios Fixes #559 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
#!/usr/bin/env node
|
#!/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 { Command } from "commander";
|
||||||
import {
|
import {
|
||||||
cmdCasGet,
|
cmdCasGet,
|
||||||
@@ -38,7 +38,6 @@ import {
|
|||||||
cmdThreadStart,
|
cmdThreadStart,
|
||||||
cmdThreadStop,
|
cmdThreadStop,
|
||||||
THREAD_READ_DEFAULT_QUOTA,
|
THREAD_READ_DEFAULT_QUOTA,
|
||||||
type ThreadStatus,
|
|
||||||
} from "./commands/thread.js";
|
} from "./commands/thread.js";
|
||||||
import { parseTimeInput } from "./commands/thread-time-parser.js";
|
import { parseTimeInput } from "./commands/thread-time-parser.js";
|
||||||
import { cmdWorkflowAdd, cmdWorkflowList, cmdWorkflowShow } from "./commands/workflow.js";
|
import { cmdWorkflowAdd, cmdWorkflowList, cmdWorkflowShow } from "./commands/workflow.js";
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import type {
|
|||||||
StepOutput,
|
StepOutput,
|
||||||
ThreadId,
|
ThreadId,
|
||||||
ThreadListItem,
|
ThreadListItem,
|
||||||
|
ThreadStatus,
|
||||||
ThreadsIndex,
|
ThreadsIndex,
|
||||||
WorkflowConfig,
|
WorkflowConfig,
|
||||||
WorkflowPayload,
|
WorkflowPayload,
|
||||||
@@ -315,10 +316,16 @@ export async function cmdThreadShow(storageRoot: string, threadId: ThreadId): Pr
|
|||||||
if (workflow === null) {
|
if (workflow === null) {
|
||||||
fail(`failed to resolve workflow from head: ${activeHead}`);
|
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 {
|
return {
|
||||||
workflow,
|
workflow,
|
||||||
thread: threadId,
|
thread: threadId,
|
||||||
head: activeHead,
|
head: activeHead,
|
||||||
|
status,
|
||||||
done: false,
|
done: false,
|
||||||
background: null,
|
background: null,
|
||||||
};
|
};
|
||||||
@@ -326,10 +333,13 @@ export async function cmdThreadShow(storageRoot: string, threadId: ThreadId): Pr
|
|||||||
|
|
||||||
const hist = await findThreadInHistory(storageRoot, threadId);
|
const hist = await findThreadInHistory(storageRoot, threadId);
|
||||||
if (hist !== null) {
|
if (hist !== null) {
|
||||||
|
const status: ThreadStatus = hist.reason === "cancelled" ? "cancelled" : "completed";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
workflow: hist.workflow,
|
workflow: hist.workflow,
|
||||||
thread: threadId,
|
thread: threadId,
|
||||||
head: hist.head,
|
head: hist.head,
|
||||||
|
status,
|
||||||
done: true,
|
done: true,
|
||||||
background: null,
|
background: null,
|
||||||
};
|
};
|
||||||
@@ -338,8 +348,6 @@ export async function cmdThreadShow(storageRoot: string, threadId: ThreadId): Pr
|
|||||||
fail(`thread not found: ${threadId}`);
|
fail(`thread not found: ${threadId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ThreadStatus = "idle" | "running" | "completed" | "cancelled";
|
|
||||||
|
|
||||||
export type ThreadListItemWithStatus = ThreadListItem & {
|
export type ThreadListItemWithStatus = ThreadListItem & {
|
||||||
status: ThreadStatus;
|
status: ThreadStatus;
|
||||||
};
|
};
|
||||||
@@ -947,6 +955,7 @@ async function cmdThreadStepBackground(
|
|||||||
workflow: workflowHash,
|
workflow: workflowHash,
|
||||||
thread: threadId,
|
thread: threadId,
|
||||||
head: headHash,
|
head: headHash,
|
||||||
|
status: "running",
|
||||||
done: false,
|
done: false,
|
||||||
background: true,
|
background: true,
|
||||||
},
|
},
|
||||||
@@ -989,6 +998,7 @@ async function cmdThreadStepOnce(
|
|||||||
workflow: workflowHash,
|
workflow: workflowHash,
|
||||||
thread: threadId,
|
thread: threadId,
|
||||||
head: headHash,
|
head: headHash,
|
||||||
|
status: "completed",
|
||||||
done: true,
|
done: true,
|
||||||
background: null,
|
background: null,
|
||||||
};
|
};
|
||||||
@@ -1041,10 +1051,14 @@ async function cmdThreadStepOnce(
|
|||||||
await archiveThread(storageRoot, threadId, workflowHash, newHead);
|
await archiveThread(storageRoot, threadId, workflowHash, newHead);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Determine status based on whether thread is done and running state
|
||||||
|
const status: ThreadStatus = done ? "completed" : "idle";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
workflow: workflowHash,
|
workflow: workflowHash,
|
||||||
thread: threadId,
|
thread: threadId,
|
||||||
head: newHead,
|
head: newHead,
|
||||||
|
status,
|
||||||
done,
|
done,
|
||||||
background: null,
|
background: null,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export type {
|
|||||||
ThreadForkOutput,
|
ThreadForkOutput,
|
||||||
ThreadId,
|
ThreadId,
|
||||||
ThreadListItem,
|
ThreadListItem,
|
||||||
|
ThreadStatus,
|
||||||
ThreadStepsOutput,
|
ThreadStepsOutput,
|
||||||
ThreadsIndex,
|
ThreadsIndex,
|
||||||
WorkflowConfig,
|
WorkflowConfig,
|
||||||
|
|||||||
@@ -76,17 +76,27 @@ export type ModeratorContext = {
|
|||||||
|
|
||||||
// ── 4.5 CLI 输出 ────────────────────────────────────────────────────
|
// ── 4.5 CLI 输出 ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Thread status — unified status representation */
|
||||||
|
export type ThreadStatus = "idle" | "running" | "completed" | "cancelled";
|
||||||
|
|
||||||
/** uwf thread start */
|
/** uwf thread start */
|
||||||
export type StartOutput = {
|
export type StartOutput = {
|
||||||
workflow: CasRef;
|
workflow: CasRef;
|
||||||
thread: ThreadId;
|
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 = {
|
export type StepOutput = {
|
||||||
workflow: CasRef;
|
workflow: CasRef;
|
||||||
thread: ThreadId;
|
thread: ThreadId;
|
||||||
head: CasRef;
|
head: CasRef;
|
||||||
|
status: ThreadStatus;
|
||||||
done: boolean;
|
done: boolean;
|
||||||
background: boolean | null;
|
background: boolean | null;
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user