fix(tasks): tighten task-flow CLI surface (#59757)

* fix(tasks): tighten task-flow CLI surface

* fix(tasks): sanitize task-flow CLI text output
This commit is contained in:
Vincent Koc
2026-04-03 00:25:10 +09:00
committed by GitHub
parent 0a76780f57
commit efe9464f5f
4 changed files with 93 additions and 133 deletions

View File

@@ -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();
});
});

View File

@@ -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 <name>",
"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 <name>",
"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("<lookup>", "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("<lookup>", "Flow id or owner key")
.action(async (lookup) => {
await runCommandWithRuntime(defaultRuntime, async () => {
await flowsCancelCommand(
{
lookup,
},
defaultRuntime,
);
});
});
}

View File

@@ -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[");
});
});

View File

@@ -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}`);
}
}