diff --git a/src/cli/program/register.status-health-sessions.test.ts b/src/cli/program/register.status-health-sessions.test.ts index 947a2017fbc..f82dde563e3 100644 --- a/src/cli/program/register.status-health-sessions.test.ts +++ b/src/cli/program/register.status-health-sessions.test.ts @@ -238,34 +238,6 @@ describe("registerStatusHealthSessionsCommands", () => { ); }); - it("runs flows subcommands with forwarded options", async () => { - await runCli(["flows", "list", "--json", "--status", "blocked"]); - expect(flowsListCommand).toHaveBeenCalledWith( - expect.objectContaining({ - json: true, - status: "blocked", - }), - runtime, - ); - - await runCli(["flows", "show", "flow-123", "--json"]); - expect(flowsShowCommand).toHaveBeenCalledWith( - expect.objectContaining({ - lookup: "flow-123", - json: true, - }), - runtime, - ); - - await runCli(["flows", "cancel", "flow-123"]); - expect(flowsCancelCommand).toHaveBeenCalledWith( - expect.objectContaining({ - lookup: "flow-123", - }), - runtime, - ); - }); - it("forwards parent-level all-agents to cleanup subcommand", async () => { await runCli(["sessions", "--all-agents", "cleanup", "--dry-run"]); @@ -382,20 +354,10 @@ describe("registerStatusHealthSessionsCommands", () => { ); }); - it("uses TaskFlow wording for the alias command help", () => { + it("does not register the legacy top-level flows command", () => { const program = new Command(); registerStatusHealthSessionsCommands(program); - const flowsCommand = program.commands.find((command) => command.name() === "flows"); - expect(flowsCommand?.description()).toContain("TaskFlow"); - expect(flowsCommand?.commands.find((command) => command.name() === "list")?.description()).toBe( - "List tracked TaskFlows", - ); - expect(flowsCommand?.commands.find((command) => command.name() === "show")?.description()).toBe( - "Show one TaskFlow by flow id or owner key", - ); - expect( - flowsCommand?.commands.find((command) => command.name() === "cancel")?.description(), - ).toBe("Cancel a running TaskFlow"); + expect(program.commands.find((command) => command.name() === "flows")).toBeUndefined(); }); }); diff --git a/src/cli/program/register.status-health-sessions.ts b/src/cli/program/register.status-health-sessions.ts index 214c86945d7..0ce796dfc8b 100644 --- a/src/cli/program/register.status-health-sessions.ts +++ b/src/cli/program/register.status-health-sessions.ts @@ -436,79 +436,4 @@ export function registerStatusHealthSessionsCommands(program: Command) { ); }); }); - - const flowsCmd = program - .command("flows") - .description("Inspect durable TaskFlow state (alias for `openclaw tasks flow`)") - .option("--json", "Output as JSON", false) - .option( - "--status ", - "Filter by status (queued, running, waiting, blocked, succeeded, failed, cancelled, lost)", - ) - .action(async (opts) => { - await runCommandWithRuntime(defaultRuntime, async () => { - await flowsListCommand( - { - json: Boolean(opts.json), - status: opts.status as string | undefined, - }, - defaultRuntime, - ); - }); - }); - flowsCmd.enablePositionalOptions(); - - flowsCmd - .command("list") - .description("List tracked TaskFlows") - .option("--json", "Output as JSON", false) - .option( - "--status ", - "Filter by status (queued, running, waiting, blocked, succeeded, failed, cancelled, lost)", - ) - .action(async (opts, command) => { - const parentOpts = command.parent?.opts() as { json?: boolean; status?: string } | undefined; - await runCommandWithRuntime(defaultRuntime, async () => { - await flowsListCommand( - { - json: Boolean(opts.json || parentOpts?.json), - status: (opts.status as string | undefined) ?? parentOpts?.status, - }, - defaultRuntime, - ); - }); - }); - - flowsCmd - .command("show") - .description("Show one TaskFlow by flow id or owner key") - .argument("", "Flow id or owner key") - .option("--json", "Output as JSON", false) - .action(async (lookup, opts, command) => { - const parentOpts = command.parent?.opts() as { json?: boolean } | undefined; - await runCommandWithRuntime(defaultRuntime, async () => { - await flowsShowCommand( - { - lookup, - json: Boolean(opts.json || parentOpts?.json), - }, - defaultRuntime, - ); - }); - }); - - flowsCmd - .command("cancel") - .description("Cancel a running TaskFlow") - .argument("", "Flow id or owner key") - .action(async (lookup) => { - await runCommandWithRuntime(defaultRuntime, async () => { - await flowsCancelCommand( - { - lookup, - }, - defaultRuntime, - ); - }); - }); } diff --git a/src/commands/flows.test.ts b/src/commands/flows.test.ts index 219be77ab2b..0a9896d1460 100644 --- a/src/commands/flows.test.ts +++ b/src/commands/flows.test.ts @@ -124,8 +124,9 @@ describe("flows commands", () => { ownerKey: "agent:main:main", controllerId: "tests/flows-command", goal: "Investigate a flaky queue", - status: "running", + status: "blocked", currentStep: "spawn_child", + blockedSummary: "Waiting on child task output", createdAt: 100, updatedAt: 100, }); @@ -152,10 +153,62 @@ describe("flows commands", () => { .join("\n"); expect(output).toContain("TaskFlow:"); expect(output).toContain(`flowId: ${flow.flowId}`); + expect(output).toContain("status: blocked"); + expect(output).toContain("goal: Investigate a flaky queue"); expect(output).toContain("currentStep: spawn_child"); + expect(output).toContain("owner: agent:main:main"); + expect(output).toContain("state: Waiting on child task output"); expect(output).toContain("Linked tasks:"); expect(output).toContain("run-child-2"); expect(output).toContain("Collect logs"); + expect(output).not.toContain("syncMode:"); + expect(output).not.toContain("controllerId:"); + expect(output).not.toContain("revision:"); + expect(output).not.toContain("blockedTaskId:"); + expect(output).not.toContain("blockedSummary:"); + expect(output).not.toContain("wait:"); + }); + }); + + it("sanitizes TaskFlow text output before printing to the terminal", async () => { + await withTaskFlowCommandStateDir(async () => { + const unsafeOwnerKey = "agent:main:\u001b[31mowner"; + const flow = createManagedTaskFlow({ + ownerKey: unsafeOwnerKey, + controllerId: "tests/flows-command", + goal: "Investigate\nqueue\tstate", + status: "blocked", + currentStep: "spawn\u001b[2K_child", + blockedSummary: "Waiting\u001b[31m on child\nforged: yes", + createdAt: 100, + updatedAt: 100, + }); + + createRunningTaskRun({ + runtime: "subagent", + ownerKey: unsafeOwnerKey, + scopeKind: "session", + parentFlowId: flow.flowId, + childSessionKey: "agent:main:child", + runId: "run-child-3", + label: "Collect\nlogs\u001b[2K", + task: "Collect logs", + startedAt: 100, + lastEventAt: 100, + }); + + const runtime = createRuntime(); + await flowsShowCommand({ lookup: flow.flowId, json: false }, runtime); + + const lines = vi.mocked(runtime.log).mock.calls.map(([line]) => String(line)); + expect(lines).toContain("goal: Investigate\\nqueue\\tstate"); + expect(lines).toContain("currentStep: spawn_child"); + expect(lines).toContain("owner: agent:main:owner"); + expect(lines).toContain("state: Waiting on child\\nforged: yes"); + expect( + lines.some((line) => line.includes("run-child-3") && line.includes("Collect\\nlogs")), + ).toBe(true); + expect(lines.join("\n")).not.toContain("\u001b["); }); }); diff --git a/src/commands/flows.ts b/src/commands/flows.ts index a01b3bde47b..297b456d0fc 100644 --- a/src/commands/flows.ts +++ b/src/commands/flows.ts @@ -9,6 +9,7 @@ import { listTaskFlowRecords, resolveTaskFlowForLookupToken, } from "../tasks/task-flow-runtime-internal.js"; +import { sanitizeTerminalText } from "../terminal/safe-text.js"; import { isRich, theme } from "../terminal/theme.js"; const ID_PAD = 10; @@ -27,6 +28,14 @@ function truncate(value: string, maxChars: number) { return `${value.slice(0, maxChars - 1)}…`; } +function safeFlowDisplayText(value: string | undefined, maxChars?: number): string { + const sanitized = sanitizeTerminalText(value ?? "").trim(); + if (!sanitized) { + return "n/a"; + } + return typeof maxChars === "number" ? truncate(sanitized, maxChars) : sanitized; +} + function shortToken(value: string | undefined, maxChars = ID_PAD): string { const trimmed = value?.trim(); if (!trimmed) { @@ -75,9 +84,9 @@ function formatFlowRows(flows: TaskFlowRecord[], rich: boolean) { flow.syncMode.padEnd(MODE_PAD), formatFlowStatusCell(flow.status, rich), String(flow.revision).padEnd(REV_PAD), - truncate(flow.controllerId ?? "n/a", CTRL_PAD).padEnd(CTRL_PAD), + safeFlowDisplayText(flow.controllerId, CTRL_PAD).padEnd(CTRL_PAD), counts.padEnd(14), - truncate(flow.goal, 80), + safeFlowDisplayText(flow.goal, 80), ].join(" "), ); } @@ -110,6 +119,22 @@ function summarizeWait(flow: TaskFlowRecord): string { return Object.keys(flow.waitJson).toSorted().join(", ") || "object"; } +function summarizeFlowState(flow: TaskFlowRecord): string | null { + if (flow.status === "blocked") { + if (flow.blockedSummary) { + return flow.blockedSummary; + } + if (flow.blockedTaskId) { + return `blocked by ${flow.blockedTaskId}`; + } + return "blocked"; + } + if (flow.status === "waiting" && flow.waitJson != null) { + return summarizeWait(flow); + } + return null; +} + export async function flowsListCommand( opts: { json?: boolean; status?: string }, runtime: RuntimeEnv, @@ -168,6 +193,7 @@ export async function flowsShowCommand( } const tasks = listTasksForFlowId(flow.flowId); const taskSummary = getFlowTaskSummary(flow.flowId); + const stateSummary = summarizeFlowState(flow); if (opts.json) { runtime.log( @@ -187,20 +213,15 @@ export async function flowsShowCommand( const lines = [ "TaskFlow:", `flowId: ${flow.flowId}`, - `syncMode: ${flow.syncMode}`, `status: ${flow.status}`, + `goal: ${safeFlowDisplayText(flow.goal)}`, + `currentStep: ${safeFlowDisplayText(flow.currentStep)}`, + `owner: ${safeFlowDisplayText(flow.ownerKey)}`, `notify: ${flow.notifyPolicy}`, - `ownerKey: ${flow.ownerKey}`, - `controllerId: ${flow.controllerId ?? "n/a"}`, - `revision: ${flow.revision}`, - `goal: ${flow.goal}`, - `currentStep: ${flow.currentStep ?? "n/a"}`, - `blockedTaskId: ${flow.blockedTaskId ?? "n/a"}`, - `blockedSummary: ${flow.blockedSummary ?? "n/a"}`, - `wait: ${summarizeWait(flow)}`, - `cancelRequestedAt: ${ - flow.cancelRequestedAt ? new Date(flow.cancelRequestedAt).toISOString() : "n/a" - }`, + ...(stateSummary ? [`state: ${safeFlowDisplayText(stateSummary)}`] : []), + ...(flow.cancelRequestedAt + ? [`cancelRequestedAt: ${new Date(flow.cancelRequestedAt).toISOString()}`] + : []), `createdAt: ${new Date(flow.createdAt).toISOString()}`, `updatedAt: ${new Date(flow.updatedAt).toISOString()}`, `endedAt: ${flow.endedAt ? new Date(flow.endedAt).toISOString() : "n/a"}`, @@ -215,9 +236,8 @@ export async function flowsShowCommand( } runtime.log("Linked tasks:"); for (const task of tasks) { - runtime.log( - `- ${task.taskId} ${task.status} ${task.runId ?? "n/a"} ${task.label ?? task.task}`, - ); + const safeLabel = safeFlowDisplayText(task.label ?? task.task); + runtime.log(`- ${task.taskId} ${task.status} ${task.runId ?? "n/a"} ${safeLabel}`); } }