From 96039dbbbf0bf7d52ebafd086ca13063543fbc77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Mon, 25 May 2026 15:39:18 +0000 Subject: [PATCH] fix: cancelled threads show distinct status instead of completed Fixes #522 --- .gitignore | 2 +- packages/cli-workflow/package.json | 2 +- .../src/__tests__/resolve-head-hash.test.ts | 5 ++ .../__tests__/thread-cancel-status.test.ts | 85 +++++++++++++++++++ .../src/__tests__/thread-list-filters.test.ts | 1 + .../cli-workflow/src/__tests__/thread.test.ts | 4 + packages/cli-workflow/src/cli.ts | 8 +- packages/cli-workflow/src/commands/thread.ts | 11 ++- packages/cli-workflow/src/store.ts | 11 ++- 9 files changed, 119 insertions(+), 10 deletions(-) create mode 100644 packages/cli-workflow/src/__tests__/thread-cancel-status.test.ts diff --git a/.gitignore b/.gitignore index 5d51e4b..27fe16e 100644 --- a/.gitignore +++ b/.gitignore @@ -12,4 +12,4 @@ packages/workflow-template-develop/develop.esm.js .DS_Store *.py .claude -tmp \ No newline at end of file +tmp.worktrees/ diff --git a/packages/cli-workflow/package.json b/packages/cli-workflow/package.json index 66a40d7..c512c81 100644 --- a/packages/cli-workflow/package.json +++ b/packages/cli-workflow/package.json @@ -8,7 +8,7 @@ ], "type": "module", "bin": { - "uwf": "./src/cli.ts" + "uwf": "./dist/cli.js" }, "dependencies": { "@uncaged/json-cas": "^0.5.3", diff --git a/packages/cli-workflow/src/__tests__/resolve-head-hash.test.ts b/packages/cli-workflow/src/__tests__/resolve-head-hash.test.ts index b868cc9..a3e7dd9 100644 --- a/packages/cli-workflow/src/__tests__/resolve-head-hash.test.ts +++ b/packages/cli-workflow/src/__tests__/resolve-head-hash.test.ts @@ -40,6 +40,7 @@ describe("resolveHeadHash", () => { workflow: workflowHash, head: headHash, completedAt: Date.now(), + reason: null, }); const result = await resolveHeadHash(tmpDir, threadId); @@ -64,6 +65,7 @@ describe("resolveHeadHash", () => { workflow: workflowHash, head: historicalHash, completedAt: Date.now(), + reason: null, }); const result = await resolveHeadHash(tmpDir, threadId); @@ -87,18 +89,21 @@ describe("resolveHeadHash", () => { workflow: workflowHash, head: hash1, completedAt: Date.now() - 2000, + reason: null, }); await appendThreadHistory(tmpDir, { thread: threadId2, workflow: workflowHash, head: hash2, completedAt: Date.now() - 1000, + reason: null, }); await appendThreadHistory(tmpDir, { thread: threadId3, workflow: workflowHash, head: hash3, completedAt: Date.now(), + reason: null, }); const result = await resolveHeadHash(tmpDir, threadId2); diff --git a/packages/cli-workflow/src/__tests__/thread-cancel-status.test.ts b/packages/cli-workflow/src/__tests__/thread-cancel-status.test.ts new file mode 100644 index 0000000..6e9a8aa --- /dev/null +++ b/packages/cli-workflow/src/__tests__/thread-cancel-status.test.ts @@ -0,0 +1,85 @@ +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { CasRef, ThreadId } from "@uncaged/workflow-protocol"; +import { describe, expect, test } from "vitest"; +import { appendThreadHistory, loadThreadHistory } from "../store.js"; + +describe("thread cancel status", () => { + test("cancelled history entry has reason 'cancelled'", async () => { + const tmpDir = await mkdtemp(join(tmpdir(), "uwf-cancel-test-")); + const threadId = "01JTEST000000000000CANCEL1" as ThreadId; + + await appendThreadHistory(tmpDir, { + thread: threadId, + workflow: "test-workflow", + head: "test-head-hash" as CasRef, + completedAt: Date.now(), + reason: "cancelled", + }); + + const history = await loadThreadHistory(tmpDir); + expect(history).toHaveLength(1); + expect(history[0]?.reason).toBe("cancelled"); + }); + + test("completed history entry has reason 'completed'", async () => { + const tmpDir = await mkdtemp(join(tmpdir(), "uwf-cancel-test-")); + const threadId = "01JTEST000000000000CANCEL2" as ThreadId; + + await appendThreadHistory(tmpDir, { + thread: threadId, + workflow: "test-workflow", + head: "test-head-hash" as CasRef, + completedAt: Date.now(), + reason: "completed", + }); + + const history = await loadThreadHistory(tmpDir); + expect(history).toHaveLength(1); + expect(history[0]?.reason).toBe("completed"); + }); + + test("legacy history entry without reason parses as null", async () => { + const tmpDir = await mkdtemp(join(tmpdir(), "uwf-cancel-test-")); + const threadId = "01JTEST000000000000CANCEL3" as ThreadId; + + // Simulate legacy entry without reason field + await appendThreadHistory(tmpDir, { + thread: threadId, + workflow: "test-workflow", + head: "test-head-hash" as CasRef, + completedAt: Date.now(), + reason: null, + }); + + const history = await loadThreadHistory(tmpDir); + expect(history).toHaveLength(1); + expect(history[0]?.reason).toBeNull(); + }); + + test("mixed completed and cancelled entries preserve distinct reasons", async () => { + const tmpDir = await mkdtemp(join(tmpdir(), "uwf-cancel-test-")); + + await appendThreadHistory(tmpDir, { + thread: "01JTEST000000000000CANCEL4" as ThreadId, + workflow: "test-workflow", + head: "head1" as CasRef, + completedAt: Date.now(), + reason: "completed", + }); + + await appendThreadHistory(tmpDir, { + thread: "01JTEST000000000000CANCEL5" as ThreadId, + workflow: "test-workflow", + head: "head2" as CasRef, + completedAt: Date.now(), + reason: "cancelled", + }); + + const history = await loadThreadHistory(tmpDir); + expect(history).toHaveLength(2); + expect(history[0]?.reason).toBe("completed"); + expect(history[1]?.reason).toBe("cancelled"); + }); +}); diff --git a/packages/cli-workflow/src/__tests__/thread-list-filters.test.ts b/packages/cli-workflow/src/__tests__/thread-list-filters.test.ts index aea5aec..2d7ee2d 100644 --- a/packages/cli-workflow/src/__tests__/thread-list-filters.test.ts +++ b/packages/cli-workflow/src/__tests__/thread-list-filters.test.ts @@ -74,6 +74,7 @@ async function completeThread( workflow: workflowHash, head: headHash, completedAt: Date.now(), + reason: null, }); } diff --git a/packages/cli-workflow/src/__tests__/thread.test.ts b/packages/cli-workflow/src/__tests__/thread.test.ts index 1eb6163..b77f27d 100644 --- a/packages/cli-workflow/src/__tests__/thread.test.ts +++ b/packages/cli-workflow/src/__tests__/thread.test.ts @@ -758,6 +758,7 @@ describe("cmdStepList with completed threads", () => { workflow: workflowHash, head: step2Hash, completedAt: Date.now(), + reason: null, }); const result = await cmdStepList(tmpDir, threadId); @@ -886,6 +887,7 @@ describe("cmdStepShow with completed threads", () => { workflow: workflowHash, head: stepHash, completedAt: Date.now(), + reason: null, }); const result = await cmdStepShow(tmpDir, stepHash); @@ -949,6 +951,7 @@ describe("cmdThreadRead with completed threads", () => { workflow: workflowHash, head: stepHash, completedAt: Date.now(), + reason: null, }); const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, false); @@ -1011,6 +1014,7 @@ describe("cmdThreadRead with completed threads", () => { workflow: workflowHash, head: step3Hash, completedAt: Date.now(), + reason: null, }); const markdown = await cmdThreadRead( diff --git a/packages/cli-workflow/src/cli.ts b/packages/cli-workflow/src/cli.ts index 9a76671..3ecddaa 100755 --- a/packages/cli-workflow/src/cli.ts +++ b/packages/cli-workflow/src/cli.ts @@ -1,4 +1,4 @@ -#!/usr/bin/env bun +#!/usr/bin/env node import type { CasRef, ThreadId } from "@uncaged/workflow-protocol"; import { Command } from "commander"; @@ -181,11 +181,11 @@ function parseStatusFilter(status: string | undefined): ThreadStatus[] | null { if (raw === "active") return ["idle", "running"]; const parts = raw.split(",").map((s) => s.trim()); - const validStatuses: ThreadStatus[] = ["idle", "running", "completed"]; + const validStatuses: ThreadStatus[] = ["idle", "running", "completed", "cancelled"]; for (const part of parts) { if (!validStatuses.includes(part as ThreadStatus)) { process.stderr.write( - `Invalid status: ${part}. Must be one of: idle, running, completed, active\n`, + `Invalid status: ${part}. Must be one of: idle, running, completed, cancelled, active\n`, ); process.exit(1); } @@ -238,7 +238,7 @@ thread .description("List threads") .option( "--status ", - "Filter by status: idle, running, completed, active (idle+running), or comma-separated values", + "Filter by status: idle, running, completed, cancelled, active (idle+running), or comma-separated values", ) .option("--after ", "Filter threads created after this date (ISO or relative like '7d')") .option("--before ", "Filter threads created before this date (ISO or relative like '7d')") diff --git a/packages/cli-workflow/src/commands/thread.ts b/packages/cli-workflow/src/commands/thread.ts index ee2b9f2..e1083c4 100644 --- a/packages/cli-workflow/src/commands/thread.ts +++ b/packages/cli-workflow/src/commands/thread.ts @@ -331,7 +331,7 @@ export async function cmdThreadShow(storageRoot: string, threadId: ThreadId): Pr fail(`thread not found: ${threadId}`); } -export type ThreadStatus = "idle" | "running" | "completed"; +export type ThreadStatus = "idle" | "running" | "completed" | "cancelled"; export type ThreadListItemWithStatus = ThreadListItem & { status: ThreadStatus; @@ -389,7 +389,7 @@ async function collectCompletedThreads( thread: entry.thread, workflow: entry.workflow, head: entry.head, - status: "completed", + status: entry.reason === "cancelled" ? "cancelled" : "completed", }); } } @@ -444,7 +444,10 @@ export async function cmdThreadList( let items = await collectActiveThreads(storageRoot, uwf, index); // Collect completed threads (if relevant for status filter) - const includeCompleted = statusFilter === null || statusFilter.includes("completed"); + const includeCompleted = + statusFilter === null || + statusFilter.includes("completed") || + statusFilter.includes("cancelled"); if (includeCompleted) { const activeIds = new Set(items.map((i) => i.thread)); const completedItems = await collectCompletedThreads(storageRoot, activeIds); @@ -811,6 +814,7 @@ async function archiveThread( workflow, head, completedAt: Date.now(), + reason: "completed", }); } @@ -1147,6 +1151,7 @@ export async function cmdThreadCancel( workflow, head, completedAt: Date.now(), + reason: "cancelled", }; await appendThreadHistory(storageRoot, historyEntry); diff --git a/packages/cli-workflow/src/store.ts b/packages/cli-workflow/src/store.ts index ba73db4..3016ab5 100644 --- a/packages/cli-workflow/src/store.ts +++ b/packages/cli-workflow/src/store.ts @@ -88,6 +88,7 @@ export function getHistoryPath(storageRoot: string): string { export type ThreadHistoryLine = ThreadListItem & { completedAt: number; + reason: "completed" | "cancelled" | null; }; export type UwfStore = { @@ -228,7 +229,15 @@ export async function loadThreadHistory(storageRoot: string): Promise