fix(cli): honor inherited tasks options (#117528)

* fix(cli): honor inherited tasks options

* test(cli): classify task flow JSON support
This commit is contained in:
Peter Steinberger
2026-08-19 18:24:11 -07:00
committed by GitHub
parent 3bfc9bc804
commit bfc6bd3da5
7 changed files with 643 additions and 392 deletions
+1 -1
View File
@@ -2481,7 +2481,7 @@ src/cli/program/register.configure.ts 1
src/cli/program/register.migrate.ts 12
src/cli/program/register.onboard.ts 37
src/cli/program/register.setup.ts 10
src/cli/program/register.status-health-sessions.ts 41
src/cli/program/register.status-health-sessions.ts 29
src/cli/program/route-specs.ts 1
src/cli/run-main.ts 1
src/cli/sandbox-cli.ts 4
-1
View File
@@ -499,7 +499,6 @@ src/cli/plugins-cli-test-helpers.ts
src/cli/plugins-cli.install.test.ts
src/cli/plugins-cli.runtime.ts
src/cli/plugins-cli.update.test.ts
src/cli/program/register.status-health-sessions.ts
src/cli/run-main.exit.test.ts
src/cli/run-main.ts
src/cli/skills-cli.commands.test.ts
@@ -14,15 +14,6 @@ const mocks = vi.hoisted(() => ({
sessionsArchiveCommand: vi.fn(),
sessionsDeleteCommand: vi.fn(),
exportTrajectoryCommand: vi.fn(),
tasksListCommand: vi.fn(),
tasksAuditCommand: vi.fn(),
tasksMaintenanceCommand: vi.fn(),
tasksShowCommand: vi.fn(),
tasksNotifyCommand: vi.fn(),
tasksCancelCommand: vi.fn(),
flowsListCommand: vi.fn(),
flowsShowCommand: vi.fn(),
flowsCancelCommand: vi.fn(),
setVerbose: vi.fn(),
runtime: {
log: vi.fn(),
@@ -40,15 +31,6 @@ const sessionsCompactCommand = mocks.sessionsCompactCommand;
const sessionsArchiveCommand = mocks.sessionsArchiveCommand;
const sessionsDeleteCommand = mocks.sessionsDeleteCommand;
const exportTrajectoryCommand = mocks.exportTrajectoryCommand;
const tasksListCommand = mocks.tasksListCommand;
const tasksAuditCommand = mocks.tasksAuditCommand;
const tasksMaintenanceCommand = mocks.tasksMaintenanceCommand;
const tasksShowCommand = mocks.tasksShowCommand;
const tasksNotifyCommand = mocks.tasksNotifyCommand;
const tasksCancelCommand = mocks.tasksCancelCommand;
const flowsListCommand = mocks.flowsListCommand;
const flowsShowCommand = mocks.flowsShowCommand;
const flowsCancelCommand = mocks.flowsCancelCommand;
const setVerbose = mocks.setVerbose;
const runtime = mocks.runtime;
@@ -106,21 +88,6 @@ vi.mock("../../commands/export-trajectory.js", () => ({
exportTrajectoryCommand: mocks.exportTrajectoryCommand,
}));
vi.mock("../../commands/tasks.js", () => ({
tasksListCommand: mocks.tasksListCommand,
tasksAuditCommand: mocks.tasksAuditCommand,
tasksMaintenanceCommand: mocks.tasksMaintenanceCommand,
tasksShowCommand: mocks.tasksShowCommand,
tasksNotifyCommand: mocks.tasksNotifyCommand,
tasksCancelCommand: mocks.tasksCancelCommand,
}));
vi.mock("../../commands/flows.js", () => ({
flowsListCommand: mocks.flowsListCommand,
flowsShowCommand: mocks.flowsShowCommand,
flowsCancelCommand: mocks.flowsCancelCommand,
}));
vi.mock("../../globals.js", () => ({
setVerbose: mocks.setVerbose,
}));
@@ -152,15 +119,6 @@ describe("registerStatusHealthSessionsCommands", () => {
sessionsArchiveCommand.mockResolvedValue(undefined);
sessionsDeleteCommand.mockResolvedValue(undefined);
exportTrajectoryCommand.mockResolvedValue(undefined);
tasksListCommand.mockResolvedValue(undefined);
tasksAuditCommand.mockResolvedValue(undefined);
tasksMaintenanceCommand.mockResolvedValue(undefined);
tasksShowCommand.mockResolvedValue(undefined);
tasksNotifyCommand.mockResolvedValue(undefined);
tasksCancelCommand.mockResolvedValue(undefined);
flowsListCommand.mockResolvedValue(undefined);
flowsShowCommand.mockResolvedValue(undefined);
flowsCancelCommand.mockResolvedValue(undefined);
});
it("runs status command with timeout and debug-derived verbose", async () => {
@@ -605,102 +563,4 @@ describe("registerStatusHealthSessionsCommands", () => {
expect(runtime.exit).toHaveBeenCalledWith(1);
expect(exportTrajectoryCommand).not.toHaveBeenCalled();
});
it("runs tasks list from the parent command", async () => {
await runCli(["tasks", "--json", "--runtime", "acp", "--status", "running"]);
expectCommandOptions(tasksListCommand, {
json: true,
runtime: "acp",
status: "running",
});
});
it("runs tasks show subcommand with lookup forwarding", async () => {
await runCli(["tasks", "show", "run-123", "--json"]);
expectCommandOptions(tasksShowCommand, {
lookup: "run-123",
json: true,
});
});
it("runs tasks maintenance subcommand with apply forwarding", async () => {
await runCli(["tasks", "--json", "maintenance", "--apply"]);
expectCommandOptions(tasksMaintenanceCommand, {
json: true,
apply: true,
});
});
it("runs tasks audit subcommand with filters", async () => {
await runCli([
"tasks",
"--json",
"audit",
"--severity",
"error",
"--code",
"stale_running",
"--limit",
"5",
]);
expectCommandOptions(tasksAuditCommand, {
json: true,
severity: "error",
code: "stale_running",
limit: 5,
});
});
it("rejects partially numeric tasks audit limits", async () => {
await runCli(["tasks", "--json", "audit", "--limit", "5abc"]);
expect(runtime.error).toHaveBeenCalledWith(
"--limit must be a positive integer, for example --limit 25.",
);
expect(runtime.exit).toHaveBeenCalledWith(1);
expect(tasksAuditCommand).not.toHaveBeenCalled();
});
it("routes tasks flow commands through the TaskFlow handlers", async () => {
await runCli(["tasks", "flow", "list", "--json", "--status", "blocked"]);
expectCommandOptions(flowsListCommand, {});
await runCli(["tasks", "flow", "show", "flow-123", "--json"]);
expectCommandOptions(flowsShowCommand, {
lookup: "flow-123",
});
await runCli(["tasks", "flow", "cancel", "flow-123"]);
expectCommandOptions(flowsCancelCommand, {
lookup: "flow-123",
});
});
it("runs tasks notify subcommand with lookup and policy forwarding", async () => {
await runCli(["tasks", "notify", "run-123", "state_changes"]);
expectCommandOptions(tasksNotifyCommand, {
lookup: "run-123",
notify: "state_changes",
});
});
it("runs tasks cancel subcommand with lookup forwarding", async () => {
await runCli(["tasks", "cancel", "run-123"]);
expectCommandOptions(tasksCancelCommand, {
lookup: "run-123",
});
});
it("does not register the legacy top-level flows command", () => {
const program = new Command();
registerStatusHealthSessionsCommands(program);
expect(program.commands.find((command) => command.name() === "flows")).toBeUndefined();
});
});
@@ -5,16 +5,9 @@ import { formatDocsLink } from "../../../packages/terminal-core/src/links.js";
import { theme } from "../../../packages/terminal-core/src/theme.js";
import { setVerbose } from "../../globals.js";
import { defaultRuntime } from "../../runtime.js";
import { TASK_FLOW_STATUSES } from "../../tasks/task-flow-registry.types.js";
import { TASK_RUNTIMES, TASK_STATUSES } from "../../tasks/task-registry.types.js";
import {
TASK_SYSTEM_AUDIT_CODES,
TASK_SYSTEM_AUDIT_SEVERITIES,
type TaskSystemAuditCode,
type TaskSystemAuditSeverity,
} from "../../tasks/task-system-audit.types.js";
import { runCommandWithRuntime } from "../cli-utils.js";
import { formatHelpExamples } from "../help-format.js";
import { registerTasksCommand } from "./register.tasks.js";
function resolveVerbose(opts: { verbose?: boolean; debug?: boolean }): boolean {
return Boolean(opts.verbose || opts.debug);
@@ -63,14 +56,6 @@ function rejectUnsupportedSessionsParentOptions(
return true;
}
function createModuleLoader<T>(load: () => Promise<T>): () => Promise<T> {
let promise: Promise<T> | undefined;
return () => (promise ??= load());
}
const loadTasksCommands = createModuleLoader(() => import("../../commands/tasks.js"));
const loadFlowsCommands = createModuleLoader(() => import("../../commands/flows.js"));
function addSessionsListOptions(command: Command): Command {
return command
.option("--json", "Output as JSON", false)
@@ -225,16 +210,6 @@ function parseTimeoutMs(timeout: unknown): number | null | undefined {
return parsed;
}
function parseTasksAuditLimit(limit: unknown): number | null | undefined {
const parsed = parseStrictPositiveInteger(limit);
if (limit !== undefined && parsed === undefined) {
defaultRuntime.error("--limit must be a positive integer, for example --limit 25.");
defaultRuntime.exit(1);
return null;
}
return parsed;
}
async function runWithVerboseAndTimeout(
opts: { verbose?: boolean; debug?: boolean; timeout?: unknown },
action: (params: { verbose: boolean; timeoutMs: number | undefined }) => Promise<void>,
@@ -595,227 +570,5 @@ export function registerStatusHealthSessionsCommands(program: Command) {
});
});
const tasksCmd = program
.command("tasks")
.description("Inspect durable background tasks and TaskFlow state")
.option("--json", "Output as JSON", false)
.option("--runtime <name>", `Filter by kind (${TASK_RUNTIMES.join(", ")})`)
.option("--status <name>", `Filter by status (${TASK_STATUSES.join(", ")})`)
.action(async (opts) => {
await runCommandWithRuntime(defaultRuntime, async () => {
const { tasksListCommand } = await loadTasksCommands();
await tasksListCommand(
{
json: Boolean(opts.json),
runtime: opts.runtime as string | undefined,
status: opts.status as string | undefined,
},
defaultRuntime,
);
});
});
tasksCmd.enablePositionalOptions();
tasksCmd
.command("list")
.description("List tracked background tasks")
.option("--json", "Output as JSON", false)
.option("--runtime <name>", `Filter by kind (${TASK_RUNTIMES.join(", ")})`)
.option("--status <name>", `Filter by status (${TASK_STATUSES.join(", ")})`)
.action(async (opts, command) => {
const parentOpts = command.parent?.opts() as
| {
json?: boolean;
runtime?: string;
status?: string;
}
| undefined;
await runCommandWithRuntime(defaultRuntime, async () => {
const { tasksListCommand } = await loadTasksCommands();
await tasksListCommand(
{
json: Boolean(opts.json || parentOpts?.json),
runtime: (opts.runtime as string | undefined) ?? parentOpts?.runtime,
status: (opts.status as string | undefined) ?? parentOpts?.status,
},
defaultRuntime,
);
});
});
tasksCmd
.command("audit")
.description("Show stale or broken background tasks and TaskFlows")
.option("--json", "Output as JSON", false)
.option("--severity <level>", `Filter by severity (${TASK_SYSTEM_AUDIT_SEVERITIES.join(", ")})`)
.option("--code <name>", `Filter by finding code (${TASK_SYSTEM_AUDIT_CODES.join(", ")})`)
.option("--limit <n>", "Limit displayed findings")
.action(async (opts, command) => {
const parentOpts = command.parent?.opts() as { json?: boolean } | undefined;
const limit = parseTasksAuditLimit(opts.limit);
if (limit === null) {
return;
}
await runCommandWithRuntime(defaultRuntime, async () => {
const { tasksAuditCommand } = await loadTasksCommands();
await tasksAuditCommand(
{
json: Boolean(opts.json || parentOpts?.json),
severity: opts.severity as TaskSystemAuditSeverity | undefined,
code: opts.code as TaskSystemAuditCode | undefined,
limit,
},
defaultRuntime,
);
});
});
tasksCmd
.command("maintenance")
.description("Preview or apply tasks and TaskFlow maintenance")
.option("--json", "Output as JSON", false)
.option("--apply", "Apply reconciliation, cleanup stamping, and pruning", false)
.action(async (opts, command) => {
const parentOpts = command.parent?.opts() as { json?: boolean } | undefined;
await runCommandWithRuntime(defaultRuntime, async () => {
const { tasksMaintenanceCommand } = await loadTasksCommands();
await tasksMaintenanceCommand(
{
json: Boolean(opts.json || parentOpts?.json),
apply: Boolean(opts.apply),
},
defaultRuntime,
);
});
});
tasksCmd
.command("show")
.description("Show one background task by task id, run id, or session key")
.argument("<lookup>", "Task id, run id, or session 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 () => {
const { tasksShowCommand } = await loadTasksCommands();
await tasksShowCommand(
{
lookup,
json: Boolean(opts.json || parentOpts?.json),
},
defaultRuntime,
);
});
});
tasksCmd
.command("notify")
.description("Set task notify policy")
.argument("<lookup>", "Task id, run id, or session key")
.argument("<notify>", "Notify policy (done_only, state_changes, silent)")
.action(async (lookup, notify) => {
await runCommandWithRuntime(defaultRuntime, async () => {
const { tasksNotifyCommand } = await loadTasksCommands();
await tasksNotifyCommand(
{
lookup,
notify: notify as "done_only" | "state_changes" | "silent",
},
defaultRuntime,
);
});
});
tasksCmd
.command("cancel")
.description("Cancel a running background task")
.argument("<lookup>", "Task id, run id, or session key")
.action(async (lookup) => {
await runCommandWithRuntime(defaultRuntime, async () => {
const { tasksCancelCommand } = await loadTasksCommands();
await tasksCancelCommand(
{
lookup,
},
defaultRuntime,
);
});
});
tasksCmd
.command("retry <lookups...>")
.description("Retry delivery for up to 10 blocked subagent completions")
.action(async (lookups: string[]) => {
await runCommandWithRuntime(defaultRuntime, async () => {
const { tasksRetryCommand } = await loadTasksCommands();
await tasksRetryCommand({ lookups }, defaultRuntime);
});
});
tasksCmd
.command("dismiss <lookups...>")
.description("Dismiss delivery for up to 10 blocked subagent completions")
.action(async (lookups: string[]) => {
await runCommandWithRuntime(defaultRuntime, async () => {
const { tasksDismissCommand } = await loadTasksCommands();
await tasksDismissCommand({ lookups }, defaultRuntime);
});
});
const tasksFlowCmd = tasksCmd
.command("flow")
.description("Inspect durable TaskFlow state under tasks");
tasksFlowCmd
.command("list")
.description("List tracked TaskFlows")
.option("--json", "Output as JSON", false)
.option("--status <name>", `Filter by status (${TASK_FLOW_STATUSES.join(", ")})`)
.action(async (opts) => {
await runCommandWithRuntime(defaultRuntime, async () => {
const { flowsListCommand } = await loadFlowsCommands();
await flowsListCommand(
{
json: Boolean(opts.json),
status: opts.status as string | undefined,
},
defaultRuntime,
);
});
});
tasksFlowCmd
.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) => {
await runCommandWithRuntime(defaultRuntime, async () => {
const { flowsShowCommand } = await loadFlowsCommands();
await flowsShowCommand(
{
lookup,
json: Boolean(opts.json),
},
defaultRuntime,
);
});
});
tasksFlowCmd
.command("cancel")
.description("Cancel a running TaskFlow")
.argument("<lookup>", "Flow id or owner key")
.action(async (lookup) => {
await runCommandWithRuntime(defaultRuntime, async () => {
const { flowsCancelCommand } = await loadFlowsCommands();
await flowsCancelCommand(
{
lookup,
},
defaultRuntime,
);
});
});
registerTasksCommand(program);
}
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */
+336
View File
@@ -0,0 +1,336 @@
// Task registration tests exercise the real Commander hierarchy and option sources.
import { Command } from "commander";
import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { registerTasksCommand } from "./register.tasks.js";
const mocks = vi.hoisted(() => ({
tasksListCommand: vi.fn(),
tasksAuditCommand: vi.fn(),
tasksMaintenanceCommand: vi.fn(),
tasksShowCommand: vi.fn(),
tasksNotifyCommand: vi.fn(),
tasksCancelCommand: vi.fn(),
tasksRetryCommand: vi.fn(),
tasksDismissCommand: vi.fn(),
flowsListCommand: vi.fn(),
flowsShowCommand: vi.fn(),
flowsCancelCommand: vi.fn(),
tasksModuleLoaded: vi.fn(),
flowsModuleLoaded: vi.fn(),
runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() },
}));
vi.mock("../../commands/tasks.js", () => {
mocks.tasksModuleLoaded();
return {
tasksListCommand: mocks.tasksListCommand,
tasksAuditCommand: mocks.tasksAuditCommand,
tasksMaintenanceCommand: mocks.tasksMaintenanceCommand,
tasksShowCommand: mocks.tasksShowCommand,
tasksNotifyCommand: mocks.tasksNotifyCommand,
tasksCancelCommand: mocks.tasksCancelCommand,
tasksRetryCommand: mocks.tasksRetryCommand,
tasksDismissCommand: mocks.tasksDismissCommand,
};
});
vi.mock("../../commands/flows.js", () => {
mocks.flowsModuleLoaded();
return {
flowsListCommand: mocks.flowsListCommand,
flowsShowCommand: mocks.flowsShowCommand,
flowsCancelCommand: mocks.flowsCancelCommand,
};
});
vi.mock("../../runtime.js", () => ({ defaultRuntime: mocks.runtime }));
const ownerHandlers = [
mocks.tasksListCommand,
mocks.tasksAuditCommand,
mocks.tasksMaintenanceCommand,
mocks.tasksShowCommand,
mocks.tasksNotifyCommand,
mocks.tasksCancelCommand,
mocks.tasksRetryCommand,
mocks.tasksDismissCommand,
mocks.flowsListCommand,
mocks.flowsShowCommand,
mocks.flowsCancelCommand,
];
const requireRecord = createRequireRecord("object", "expected-label");
function expectCommandOptions(handler: (typeof ownerHandlers)[number], expected: object) {
expect(handler).toHaveBeenCalledTimes(1);
const [options, runtime] = handler.mock.calls[0] ?? [];
expect(runtime).toBe(mocks.runtime);
expect(requireRecord(options, "command options")).toMatchObject(expected);
}
describe("registerTasksCommand", () => {
async function runCli(args: string[]) {
const program = new Command().enablePositionalOptions();
registerTasksCommand(program);
await program.parseAsync(args, { from: "user" });
}
beforeEach(() => {
vi.clearAllMocks();
mocks.runtime.exit.mockImplementation(() => {});
for (const handler of ownerHandlers) {
handler.mockResolvedValue(undefined);
}
});
it("rejects inherited mutation options before loading task or flow owners", async () => {
await runCli(["tasks", "--json", "cancel", "task-123"]);
expect(mocks.tasksModuleLoaded).not.toHaveBeenCalled();
await runCli(["tasks", "--json", "flow", "cancel", "flow-123"]);
expect(mocks.flowsModuleLoaded).not.toHaveBeenCalled();
});
it("runs the bare tasks list with root options", async () => {
await runCli(["tasks", "--json", "--runtime", "acp", "--status", "running"]);
expectCommandOptions(mocks.tasksListCommand, {
json: true,
runtime: "acp",
status: "running",
});
});
it.each([
{
label: "task list options before the leaf",
args: ["tasks", "--json", "--runtime", "acp", "--status", "running", "list"],
handler: mocks.tasksListCommand,
expected: { json: true, runtime: "acp", status: "running" },
},
{
label: "task list options after the leaf",
args: ["tasks", "list", "--json", "--runtime", "acp", "--status", "running"],
handler: mocks.tasksListCommand,
expected: { json: true, runtime: "acp", status: "running" },
},
{
label: "task audit JSON before the leaf",
args: ["tasks", "--json", "audit"],
handler: mocks.tasksAuditCommand,
expected: { json: true },
},
{
label: "task audit JSON after the leaf",
args: ["tasks", "audit", "--json"],
handler: mocks.tasksAuditCommand,
expected: { json: true },
},
{
label: "task maintenance JSON before the leaf",
args: ["tasks", "--json", "maintenance", "--apply"],
handler: mocks.tasksMaintenanceCommand,
expected: { json: true, apply: true },
},
{
label: "task maintenance JSON after the leaf",
args: ["tasks", "maintenance", "--apply", "--json"],
handler: mocks.tasksMaintenanceCommand,
expected: { json: true, apply: true },
},
{
label: "task show JSON before the leaf",
args: ["tasks", "--json", "show", "run-123"],
handler: mocks.tasksShowCommand,
expected: { lookup: "run-123", json: true },
},
{
label: "task show JSON after the leaf",
args: ["tasks", "show", "run-123", "--json"],
handler: mocks.tasksShowCommand,
expected: { lookup: "run-123", json: true },
},
{
label: "flow list JSON before flow",
args: ["tasks", "--json", "flow", "list"],
handler: mocks.flowsListCommand,
expected: { json: true, status: undefined },
},
{
label: "flow list JSON before the leaf",
args: ["tasks", "flow", "--json", "list"],
handler: mocks.flowsListCommand,
expected: { json: true, status: undefined },
},
{
label: "flow list JSON after the leaf",
args: ["tasks", "flow", "list", "--json"],
handler: mocks.flowsListCommand,
expected: { json: true, status: undefined },
},
{
label: "flow list status after the leaf",
args: ["tasks", "flow", "list", "--status", "blocked"],
handler: mocks.flowsListCommand,
expected: { json: false, status: "blocked" },
},
{
label: "flow show JSON before flow",
args: ["tasks", "--json", "flow", "show", "flow-123"],
handler: mocks.flowsShowCommand,
expected: { lookup: "flow-123", json: true },
},
{
label: "flow show JSON before the leaf",
args: ["tasks", "flow", "--json", "show", "flow-123"],
handler: mocks.flowsShowCommand,
expected: { lookup: "flow-123", json: true },
},
{
label: "flow show JSON after the leaf",
args: ["tasks", "flow", "show", "flow-123", "--json"],
handler: mocks.flowsShowCommand,
expected: { lookup: "flow-123", json: true },
},
])("routes $label", async ({ args, handler, expected }) => {
await runCli(args);
expectCommandOptions(handler, expected);
});
it("runs task audit filters and validates the limit", async () => {
await runCli([
"tasks",
"audit",
"--severity",
"error",
"--code",
"stale_running",
"--limit",
"5",
]);
expectCommandOptions(mocks.tasksAuditCommand, {
severity: "error",
code: "stale_running",
limit: 5,
});
});
it("rejects partially numeric task audit limits before owner action", async () => {
await runCli(["tasks", "audit", "--limit", "5abc"]);
expect(mocks.runtime.error).toHaveBeenCalledWith(
"--limit must be a positive integer, for example --limit 25.",
);
expect(mocks.runtime.exit).toHaveBeenCalledWith(1);
expect(mocks.tasksAuditCommand).not.toHaveBeenCalled();
});
it.each([
{
args: ["tasks", "audit", "--severity", "fatal"],
error: "--severity must be warn or error.",
},
{
args: ["tasks", "audit", "--code", "unknown"],
error: expect.stringContaining("--code must be"),
},
])("narrows invalid audit filters before owner action", async ({ args, error }) => {
await runCli(args);
expect(mocks.runtime.error).toHaveBeenCalledWith(error);
expect(mocks.tasksAuditCommand).not.toHaveBeenCalled();
});
const rootOptionArgs = {
json: ["--json"],
runtime: ["--runtime", "cron"],
status: ["--status", "running"],
} as const;
const directUnsupported = [
{ leaf: ["audit"], options: ["runtime", "status"] },
{ leaf: ["maintenance", "--apply"], options: ["runtime", "status"] },
{ leaf: ["show", "task-123"], options: ["runtime", "status"] },
{ leaf: ["notify", "task-123", "silent"], options: ["json", "runtime", "status"] },
{ leaf: ["cancel", "task-123"], options: ["json", "runtime", "status"] },
{ leaf: ["retry", "task-123"], options: ["json", "runtime", "status"] },
{ leaf: ["dismiss", "task-123"], options: ["json", "runtime", "status"] },
].flatMap(({ leaf, options }) =>
options.map((option) => ({
label: `tasks ${leaf[0]} with root --${option}`,
args: ["tasks", ...rootOptionArgs[option as keyof typeof rootOptionArgs], ...leaf],
flag: `--${option}`,
})),
);
const flowRootUnsupported = [
{ leaf: ["list"], options: ["runtime", "status"] },
{ leaf: ["show", "flow-123"], options: ["runtime", "status"] },
{ leaf: ["cancel", "flow-123"], options: ["json", "runtime", "status"] },
].flatMap(({ leaf, options }) =>
options.map((option) => ({
label: `tasks flow ${leaf[0]} with tasks root --${option}`,
args: ["tasks", ...rootOptionArgs[option as keyof typeof rootOptionArgs], "flow", ...leaf],
flag: `--${option}`,
})),
);
it.each([
...directUnsupported,
...flowRootUnsupported,
{
label: "tasks flow cancel with flow parent --json",
args: ["tasks", "flow", "--json", "cancel", "flow-123"],
flag: "--json",
},
])("rejects $label before owner action", async ({ args, flag }) => {
await runCli(args);
expect(mocks.runtime.error).toHaveBeenCalledWith(expect.stringContaining(flag));
expect(mocks.runtime.exit).toHaveBeenCalledWith(1);
for (const handler of ownerHandlers) {
expect(handler).not.toHaveBeenCalled();
}
});
it.each([
{
args: ["tasks", "--runtime", "cron", "--status", "running", "maintenance", "--apply"],
error: "`tasks maintenance` does not support inherited options --runtime, --status.",
},
{
args: ["tasks", "--json", "--runtime", "cron", "cancel", "task-123"],
error: "`tasks cancel` does not support inherited options --json, --runtime.",
},
])("lists only explicitly supplied unsupported flags", async ({ args, error }) => {
await runCli(args);
expect(mocks.runtime.error).toHaveBeenCalledWith(error);
for (const handler of ownerHandlers) {
expect(handler).not.toHaveBeenCalled();
}
});
it("forwards notify and cancel arguments without root options", async () => {
await runCli(["tasks", "notify", "run-123", "state_changes"]);
expectCommandOptions(mocks.tasksNotifyCommand, {
lookup: "run-123",
notify: "state_changes",
});
vi.clearAllMocks();
await runCli(["tasks", "cancel", "run-123"]);
expectCommandOptions(mocks.tasksCancelCommand, { lookup: "run-123" });
});
it("rejects an invalid notify policy before owner action", async () => {
await runCli(["tasks", "notify", "run-123", "sometimes"]);
expect(mocks.runtime.error).toHaveBeenCalledWith(
"Notify policy must be done_only, state_changes, or silent.",
);
expect(mocks.tasksNotifyCommand).not.toHaveBeenCalled();
});
it("does not register the legacy top-level flows command", () => {
const program = new Command();
registerTasksCommand(program);
expect(program.commands.find((command) => command.name() === "flows")).toBeUndefined();
});
});
+304
View File
@@ -0,0 +1,304 @@
// Background task and TaskFlow command registration.
import { parseStrictPositiveInteger } from "@openclaw/normalization-core/number-coercion";
import type { Command } from "commander";
import { defaultRuntime } from "../../runtime.js";
import { TASK_FLOW_STATUSES } from "../../tasks/task-flow-registry.types.js";
import {
TASK_RUNTIMES,
TASK_STATUSES,
type TaskNotifyPolicy,
} from "../../tasks/task-registry.types.js";
import {
TASK_SYSTEM_AUDIT_CODES,
TASK_SYSTEM_AUDIT_SEVERITIES,
} from "../../tasks/task-system-audit.types.js";
import { runCommandWithRuntime } from "../cli-utils.js";
import { inheritOptionFromParent } from "../command-options.js";
import { parseCliEnumFilter } from "../enum-filter.js";
type TasksParentOption = "json" | "runtime" | "status";
const TASKS_PARENT_OPTIONS = ["json", "runtime", "status"] as const;
const TASKS_LEAF_OPTION_SUPPORT = {
list: TASKS_PARENT_OPTIONS,
audit: ["json"],
maintenance: ["json"],
show: ["json"],
notify: [],
cancel: [],
retry: [],
dismiss: [],
"flow list": ["json"],
"flow show": ["json"],
"flow cancel": [],
} satisfies Record<string, readonly TasksParentOption[]>;
type TasksLeaf = keyof typeof TASKS_LEAF_OPTION_SUPPORT;
function createModuleLoader<T>(load: () => Promise<T>): () => Promise<T> {
let promise: Promise<T> | undefined;
return () => (promise ??= load());
}
const loadTasksCommands = createModuleLoader(() => import("../../commands/tasks.js"));
const loadFlowsCommands = createModuleLoader(() => import("../../commands/flows.js"));
async function runOwner<T>(load: () => Promise<T>, action: (owner: T) => Promise<void>) {
await runCommandWithRuntime(defaultRuntime, async () => action(await load()));
}
function addTasksListOptions(command: Command): Command {
return command
.option("--json", "Output as JSON", false)
.option("--runtime <name>", `Filter by kind (${TASK_RUNTIMES.join(", ")})`)
.option("--status <name>", `Filter by status (${TASK_STATUSES.join(", ")})`);
}
function isTaskNotifyPolicy(value: unknown): value is TaskNotifyPolicy {
return value === "done_only" || value === "state_changes" || value === "silent";
}
function resolveTasksLeafOptions(
command: Command,
leaf: TasksLeaf,
): { json?: boolean; runtime?: string; status?: string } | undefined {
const supported: readonly TasksParentOption[] = TASKS_LEAF_OPTION_SUPPORT[leaf];
const flags = TASKS_PARENT_OPTIONS.filter(
(name) =>
!supported.includes(name) && inheritOptionFromParent(command, name, "cli") !== undefined,
).map((name) => `--${name}`);
if (flags.length > 0) {
defaultRuntime.error(
`\`tasks ${leaf}\` does not support inherited ${flags.length === 1 ? "option" : "options"} ${flags.join(", ")}.`,
);
defaultRuntime.exit(1);
return undefined;
}
const resolveLocal = (name: TasksParentOption): unknown => {
const source = command.getOptionValueSource(name);
return source && source !== "default" ? command.getOptionValue(name) : undefined;
};
const resolve = (name: TasksParentOption): unknown =>
resolveLocal(name) ?? inheritOptionFromParent(command, name);
const json = resolve("json");
const runtime = resolve("runtime");
const status = leaf === "flow list" ? resolveLocal("status") : resolve("status");
return {
json: typeof json === "boolean" ? json : undefined,
runtime: typeof runtime === "string" ? runtime : undefined,
status: typeof status === "string" ? status : undefined,
};
}
function parseTasksAuditLimit(limit: unknown): number | null | undefined {
const parsed = parseStrictPositiveInteger(limit);
if (limit !== undefined && parsed === undefined) {
defaultRuntime.error("--limit must be a positive integer, for example --limit 25.");
defaultRuntime.exit(1);
return null;
}
return parsed;
}
export function registerTasksCommand(program: Command): void {
const tasksCmd = addTasksListOptions(
program.command("tasks").description("Inspect durable background tasks and TaskFlow state"),
).action(async (opts) => {
await runOwner(loadTasksCommands, ({ tasksListCommand }) =>
tasksListCommand(
{
json: Boolean(opts.json),
runtime: typeof opts.runtime === "string" ? opts.runtime : undefined,
status: typeof opts.status === "string" ? opts.status : undefined,
},
defaultRuntime,
),
);
});
tasksCmd.enablePositionalOptions();
addTasksListOptions(tasksCmd.command("list").description("List tracked background tasks")).action(
async (_opts, command) => {
const resolved = resolveTasksLeafOptions(command, "list");
if (!resolved) {
return;
}
await runOwner(loadTasksCommands, ({ tasksListCommand }) =>
tasksListCommand(
{
json: Boolean(resolved.json),
runtime: resolved.runtime,
status: resolved.status,
},
defaultRuntime,
),
);
},
);
tasksCmd
.command("audit")
.description("Show stale or broken background tasks and TaskFlows")
.option("--json", "Output as JSON", false)
.option("--severity <level>", `Filter by severity (${TASK_SYSTEM_AUDIT_SEVERITIES.join(", ")})`)
.option("--code <name>", `Filter by finding code (${TASK_SYSTEM_AUDIT_CODES.join(", ")})`)
.option("--limit <n>", "Limit displayed findings")
.action(async (opts, command) => {
const resolved = resolveTasksLeafOptions(command, "audit");
if (!resolved) {
return;
}
const limit = parseTasksAuditLimit(opts.limit);
if (limit === null) {
return;
}
await runOwner(loadTasksCommands, ({ tasksAuditCommand }) =>
tasksAuditCommand(
{
json: Boolean(resolved.json),
severity: parseCliEnumFilter(opts.severity, "--severity", TASK_SYSTEM_AUDIT_SEVERITIES),
code: parseCliEnumFilter(opts.code, "--code", TASK_SYSTEM_AUDIT_CODES),
limit,
},
defaultRuntime,
),
);
});
tasksCmd
.command("maintenance")
.description("Preview or apply tasks and TaskFlow maintenance")
.option("--json", "Output as JSON", false)
.option("--apply", "Apply reconciliation, cleanup stamping, and pruning", false)
.action(async (opts, command) => {
const resolved = resolveTasksLeafOptions(command, "maintenance");
if (!resolved) {
return;
}
await runOwner(loadTasksCommands, ({ tasksMaintenanceCommand }) =>
tasksMaintenanceCommand(
{ json: Boolean(resolved.json), apply: Boolean(opts.apply) },
defaultRuntime,
),
);
});
tasksCmd
.command("show")
.description("Show one background task by task id, run id, or session key")
.argument("<lookup>", "Task id, run id, or session key")
.option("--json", "Output as JSON", false)
.action(async (lookup, _opts, command) => {
const resolved = resolveTasksLeafOptions(command, "show");
if (!resolved) {
return;
}
await runOwner(loadTasksCommands, ({ tasksShowCommand }) =>
tasksShowCommand({ lookup, json: Boolean(resolved.json) }, defaultRuntime),
);
});
tasksCmd
.command("notify")
.description("Set task notify policy")
.argument("<lookup>", "Task id, run id, or session key")
.argument("<notify>", "Notify policy (done_only, state_changes, silent)")
.action(async (lookup, notify, _opts, command) => {
if (!resolveTasksLeafOptions(command, "notify")) {
return;
}
if (!isTaskNotifyPolicy(notify)) {
defaultRuntime.error("Notify policy must be done_only, state_changes, or silent.");
defaultRuntime.exit(1);
return;
}
await runOwner(loadTasksCommands, ({ tasksNotifyCommand }) =>
tasksNotifyCommand({ lookup, notify }, defaultRuntime),
);
});
tasksCmd
.command("cancel")
.description("Cancel a running background task")
.argument("<lookup>", "Task id, run id, or session key")
.action(async (lookup, _opts, command) => {
if (!resolveTasksLeafOptions(command, "cancel")) {
return;
}
await runOwner(loadTasksCommands, ({ tasksCancelCommand }) =>
tasksCancelCommand({ lookup }, defaultRuntime),
);
});
tasksCmd
.command("retry <lookups...>")
.description("Retry delivery for up to 10 blocked subagent completions")
.action(async (lookups: string[], _opts, command) => {
if (!resolveTasksLeafOptions(command, "retry")) {
return;
}
await runOwner(loadTasksCommands, ({ tasksRetryCommand }) =>
tasksRetryCommand({ lookups }, defaultRuntime),
);
});
tasksCmd
.command("dismiss <lookups...>")
.description("Dismiss delivery for up to 10 blocked subagent completions")
.action(async (lookups: string[], _opts, command) => {
if (!resolveTasksLeafOptions(command, "dismiss")) {
return;
}
await runOwner(loadTasksCommands, ({ tasksDismissCommand }) =>
tasksDismissCommand({ lookups }, defaultRuntime),
);
});
const tasksFlowCmd = tasksCmd
.command("flow")
.description("Inspect durable TaskFlow state under tasks")
.option("--json", "Output as JSON", false);
tasksFlowCmd.enablePositionalOptions();
tasksFlowCmd
.command("list")
.description("List tracked TaskFlows")
.option("--json", "Output as JSON", false)
.option("--status <name>", `Filter by status (${TASK_FLOW_STATUSES.join(", ")})`)
.action(async (_opts, command) => {
const resolved = resolveTasksLeafOptions(command, "flow list");
if (!resolved) {
return;
}
await runOwner(loadFlowsCommands, ({ flowsListCommand }) =>
flowsListCommand({ json: Boolean(resolved.json), status: resolved.status }, defaultRuntime),
);
});
tasksFlowCmd
.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 resolved = resolveTasksLeafOptions(command, "flow show");
if (!resolved) {
return;
}
await runOwner(loadFlowsCommands, ({ flowsShowCommand }) =>
flowsShowCommand({ lookup, json: Boolean(resolved.json) }, defaultRuntime),
);
});
tasksFlowCmd
.command("cancel")
.description("Cancel a running TaskFlow")
.argument("<lookup>", "Flow id or owner key")
.action(async (lookup, _opts, command) => {
if (!resolveTasksLeafOptions(command, "flow cancel")) {
return;
}
await runOwner(loadFlowsCommands, ({ flowsCancelCommand }) =>
flowsCancelCommand({ lookup }, defaultRuntime),
);
});
}
@@ -94,7 +94,6 @@ const JSON_NOT_APPLICABLE = {
"models image-fallbacks",
"models auth",
"models auth order",
"tasks flow",
"skills workshop",
],
},