mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-11 01:01:13 +00:00
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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[");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user