diff --git a/config/assertion-safety-baseline.txt b/config/assertion-safety-baseline.txt index 38db9d2c00f3..6c242f03c566 100644 --- a/config/assertion-safety-baseline.txt +++ b/config/assertion-safety-baseline.txt @@ -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 diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index c761ba5ef69e..3a42d3717fd6 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -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 diff --git a/src/cli/program/register.status-health-sessions.test.ts b/src/cli/program/register.status-health-sessions.test.ts index 666780e701d7..6e441a713b92 100644 --- a/src/cli/program/register.status-health-sessions.test.ts +++ b/src/cli/program/register.status-health-sessions.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(); - }); }); diff --git a/src/cli/program/register.status-health-sessions.ts b/src/cli/program/register.status-health-sessions.ts index 2b23ce0a0d6c..ca050cb64193 100644 --- a/src/cli/program/register.status-health-sessions.ts +++ b/src/cli/program/register.status-health-sessions.ts @@ -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(load: () => Promise): () => Promise { - let promise: Promise | 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, @@ -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 ", `Filter by kind (${TASK_RUNTIMES.join(", ")})`) - .option("--status ", `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 ", `Filter by kind (${TASK_RUNTIMES.join(", ")})`) - .option("--status ", `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 ", `Filter by severity (${TASK_SYSTEM_AUDIT_SEVERITIES.join(", ")})`) - .option("--code ", `Filter by finding code (${TASK_SYSTEM_AUDIT_CODES.join(", ")})`) - .option("--limit ", "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("", "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("", "Task id, run id, or session key") - .argument("", "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("", "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 ") - .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 ") - .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 ", `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("", "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("", "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. */ diff --git a/src/cli/program/register.tasks.test.ts b/src/cli/program/register.tasks.test.ts new file mode 100644 index 000000000000..e1d87363e765 --- /dev/null +++ b/src/cli/program/register.tasks.test.ts @@ -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(); + }); +}); diff --git a/src/cli/program/register.tasks.ts b/src/cli/program/register.tasks.ts new file mode 100644 index 000000000000..104e2edafba0 --- /dev/null +++ b/src/cli/program/register.tasks.ts @@ -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; +type TasksLeaf = keyof typeof TASKS_LEAF_OPTION_SUPPORT; + +function createModuleLoader(load: () => Promise): () => Promise { + let promise: Promise | undefined; + return () => (promise ??= load()); +} + +const loadTasksCommands = createModuleLoader(() => import("../../commands/tasks.js")); +const loadFlowsCommands = createModuleLoader(() => import("../../commands/flows.js")); + +async function runOwner(load: () => Promise, action: (owner: T) => Promise) { + await runCommandWithRuntime(defaultRuntime, async () => action(await load())); +} + +function addTasksListOptions(command: Command): Command { + return command + .option("--json", "Output as JSON", false) + .option("--runtime ", `Filter by kind (${TASK_RUNTIMES.join(", ")})`) + .option("--status ", `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 ", `Filter by severity (${TASK_SYSTEM_AUDIT_SEVERITIES.join(", ")})`) + .option("--code ", `Filter by finding code (${TASK_SYSTEM_AUDIT_CODES.join(", ")})`) + .option("--limit ", "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("", "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("", "Task id, run id, or session key") + .argument("", "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("", "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 ") + .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 ") + .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 ", `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("", "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("", "Flow id or owner key") + .action(async (lookup, _opts, command) => { + if (!resolveTasksLeafOptions(command, "flow cancel")) { + return; + } + await runOwner(loadFlowsCommands, ({ flowsCancelCommand }) => + flowsCancelCommand({ lookup }, defaultRuntime), + ); + }); +} diff --git a/src/cli/program/root-command-descriptions.test.ts b/src/cli/program/root-command-descriptions.test.ts index 431f6bedfa77..ccf5bac80580 100644 --- a/src/cli/program/root-command-descriptions.test.ts +++ b/src/cli/program/root-command-descriptions.test.ts @@ -94,7 +94,6 @@ const JSON_NOT_APPLICABLE = { "models image-fallbacks", "models auth", "models auth order", - "tasks flow", "skills workshop", ], },