fix: cancelled threads show distinct status instead of completed
Fixes #522
This commit is contained in:
+1
-1
@@ -12,4 +12,4 @@ packages/workflow-template-develop/develop.esm.js
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
*.py
|
*.py
|
||||||
.claude
|
.claude
|
||||||
tmp
|
tmp.worktrees/
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
],
|
],
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"bin": {
|
"bin": {
|
||||||
"uwf": "./src/cli.ts"
|
"uwf": "./dist/cli.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@uncaged/json-cas": "^0.5.3",
|
"@uncaged/json-cas": "^0.5.3",
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ describe("resolveHeadHash", () => {
|
|||||||
workflow: workflowHash,
|
workflow: workflowHash,
|
||||||
head: headHash,
|
head: headHash,
|
||||||
completedAt: Date.now(),
|
completedAt: Date.now(),
|
||||||
|
reason: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await resolveHeadHash(tmpDir, threadId);
|
const result = await resolveHeadHash(tmpDir, threadId);
|
||||||
@@ -64,6 +65,7 @@ describe("resolveHeadHash", () => {
|
|||||||
workflow: workflowHash,
|
workflow: workflowHash,
|
||||||
head: historicalHash,
|
head: historicalHash,
|
||||||
completedAt: Date.now(),
|
completedAt: Date.now(),
|
||||||
|
reason: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await resolveHeadHash(tmpDir, threadId);
|
const result = await resolveHeadHash(tmpDir, threadId);
|
||||||
@@ -87,18 +89,21 @@ describe("resolveHeadHash", () => {
|
|||||||
workflow: workflowHash,
|
workflow: workflowHash,
|
||||||
head: hash1,
|
head: hash1,
|
||||||
completedAt: Date.now() - 2000,
|
completedAt: Date.now() - 2000,
|
||||||
|
reason: null,
|
||||||
});
|
});
|
||||||
await appendThreadHistory(tmpDir, {
|
await appendThreadHistory(tmpDir, {
|
||||||
thread: threadId2,
|
thread: threadId2,
|
||||||
workflow: workflowHash,
|
workflow: workflowHash,
|
||||||
head: hash2,
|
head: hash2,
|
||||||
completedAt: Date.now() - 1000,
|
completedAt: Date.now() - 1000,
|
||||||
|
reason: null,
|
||||||
});
|
});
|
||||||
await appendThreadHistory(tmpDir, {
|
await appendThreadHistory(tmpDir, {
|
||||||
thread: threadId3,
|
thread: threadId3,
|
||||||
workflow: workflowHash,
|
workflow: workflowHash,
|
||||||
head: hash3,
|
head: hash3,
|
||||||
completedAt: Date.now(),
|
completedAt: Date.now(),
|
||||||
|
reason: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await resolveHeadHash(tmpDir, threadId2);
|
const result = await resolveHeadHash(tmpDir, threadId2);
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -74,6 +74,7 @@ async function completeThread(
|
|||||||
workflow: workflowHash,
|
workflow: workflowHash,
|
||||||
head: headHash,
|
head: headHash,
|
||||||
completedAt: Date.now(),
|
completedAt: Date.now(),
|
||||||
|
reason: null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -758,6 +758,7 @@ describe("cmdStepList with completed threads", () => {
|
|||||||
workflow: workflowHash,
|
workflow: workflowHash,
|
||||||
head: step2Hash,
|
head: step2Hash,
|
||||||
completedAt: Date.now(),
|
completedAt: Date.now(),
|
||||||
|
reason: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await cmdStepList(tmpDir, threadId);
|
const result = await cmdStepList(tmpDir, threadId);
|
||||||
@@ -886,6 +887,7 @@ describe("cmdStepShow with completed threads", () => {
|
|||||||
workflow: workflowHash,
|
workflow: workflowHash,
|
||||||
head: stepHash,
|
head: stepHash,
|
||||||
completedAt: Date.now(),
|
completedAt: Date.now(),
|
||||||
|
reason: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await cmdStepShow(tmpDir, stepHash);
|
const result = await cmdStepShow(tmpDir, stepHash);
|
||||||
@@ -949,6 +951,7 @@ describe("cmdThreadRead with completed threads", () => {
|
|||||||
workflow: workflowHash,
|
workflow: workflowHash,
|
||||||
head: stepHash,
|
head: stepHash,
|
||||||
completedAt: Date.now(),
|
completedAt: Date.now(),
|
||||||
|
reason: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, false);
|
const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, false);
|
||||||
@@ -1011,6 +1014,7 @@ describe("cmdThreadRead with completed threads", () => {
|
|||||||
workflow: workflowHash,
|
workflow: workflowHash,
|
||||||
head: step3Hash,
|
head: step3Hash,
|
||||||
completedAt: Date.now(),
|
completedAt: Date.now(),
|
||||||
|
reason: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const markdown = await cmdThreadRead(
|
const markdown = await cmdThreadRead(
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
#!/usr/bin/env bun
|
#!/usr/bin/env node
|
||||||
|
|
||||||
import type { CasRef, ThreadId } from "@uncaged/workflow-protocol";
|
import type { CasRef, ThreadId } from "@uncaged/workflow-protocol";
|
||||||
import { Command } from "commander";
|
import { Command } from "commander";
|
||||||
@@ -181,11 +181,11 @@ function parseStatusFilter(status: string | undefined): ThreadStatus[] | null {
|
|||||||
if (raw === "active") return ["idle", "running"];
|
if (raw === "active") return ["idle", "running"];
|
||||||
|
|
||||||
const parts = raw.split(",").map((s) => s.trim());
|
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) {
|
for (const part of parts) {
|
||||||
if (!validStatuses.includes(part as ThreadStatus)) {
|
if (!validStatuses.includes(part as ThreadStatus)) {
|
||||||
process.stderr.write(
|
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);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
@@ -238,7 +238,7 @@ thread
|
|||||||
.description("List threads")
|
.description("List threads")
|
||||||
.option(
|
.option(
|
||||||
"--status <status>",
|
"--status <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 <date>", "Filter threads created after this date (ISO or relative like '7d')")
|
.option("--after <date>", "Filter threads created after this date (ISO or relative like '7d')")
|
||||||
.option("--before <date>", "Filter threads created before this date (ISO or relative like '7d')")
|
.option("--before <date>", "Filter threads created before this date (ISO or relative like '7d')")
|
||||||
|
|||||||
@@ -331,7 +331,7 @@ 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";
|
export type ThreadStatus = "idle" | "running" | "completed" | "cancelled";
|
||||||
|
|
||||||
export type ThreadListItemWithStatus = ThreadListItem & {
|
export type ThreadListItemWithStatus = ThreadListItem & {
|
||||||
status: ThreadStatus;
|
status: ThreadStatus;
|
||||||
@@ -389,7 +389,7 @@ async function collectCompletedThreads(
|
|||||||
thread: entry.thread,
|
thread: entry.thread,
|
||||||
workflow: entry.workflow,
|
workflow: entry.workflow,
|
||||||
head: entry.head,
|
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);
|
let items = await collectActiveThreads(storageRoot, uwf, index);
|
||||||
|
|
||||||
// Collect completed threads (if relevant for status filter)
|
// 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) {
|
if (includeCompleted) {
|
||||||
const activeIds = new Set(items.map((i) => i.thread));
|
const activeIds = new Set(items.map((i) => i.thread));
|
||||||
const completedItems = await collectCompletedThreads(storageRoot, activeIds);
|
const completedItems = await collectCompletedThreads(storageRoot, activeIds);
|
||||||
@@ -811,6 +814,7 @@ async function archiveThread(
|
|||||||
workflow,
|
workflow,
|
||||||
head,
|
head,
|
||||||
completedAt: Date.now(),
|
completedAt: Date.now(),
|
||||||
|
reason: "completed",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1147,6 +1151,7 @@ export async function cmdThreadCancel(
|
|||||||
workflow,
|
workflow,
|
||||||
head,
|
head,
|
||||||
completedAt: Date.now(),
|
completedAt: Date.now(),
|
||||||
|
reason: "cancelled",
|
||||||
};
|
};
|
||||||
await appendThreadHistory(storageRoot, historyEntry);
|
await appendThreadHistory(storageRoot, historyEntry);
|
||||||
|
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ export function getHistoryPath(storageRoot: string): string {
|
|||||||
|
|
||||||
export type ThreadHistoryLine = ThreadListItem & {
|
export type ThreadHistoryLine = ThreadListItem & {
|
||||||
completedAt: number;
|
completedAt: number;
|
||||||
|
reason: "completed" | "cancelled" | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type UwfStore = {
|
export type UwfStore = {
|
||||||
@@ -228,7 +229,15 @@ export async function loadThreadHistory(storageRoot: string): Promise<ThreadHist
|
|||||||
typeof head === "string" &&
|
typeof head === "string" &&
|
||||||
typeof completedAt === "number"
|
typeof completedAt === "number"
|
||||||
) {
|
) {
|
||||||
lines.push({ thread: thread as ThreadId, workflow, head, completedAt });
|
const reason = rec.reason;
|
||||||
|
const parsedReason = reason === "completed" || reason === "cancelled" ? reason : null;
|
||||||
|
lines.push({
|
||||||
|
thread: thread as ThreadId,
|
||||||
|
workflow,
|
||||||
|
head,
|
||||||
|
completedAt,
|
||||||
|
reason: parsedReason,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return lines;
|
return lines;
|
||||||
|
|||||||
Reference in New Issue
Block a user