refactor: move tasks into bundled plugin

This commit is contained in:
Peter Steinberger 2026-03-31 14:48:22 +01:00
parent 584db0aff2
commit c75f4695b7
No known key found for this signature in database
39 changed files with 2492 additions and 736 deletions

View File

@ -140,14 +140,15 @@ methods:
### Infrastructure
| Method | What it registers |
| ---------------------------------------------- | --------------------- |
| `api.registerHook(events, handler, opts?)` | Event hook |
| `api.registerHttpRoute(params)` | Gateway HTTP endpoint |
| `api.registerGatewayMethod(name, handler)` | Gateway RPC method |
| `api.registerCli(registrar, opts?)` | CLI subcommand |
| `api.registerService(service)` | Background service |
| `api.registerInteractiveHandler(registration)` | Interactive handler |
| Method | What it registers |
| ---------------------------------------------- | -------------------------- |
| `api.registerHook(events, handler, opts?)` | Event hook |
| `api.registerHttpRoute(params)` | Gateway HTTP endpoint |
| `api.registerGatewayMethod(name, handler)` | Gateway RPC method |
| `api.registerCli(registrar, opts?)` | CLI subcommand |
| `api.registerService(service)` | Background service |
| `api.registerInteractiveHandler(registration)` | Interactive handler |
| `api.registerOperationsRuntime(runtime)` | Durable operations runtime |
### CLI registration metadata

View File

@ -115,6 +115,40 @@ await api.runtime.subagent.deleteSession({
Untrusted plugins can still run subagents, but override requests are rejected.
</Warning>
### `api.runtime.operations`
Dispatch and query durable operation records behind a plugin-owned operations
runtime.
```typescript
const created = await api.runtime.operations.dispatch({
type: "create",
namespace: "imports",
kind: "csv",
status: "queued",
description: "Import contacts.csv",
runId: "import-1",
});
const progressed = await api.runtime.operations.dispatch({
type: "transition",
runId: "import-1",
status: "running",
progressSummary: "Parsing rows",
});
const record = await api.runtime.operations.findByRunId("import-1");
const list = await api.runtime.operations.list({ namespace: "imports" });
const summary = await api.runtime.operations.summarize({ namespace: "imports" });
```
Notes:
- `api.registerOperationsRuntime(...)` installs the active runtime.
- Core exposes the facade; plugins own the operation semantics and storage.
- The built-in default runtime maps the existing background task ledger into the
generic operations shape until a plugin overrides it.
### `api.runtime.tts`
Text-to-speech synthesis.

View File

@ -60,6 +60,7 @@ function fakeApi(overrides: Partial<OpenClawPluginApi> = {}): OpenClawPluginApi
registerMemoryFlushPlan() {},
registerMemoryRuntime() {},
registerMemoryEmbeddingProvider() {},
registerOperationsRuntime() {},
on() {},
resolvePath: (p) => p,
...overrides,

View File

@ -0,0 +1,29 @@
import { describe, expect, it, vi } from "vitest";
import { createTestPluginApi } from "../../test/helpers/plugins/plugin-api.js";
import { createPluginRuntimeMock } from "../../test/helpers/plugins/plugin-runtime-mock.js";
import tasksPlugin from "./index.js";
describe("tasks plugin", () => {
it("registers the default operations runtime, maintenance service, and CLI", () => {
const registerOperationsRuntime = vi.fn();
const registerService = vi.fn();
const registerCli = vi.fn();
tasksPlugin.register(
createTestPluginApi({
id: "tasks",
name: "Tasks",
source: "test",
config: {},
runtime: createPluginRuntimeMock(),
registerOperationsRuntime,
registerService,
registerCli,
}),
);
expect(registerOperationsRuntime).toHaveBeenCalledTimes(1);
expect(registerService).toHaveBeenCalledTimes(1);
expect(registerCli).toHaveBeenCalledTimes(1);
});
});

31
extensions/tasks/index.ts Normal file
View File

@ -0,0 +1,31 @@
import { createDefaultOperationsMaintenanceService } from "openclaw/plugin-sdk/operations-default";
import { defaultOperationsRuntime } from "openclaw/plugin-sdk/operations-default";
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { registerTasksCli } from "./src/cli.js";
export default definePluginEntry({
id: "tasks",
name: "Tasks",
description: "Durable task inspection and maintenance CLI",
register(api) {
api.registerOperationsRuntime(defaultOperationsRuntime);
api.registerService(createDefaultOperationsMaintenanceService());
api.registerCli(
({ program }) => {
registerTasksCli(program, {
config: api.config,
operations: api.runtime.operations,
});
},
{
descriptors: [
{
name: "tasks",
description: "Inspect durable background task state",
hasSubcommands: true,
},
],
},
);
},
});

View File

@ -0,0 +1,9 @@
{
"id": "tasks",
"enabledByDefault": true,
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

View File

@ -0,0 +1,12 @@
{
"name": "@openclaw/tasks",
"version": "2026.3.30",
"private": true,
"description": "OpenClaw durable tasks plugin",
"type": "module",
"openclaw": {
"extensions": [
"./index.ts"
]
}
}

View File

@ -0,0 +1,371 @@
import type {
PluginOperationAuditFinding,
PluginOperationRecord,
PluginOperationsRuntime,
} from "openclaw/plugin-sdk/plugin-entry";
import { createLoggerBackedRuntime, type OutputRuntimeEnv } from "openclaw/plugin-sdk/runtime";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
runTasksAudit,
runTasksCancel,
runTasksList,
runTasksMaintenance,
runTasksNotify,
runTasksShow,
} from "./cli.runtime.js";
function createRuntimeCapture() {
const logs: string[] = [];
const errors: string[] = [];
const runtime = createLoggerBackedRuntime({
logger: {
info(message) {
logs.push(message);
},
error(message) {
errors.push(message);
},
},
exitError(code) {
return new Error(`exit ${code}`);
},
}) as OutputRuntimeEnv;
return { runtime, logs, errors };
}
function createOperationsMock(): PluginOperationsRuntime {
return {
dispatch: vi.fn().mockResolvedValue({
matched: false,
record: null,
}),
getById: vi.fn().mockResolvedValue(null),
findByRunId: vi.fn().mockResolvedValue(null),
list: vi.fn().mockResolvedValue([]),
summarize: vi.fn().mockResolvedValue({
total: 0,
active: 0,
terminal: 0,
failures: 0,
byNamespace: {},
byKind: {},
byStatus: {},
}),
audit: vi.fn().mockResolvedValue([]),
maintenance: vi.fn().mockResolvedValue({
reconciled: 0,
cleanupStamped: 0,
pruned: 0,
}),
cancel: vi.fn().mockResolvedValue({
found: false,
cancelled: false,
}),
};
}
const taskFixture: PluginOperationRecord = {
operationId: "task-12345678",
namespace: "tasks",
kind: "acp",
status: "running",
sourceId: "run-12345678",
requesterSessionKey: "agent:main:main",
childSessionKey: "agent:codex:acp:child",
runId: "run-12345678",
title: "Task title",
description: "Create a file",
createdAt: Date.parse("2026-03-29T10:00:00.000Z"),
updatedAt: Date.parse("2026-03-29T10:00:10.000Z"),
progressSummary: "No output for 60s. It may be waiting for input.",
metadata: {
deliveryStatus: "pending",
notifyPolicy: "state_changes",
},
};
describe("tasks CLI runtime", () => {
let operations: PluginOperationsRuntime;
let logs: string[];
let errors: string[];
let runtime: OutputRuntimeEnv;
let config: import("openclaw/plugin-sdk/plugin-entry").OpenClawConfig;
beforeEach(() => {
operations = createOperationsMock();
({ runtime, logs, errors } = createRuntimeCapture());
config = {};
});
it("lists task rows with progress summary fallback", async () => {
vi.mocked(operations.list).mockResolvedValue([taskFixture]);
await runTasksList(
{
runtime: "acp",
status: "running",
},
{
config,
operations,
},
runtime,
);
expect(logs[0]).toContain("Background tasks: 1");
expect(logs[1]).toContain("Task pressure: 0 queued · 1 running · 0 issues");
expect(logs.join("\n")).toContain("No output for 60s. It may be waiting for input.");
});
it("shows detailed task fields including notify and recent events", async () => {
vi.mocked(operations.findByRunId).mockResolvedValue(taskFixture);
await runTasksShow(
{ lookup: "run-12345678" },
{
config: {},
operations,
},
runtime,
);
expect(logs.join("\n")).toContain("notify: state_changes");
expect(logs.join("\n")).toContain(
"progressSummary: No output for 60s. It may be waiting for input.",
);
});
it("updates notify policy for an existing task", async () => {
vi.mocked(operations.findByRunId).mockResolvedValue(taskFixture);
vi.mocked(operations.dispatch).mockResolvedValue({
matched: true,
record: {
...taskFixture,
metadata: {
...taskFixture.metadata,
notifyPolicy: "silent",
},
},
});
await runTasksNotify(
{ lookup: "run-12345678", notify: "silent" },
{
config: {},
operations,
},
runtime,
);
expect(operations.dispatch).toHaveBeenCalledWith({
type: "patch",
operationId: "task-12345678",
at: expect.any(Number),
metadataPatch: {
notifyPolicy: "silent",
},
});
expect(logs[0]).toContain("Updated task-12345678 notify policy to silent.");
});
it("cancels a running task and reports the updated runtime", async () => {
vi.mocked(operations.findByRunId).mockResolvedValue(taskFixture);
vi.mocked(operations.cancel).mockResolvedValue({
found: true,
cancelled: true,
record: {
...taskFixture,
status: "cancelled",
},
});
vi.mocked(operations.getById).mockResolvedValue({
...taskFixture,
status: "cancelled",
});
await runTasksCancel(
{ lookup: "run-12345678" },
{
config,
operations,
},
runtime,
);
expect(operations.cancel).toHaveBeenCalledWith({
cfg: config,
operationId: "task-12345678",
});
expect(logs[0]).toContain("Cancelled task-12345678 (acp) run run-12345678.");
expect(errors).toEqual([]);
});
it("shows task audit findings with filters", async () => {
const findings: PluginOperationAuditFinding[] = [
{
severity: "error",
code: "stale_running",
operation: taskFixture,
ageMs: 45 * 60_000,
detail: "running task appears stuck",
},
{
severity: "warn",
code: "delivery_failed",
operation: {
...taskFixture,
operationId: "task-87654321",
status: "failed",
},
ageMs: 10 * 60_000,
detail: "terminal update delivery failed",
},
];
vi.mocked(operations.audit)
.mockResolvedValueOnce(findings)
.mockResolvedValueOnce([findings[0]!]);
await runTasksAudit(
{ severity: "error", code: "stale_running", limit: 1 },
{
config: {},
operations,
},
runtime,
);
expect(logs[0]).toContain("Task audit: 2 findings · 1 errors · 1 warnings");
expect(logs[1]).toContain("Showing 1 matching findings.");
expect(logs.join("\n")).toContain("stale_running");
expect(logs.join("\n")).toContain("running task appears stuck");
expect(logs.join("\n")).not.toContain("delivery_failed");
});
it("previews task maintenance without applying changes", async () => {
vi.mocked(operations.audit).mockResolvedValue([
{
severity: "error",
code: "stale_running",
operation: taskFixture,
detail: "running task appears stuck",
},
{
severity: "warn",
code: "lost",
operation: {
...taskFixture,
operationId: "task-2",
status: "lost",
},
detail: "backing session missing",
},
]);
vi.mocked(operations.maintenance).mockResolvedValue({
reconciled: 2,
cleanupStamped: 1,
pruned: 3,
});
vi.mocked(operations.summarize).mockResolvedValue({
total: 5,
active: 2,
terminal: 3,
failures: 1,
byNamespace: { tasks: 5 },
byKind: { acp: 1, cron: 2, subagent: 1, cli: 1 },
byStatus: {
queued: 1,
running: 1,
succeeded: 1,
lost: 1,
failed: 1,
},
});
await runTasksMaintenance(
{},
{
config: {},
operations,
},
runtime,
);
expect(logs[0]).toContain(
"Task maintenance (preview): 2 reconcile · 1 cleanup stamp · 3 prune",
);
expect(logs[1]).toContain(
"Task health: 1 queued · 1 running · 1 audit errors · 1 audit warnings",
);
expect(logs[2]).toContain("Dry run only.");
});
it("shows before and after audit health when applying maintenance", async () => {
vi.mocked(operations.audit)
.mockResolvedValueOnce([
{
severity: "error",
code: "stale_running",
operation: taskFixture,
detail: "running task appears stuck",
},
{
severity: "warn",
code: "missing_cleanup",
operation: {
...taskFixture,
operationId: "task-2",
status: "succeeded",
},
detail: "missing cleanupAfter",
},
])
.mockResolvedValueOnce([
{
severity: "warn",
code: "lost",
operation: {
...taskFixture,
operationId: "task-2",
status: "lost",
},
detail: "backing session missing",
},
]);
vi.mocked(operations.maintenance).mockResolvedValue({
reconciled: 2,
cleanupStamped: 1,
pruned: 3,
});
vi.mocked(operations.summarize).mockResolvedValue({
total: 4,
active: 2,
terminal: 2,
failures: 1,
byNamespace: { tasks: 4 },
byKind: { acp: 1, cron: 2, subagent: 1 },
byStatus: {
queued: 1,
running: 1,
succeeded: 1,
lost: 1,
},
});
await runTasksMaintenance(
{ apply: true },
{
config: {},
operations,
},
runtime,
);
expect(logs[0]).toContain(
"Task maintenance (applied): 2 reconcile · 1 cleanup stamp · 3 prune",
);
expect(logs[1]).toContain(
"Task health after apply: 1 queued · 1 running · 0 audit errors · 1 audit warnings",
);
expect(logs[2]).toContain("Task health before apply: 1 audit errors · 1 audit warnings");
});
});

View File

@ -0,0 +1,464 @@
import type {
OpenClawConfig,
PluginOperationAuditFinding,
PluginOperationRecord,
PluginOperationsRuntime,
} from "openclaw/plugin-sdk/plugin-entry";
import { info, type RuntimeEnv } from "openclaw/plugin-sdk/runtime";
type TasksCliDeps = {
config: OpenClawConfig;
operations: PluginOperationsRuntime;
};
type TaskNotifyPolicy = "done_only" | "state_changes" | "silent";
const KIND_PAD = 8;
const STATUS_PAD = 10;
const DELIVERY_PAD = 14;
const ID_PAD = 10;
const RUN_PAD = 10;
function truncate(value: string, maxChars: number) {
if (value.length <= maxChars) {
return value;
}
if (maxChars <= 1) {
return value.slice(0, maxChars);
}
return `${value.slice(0, maxChars - 1)}`;
}
function shortToken(value: string | undefined, maxChars = ID_PAD): string {
const trimmed = value?.trim();
if (!trimmed) {
return "n/a";
}
return truncate(trimmed, maxChars);
}
function readStringMetadata(record: PluginOperationRecord, key: string): string | undefined {
const value = record.metadata?.[key];
return typeof value === "string" && value.trim() ? value : undefined;
}
function readNumberMetadata(record: PluginOperationRecord, key: string): number | undefined {
const value = record.metadata?.[key];
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
function formatTaskRows(tasks: PluginOperationRecord[]) {
const header = [
"Task".padEnd(ID_PAD),
"Kind".padEnd(KIND_PAD),
"Status".padEnd(STATUS_PAD),
"Delivery".padEnd(DELIVERY_PAD),
"Run".padEnd(RUN_PAD),
"Child Session".padEnd(36),
"Summary",
].join(" ");
const lines = [header];
for (const task of tasks) {
const summary = truncate(
task.terminalSummary?.trim() ||
task.progressSummary?.trim() ||
task.title?.trim() ||
task.description.trim(),
80,
);
const line = [
shortToken(task.operationId).padEnd(ID_PAD),
task.kind.padEnd(KIND_PAD),
task.status.padEnd(STATUS_PAD),
(readStringMetadata(task, "deliveryStatus") ?? "n/a").padEnd(DELIVERY_PAD),
shortToken(task.runId, RUN_PAD).padEnd(RUN_PAD),
truncate(task.childSessionKey?.trim() || "n/a", 36).padEnd(36),
summary,
].join(" ");
lines.push(line.trimEnd());
}
return lines;
}
function formatAgeMs(ageMs: number | undefined): string {
if (typeof ageMs !== "number" || ageMs < 1000) {
return "fresh";
}
const totalSeconds = Math.floor(ageMs / 1000);
const days = Math.floor(totalSeconds / 86_400);
const hours = Math.floor((totalSeconds % 86_400) / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
if (days > 0) {
return `${days}d${hours}h`;
}
if (hours > 0) {
return `${hours}h${minutes}m`;
}
if (minutes > 0) {
return `${minutes}m`;
}
return `${totalSeconds}s`;
}
function formatAuditRows(findings: PluginOperationAuditFinding[]) {
const header = [
"Severity".padEnd(8),
"Code".padEnd(22),
"Task".padEnd(ID_PAD),
"Status".padEnd(STATUS_PAD),
"Age".padEnd(8),
"Detail",
].join(" ");
const lines = [header];
for (const finding of findings) {
lines.push(
[
finding.severity.padEnd(8),
finding.code.padEnd(22),
shortToken(finding.operation.operationId).padEnd(ID_PAD),
finding.operation.status.padEnd(STATUS_PAD),
formatAgeMs(finding.ageMs).padEnd(8),
truncate(finding.detail, 88),
]
.join(" ")
.trimEnd(),
);
}
return lines;
}
function summarizeAuditFindings(findings: Iterable<PluginOperationAuditFinding>) {
const summary = {
total: 0,
warnings: 0,
errors: 0,
byCode: {} as Record<string, number>,
};
for (const finding of findings) {
summary.total += 1;
summary.byCode[finding.code] = (summary.byCode[finding.code] ?? 0) + 1;
if (finding.severity === "error") {
summary.errors += 1;
continue;
}
summary.warnings += 1;
}
return summary;
}
function formatTaskListSummary(tasks: PluginOperationRecord[]) {
const queued = tasks.filter((task) => task.status === "queued").length;
const running = tasks.filter((task) => task.status === "running").length;
const failures = tasks.filter(
(task) => task.status === "failed" || task.status === "timed_out" || task.status === "lost",
).length;
return `${queued} queued · ${running} running · ${failures} issues`;
}
async function resolveTaskLookupToken(
operations: PluginOperationsRuntime,
lookup: string,
): Promise<PluginOperationRecord | null> {
const token = lookup.trim();
if (!token) {
return null;
}
const byId = await operations.getById(token);
if (byId?.namespace === "tasks") {
return byId;
}
const byRunId = await operations.findByRunId(token);
if (byRunId?.namespace === "tasks") {
return byRunId;
}
const bySession = await operations.list({
namespace: "tasks",
sessionKey: token,
limit: 1,
});
return bySession[0] ?? null;
}
export async function runTasksList(
opts: { json?: boolean; runtime?: string; status?: string },
deps: TasksCliDeps,
runtime: RuntimeEnv,
) {
const tasks = await deps.operations.list({
namespace: "tasks",
...(opts.runtime ? { kind: opts.runtime.trim() } : {}),
...(opts.status ? { status: opts.status.trim() } : {}),
});
if (opts.json) {
runtime.log(
JSON.stringify(
{
count: tasks.length,
runtime: opts.runtime ?? null,
status: opts.status ?? null,
tasks,
},
null,
2,
),
);
return;
}
runtime.log(info(`Background tasks: ${tasks.length}`));
runtime.log(info(`Task pressure: ${formatTaskListSummary(tasks)}`));
if (opts.runtime) {
runtime.log(info(`Runtime filter: ${opts.runtime}`));
}
if (opts.status) {
runtime.log(info(`Status filter: ${opts.status}`));
}
if (tasks.length === 0) {
runtime.log("No background tasks found.");
return;
}
for (const line of formatTaskRows(tasks)) {
runtime.log(line);
}
}
export async function runTasksShow(
opts: { json?: boolean; lookup: string },
deps: TasksCliDeps,
runtime: RuntimeEnv,
) {
const task = await resolveTaskLookupToken(deps.operations, opts.lookup);
if (!task) {
runtime.error(`Task not found: ${opts.lookup}`);
runtime.exit(1);
return;
}
if (opts.json) {
runtime.log(JSON.stringify(task, null, 2));
return;
}
const lines = [
"Background task:",
`taskId: ${task.operationId}`,
`kind: ${task.kind}`,
`sourceId: ${task.sourceId ?? "n/a"}`,
`status: ${task.status}`,
`result: ${readStringMetadata(task, "terminalOutcome") ?? "n/a"}`,
`delivery: ${readStringMetadata(task, "deliveryStatus") ?? "n/a"}`,
`notify: ${readStringMetadata(task, "notifyPolicy") ?? "n/a"}`,
`requesterSessionKey: ${task.requesterSessionKey ?? "n/a"}`,
`childSessionKey: ${task.childSessionKey ?? "n/a"}`,
`parentTaskId: ${task.parentOperationId ?? "n/a"}`,
`agentId: ${task.agentId ?? "n/a"}`,
`runId: ${task.runId ?? "n/a"}`,
`label: ${task.title ?? "n/a"}`,
`task: ${task.description}`,
`createdAt: ${new Date(task.createdAt).toISOString()}`,
`startedAt: ${task.startedAt ? new Date(task.startedAt).toISOString() : "n/a"}`,
`endedAt: ${task.endedAt ? new Date(task.endedAt).toISOString() : "n/a"}`,
`lastEventAt: ${new Date(task.updatedAt).toISOString()}`,
`cleanupAfter: ${(() => {
const cleanupAfter = readNumberMetadata(task, "cleanupAfter");
return cleanupAfter ? new Date(cleanupAfter).toISOString() : "n/a";
})()}`,
...(task.error ? [`error: ${task.error}`] : []),
...(task.progressSummary ? [`progressSummary: ${task.progressSummary}`] : []),
...(task.terminalSummary ? [`terminalSummary: ${task.terminalSummary}`] : []),
];
for (const line of lines) {
runtime.log(line);
}
}
export async function runTasksNotify(
opts: { lookup: string; notify: TaskNotifyPolicy },
deps: TasksCliDeps,
runtime: RuntimeEnv,
) {
const task = await resolveTaskLookupToken(deps.operations, opts.lookup);
if (!task) {
runtime.error(`Task not found: ${opts.lookup}`);
runtime.exit(1);
return;
}
const updated = await deps.operations.dispatch({
type: "patch",
operationId: task.operationId,
at: Date.now(),
metadataPatch: {
notifyPolicy: opts.notify,
},
});
if (!updated.matched || !updated.record) {
runtime.error(`Task not found: ${opts.lookup}`);
runtime.exit(1);
return;
}
runtime.log(`Updated ${updated.record.operationId} notify policy to ${opts.notify}.`);
}
export async function runTasksCancel(
opts: { lookup: string },
deps: TasksCliDeps,
runtime: RuntimeEnv,
) {
const task = await resolveTaskLookupToken(deps.operations, opts.lookup);
if (!task) {
runtime.error(`Task not found: ${opts.lookup}`);
runtime.exit(1);
return;
}
const result = await deps.operations.cancel({
cfg: deps.config,
operationId: task.operationId,
});
if (!result.found) {
runtime.error(result.reason ?? `Task not found: ${opts.lookup}`);
runtime.exit(1);
return;
}
if (!result.cancelled) {
runtime.error(result.reason ?? `Could not cancel task: ${opts.lookup}`);
runtime.exit(1);
return;
}
const updated = await deps.operations.getById(task.operationId);
runtime.log(
`Cancelled ${updated?.operationId ?? task.operationId} (${updated?.kind ?? task.kind})${updated?.runId ? ` run ${updated.runId}` : ""}.`,
);
}
export async function runTasksAudit(
opts: {
json?: boolean;
severity?: "warn" | "error";
code?: string;
limit?: number;
},
deps: TasksCliDeps,
runtime: RuntimeEnv,
) {
const allFindings = await deps.operations.audit({
namespace: "tasks",
});
const findings = await deps.operations.audit({
namespace: "tasks",
...(opts.severity ? { severity: opts.severity } : {}),
...(opts.code ? { code: opts.code.trim() } : {}),
});
const displayed =
typeof opts.limit === "number" && opts.limit > 0 ? findings.slice(0, opts.limit) : findings;
const summary = summarizeAuditFindings(allFindings);
if (opts.json) {
runtime.log(
JSON.stringify(
{
count: allFindings.length,
filteredCount: findings.length,
displayed: displayed.length,
filters: {
severity: opts.severity ?? null,
code: opts.code ?? null,
limit: opts.limit ?? null,
},
summary,
findings: displayed,
},
null,
2,
),
);
return;
}
runtime.log(
info(
`Task audit: ${summary.total} findings · ${summary.errors} errors · ${summary.warnings} warnings`,
),
);
if (opts.severity || opts.code) {
runtime.log(info(`Showing ${findings.length} matching findings.`));
}
if (opts.severity) {
runtime.log(info(`Severity filter: ${opts.severity}`));
}
if (opts.code) {
runtime.log(info(`Code filter: ${opts.code}`));
}
if (typeof opts.limit === "number" && opts.limit > 0) {
runtime.log(info(`Limit: ${opts.limit}`));
}
if (displayed.length === 0) {
runtime.log("No task audit findings.");
return;
}
for (const line of formatAuditRows(displayed)) {
runtime.log(line);
}
}
export async function runTasksMaintenance(
opts: { json?: boolean; apply?: boolean },
deps: TasksCliDeps,
runtime: RuntimeEnv,
) {
const auditBeforeFindings = await deps.operations.audit({
namespace: "tasks",
});
const maintenance = await deps.operations.maintenance({
namespace: "tasks",
apply: Boolean(opts.apply),
});
const summary = await deps.operations.summarize({
namespace: "tasks",
});
const auditAfterFindings = opts.apply
? await deps.operations.audit({
namespace: "tasks",
})
: auditBeforeFindings;
const auditBefore = summarizeAuditFindings(auditBeforeFindings);
const auditAfter = summarizeAuditFindings(auditAfterFindings);
if (opts.json) {
runtime.log(
JSON.stringify(
{
mode: opts.apply ? "apply" : "preview",
maintenance,
tasks: summary,
auditBefore,
auditAfter,
},
null,
2,
),
);
return;
}
runtime.log(
info(
`Task maintenance (${opts.apply ? "applied" : "preview"}): ${maintenance.reconciled} reconcile · ${maintenance.cleanupStamped} cleanup stamp · ${maintenance.pruned} prune`,
),
);
runtime.log(
info(
`${opts.apply ? "Task health after apply" : "Task health"}: ${summary.byStatus.queued ?? 0} queued · ${summary.byStatus.running ?? 0} running · ${auditAfter.errors} audit errors · ${auditAfter.warnings} audit warnings`,
),
);
if (opts.apply) {
runtime.log(
info(
`Task health before apply: ${auditBefore.errors} audit errors · ${auditBefore.warnings} audit warnings`,
),
);
}
if (!opts.apply) {
runtime.log("Dry run only. Re-run with `openclaw tasks maintenance --apply` to write changes.");
}
}

162
extensions/tasks/src/cli.ts Normal file
View File

@ -0,0 +1,162 @@
import type { Command } from "commander";
import type { OpenClawConfig, PluginOperationsRuntime } from "openclaw/plugin-sdk/plugin-entry";
import { defaultRuntime } from "openclaw/plugin-sdk/runtime";
import {
runTasksAudit,
runTasksCancel,
runTasksList,
runTasksMaintenance,
runTasksNotify,
runTasksShow,
} from "./cli.runtime.js";
function parsePositiveIntOrUndefined(value: unknown): number | undefined {
if (typeof value !== "string" || !value.trim()) {
return undefined;
}
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
}
export function registerTasksCli(
program: Command,
deps: {
config: OpenClawConfig;
operations: PluginOperationsRuntime;
},
) {
const tasks = program
.command("tasks")
.description("Inspect durable background task state")
.option("--json", "Output as JSON", false)
.option("--runtime <name>", "Filter by kind (subagent, acp, cron, cli)")
.option(
"--status <name>",
"Filter by status (queued, running, succeeded, failed, timed_out, cancelled, lost)",
)
.action(async (opts) => {
await runTasksList(
{
json: Boolean(opts.json),
runtime: opts.runtime as string | undefined,
status: opts.status as string | undefined,
},
deps,
defaultRuntime,
);
});
tasks.enablePositionalOptions();
tasks
.command("list")
.description("List tracked background tasks")
.option("--json", "Output as JSON", false)
.option("--runtime <name>", "Filter by kind (subagent, acp, cron, cli)")
.option(
"--status <name>",
"Filter by status (queued, running, succeeded, failed, timed_out, cancelled, lost)",
)
.action(async (opts, command) => {
const parentOpts = command.parent?.opts() as
| {
json?: boolean;
runtime?: string;
status?: string;
}
| undefined;
await runTasksList(
{
json: Boolean(opts.json || parentOpts?.json),
runtime: (opts.runtime as string | undefined) ?? parentOpts?.runtime,
status: (opts.status as string | undefined) ?? parentOpts?.status,
},
deps,
defaultRuntime,
);
});
tasks
.command("audit")
.description("Show stale or broken background task runs")
.option("--json", "Output as JSON", false)
.option("--severity <level>", "Filter by severity (warn, error)")
.option("--code <name>", "Filter by finding code")
.option("--limit <n>", "Limit displayed findings")
.action(async (opts, command) => {
const parentOpts = command.parent?.opts() as { json?: boolean } | undefined;
await runTasksAudit(
{
json: Boolean(opts.json || parentOpts?.json),
severity: opts.severity as "warn" | "error" | undefined,
code: opts.code as string | undefined,
limit: parsePositiveIntOrUndefined(opts.limit),
},
deps,
defaultRuntime,
);
});
tasks
.command("maintenance")
.description("Preview or apply task ledger 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 runTasksMaintenance(
{
json: Boolean(opts.json || parentOpts?.json),
apply: Boolean(opts.apply),
},
deps,
defaultRuntime,
);
});
tasks
.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 runTasksShow(
{
lookup,
json: Boolean(opts.json || parentOpts?.json),
},
deps,
defaultRuntime,
);
});
tasks
.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 runTasksNotify(
{
lookup,
notify: notify as "done_only" | "state_changes" | "silent",
},
deps,
defaultRuntime,
);
});
tasks
.command("cancel")
.description("Cancel a running background task")
.argument("<lookup>", "Task id, run id, or session key")
.action(async (lookup) => {
await runTasksCancel(
{
lookup,
},
deps,
defaultRuntime,
);
});
}

View File

@ -63,14 +63,10 @@ describe("resolveTelegramExecApproval", () => {
id: "legacy-plugin-123",
decision: "allow-always",
});
expect(gatewayRuntimeHoisted.requestSpy).toHaveBeenNthCalledWith(
2,
"plugin.approval.resolve",
{
id: "legacy-plugin-123",
decision: "allow-always",
},
);
expect(gatewayRuntimeHoisted.requestSpy).toHaveBeenNthCalledWith(2, "plugin.approval.resolve", {
id: "legacy-plugin-123",
decision: "allow-always",
});
});
it("falls back to plugin.approval.resolve for structured approval-not-found errors", async () => {
@ -95,14 +91,10 @@ describe("resolveTelegramExecApproval", () => {
id: "legacy-plugin-123",
decision: "allow-always",
});
expect(gatewayRuntimeHoisted.requestSpy).toHaveBeenNthCalledWith(
2,
"plugin.approval.resolve",
{
id: "legacy-plugin-123",
decision: "allow-always",
},
);
expect(gatewayRuntimeHoisted.requestSpy).toHaveBeenNthCalledWith(2, "plugin.approval.resolve", {
id: "legacy-plugin-123",
decision: "allow-always",
});
});
it("does not fall back to plugin.approval.resolve without explicit permission", async () => {

View File

@ -185,6 +185,10 @@
"types": "./dist/plugin-sdk/plugin-runtime.d.ts",
"default": "./dist/plugin-sdk/plugin-runtime.js"
},
"./plugin-sdk/operations-default": {
"types": "./dist/plugin-sdk/operations-default.d.ts",
"default": "./dist/plugin-sdk/operations-default.js"
},
"./plugin-sdk/security-runtime": {
"types": "./dist/plugin-sdk/security-runtime.d.ts",
"default": "./dist/plugin-sdk/security-runtime.js"

View File

@ -36,6 +36,7 @@
"speech-runtime",
"speech-core",
"plugin-runtime",
"operations-default",
"security-runtime",
"gateway-runtime",
"github-copilot-login",

View File

@ -11,9 +11,8 @@ let sendExecApprovalFollowup: typeof import("./bash-tools.exec-approval-followup
beforeEach(async () => {
vi.resetModules();
({ callGatewayTool } = await import("./tools/gateway.js"));
({ buildExecApprovalFollowupPrompt, sendExecApprovalFollowup } = await import(
"./bash-tools.exec-approval-followup.js"
));
({ buildExecApprovalFollowupPrompt, sendExecApprovalFollowup } =
await import("./bash-tools.exec-approval-followup.js"));
});
afterEach(() => {

View File

@ -10,12 +10,6 @@ const mocks = vi.hoisted(() => ({
flowsCancelCommand: vi.fn(),
sessionsCommand: vi.fn(),
sessionsCleanupCommand: vi.fn(),
tasksListCommand: vi.fn(),
tasksAuditCommand: vi.fn(),
tasksMaintenanceCommand: vi.fn(),
tasksShowCommand: vi.fn(),
tasksNotifyCommand: vi.fn(),
tasksCancelCommand: vi.fn(),
setVerbose: vi.fn(),
runtime: {
log: vi.fn(),
@ -31,12 +25,6 @@ const flowsShowCommand = mocks.flowsShowCommand;
const flowsCancelCommand = mocks.flowsCancelCommand;
const sessionsCommand = mocks.sessionsCommand;
const sessionsCleanupCommand = mocks.sessionsCleanupCommand;
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 setVerbose = mocks.setVerbose;
const runtime = mocks.runtime;
@ -62,15 +50,6 @@ vi.mock("../../commands/sessions-cleanup.js", () => ({
sessionsCleanupCommand: mocks.sessionsCleanupCommand,
}));
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("../../globals.js", () => ({
setVerbose: mocks.setVerbose,
}));
@ -96,12 +75,6 @@ describe("registerStatusHealthSessionsCommands", () => {
flowsCancelCommand.mockResolvedValue(undefined);
sessionsCommand.mockResolvedValue(undefined);
sessionsCleanupCommand.mockResolvedValue(undefined);
tasksListCommand.mockResolvedValue(undefined);
tasksAuditCommand.mockResolvedValue(undefined);
tasksMaintenanceCommand.mockResolvedValue(undefined);
tasksShowCommand.mockResolvedValue(undefined);
tasksNotifyCommand.mockResolvedValue(undefined);
tasksCancelCommand.mockResolvedValue(undefined);
});
it("runs status command with timeout and debug-derived verbose", async () => {
@ -249,90 +222,6 @@ describe("registerStatusHealthSessionsCommands", () => {
);
});
it("runs tasks list from the parent command", async () => {
await runCli(["tasks", "--json", "--runtime", "acp", "--status", "running"]);
expect(tasksListCommand).toHaveBeenCalledWith(
expect.objectContaining({
json: true,
runtime: "acp",
status: "running",
}),
runtime,
);
});
it("runs tasks show subcommand with lookup forwarding", async () => {
await runCli(["tasks", "show", "run-123", "--json"]);
expect(tasksShowCommand).toHaveBeenCalledWith(
expect.objectContaining({
lookup: "run-123",
json: true,
}),
runtime,
);
});
it("runs tasks maintenance subcommand with apply forwarding", async () => {
await runCli(["tasks", "--json", "maintenance", "--apply"]);
expect(tasksMaintenanceCommand).toHaveBeenCalledWith(
expect.objectContaining({
json: true,
apply: true,
}),
runtime,
);
});
it("runs tasks audit subcommand with filters", async () => {
await runCli([
"tasks",
"--json",
"audit",
"--severity",
"error",
"--code",
"stale_running",
"--limit",
"5",
]);
expect(tasksAuditCommand).toHaveBeenCalledWith(
expect.objectContaining({
json: true,
severity: "error",
code: "stale_running",
limit: 5,
}),
runtime,
);
});
it("runs tasks notify subcommand with lookup and policy forwarding", async () => {
await runCli(["tasks", "notify", "run-123", "state_changes"]);
expect(tasksNotifyCommand).toHaveBeenCalledWith(
expect.objectContaining({
lookup: "run-123",
notify: "state_changes",
}),
runtime,
);
});
it("runs tasks cancel subcommand with lookup forwarding", async () => {
await runCli(["tasks", "cancel", "run-123"]);
expect(tasksCancelCommand).toHaveBeenCalledWith(
expect.objectContaining({
lookup: "run-123",
}),
runtime,
);
});
it("runs flows list from the parent command", async () => {
await runCli(["flows", "--json", "--status", "blocked"]);

View File

@ -4,28 +4,24 @@ import { healthCommand } from "../../commands/health.js";
import { sessionsCleanupCommand } from "../../commands/sessions-cleanup.js";
import { sessionsCommand } from "../../commands/sessions.js";
import { statusCommand } from "../../commands/status.js";
import {
tasksAuditCommand,
tasksCancelCommand,
tasksListCommand,
tasksMaintenanceCommand,
tasksNotifyCommand,
tasksShowCommand,
} from "../../commands/tasks.js";
import { setVerbose } from "../../globals.js";
import { defaultRuntime } from "../../runtime.js";
import { formatDocsLink } from "../../terminal/links.js";
import { theme } from "../../terminal/theme.js";
import { runCommandWithRuntime } from "../cli-utils.js";
import { formatHelpExamples } from "../help-format.js";
import { parsePositiveIntOrUndefined } from "./helpers.js";
function resolveVerbose(opts: { verbose?: boolean; debug?: boolean }): boolean {
return Boolean(opts.verbose || opts.debug);
}
function parseTimeoutMs(timeout: unknown): number | null | undefined {
const parsed = parsePositiveIntOrUndefined(timeout);
const parsedRaw =
typeof timeout === "string" && timeout.trim() ? Number.parseInt(timeout, 10) : undefined;
const parsed =
typeof parsedRaw === "number" && Number.isFinite(parsedRaw) && parsedRaw > 0
? parsedRaw
: undefined;
if (timeout !== undefined && parsed === undefined) {
defaultRuntime.error("--timeout must be a positive integer (milliseconds)");
defaultRuntime.exit(1);
@ -222,159 +218,6 @@ export function registerStatusHealthSessionsCommands(program: Command) {
);
});
});
const tasksCmd = program
.command("tasks")
.description("Inspect durable background task state")
.option("--json", "Output as JSON", false)
.option("--runtime <name>", "Filter by kind (subagent, acp, cron, cli)")
.option(
"--status <name>",
"Filter by status (queued, running, succeeded, failed, timed_out, cancelled, lost)",
)
.action(async (opts) => {
await runCommandWithRuntime(defaultRuntime, async () => {
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 (subagent, acp, cron, cli)")
.option(
"--status <name>",
"Filter by status (queued, running, succeeded, failed, timed_out, cancelled, lost)",
)
.action(async (opts, command) => {
const parentOpts = command.parent?.opts() as
| {
json?: boolean;
runtime?: string;
status?: string;
}
| undefined;
await runCommandWithRuntime(defaultRuntime, async () => {
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 task runs")
.option("--json", "Output as JSON", false)
.option("--severity <level>", "Filter by severity (warn, error)")
.option(
"--code <name>",
"Filter by finding code (stale_queued, stale_running, lost, delivery_failed, missing_cleanup, inconsistent_timestamps)",
)
.option("--limit <n>", "Limit displayed findings")
.action(async (opts, command) => {
const parentOpts = command.parent?.opts() as { json?: boolean } | undefined;
await runCommandWithRuntime(defaultRuntime, async () => {
await tasksAuditCommand(
{
json: Boolean(opts.json || parentOpts?.json),
severity: opts.severity as "warn" | "error" | undefined,
code: opts.code as
| "stale_queued"
| "stale_running"
| "lost"
| "delivery_failed"
| "missing_cleanup"
| "inconsistent_timestamps"
| undefined,
limit: parsePositiveIntOrUndefined(opts.limit),
},
defaultRuntime,
);
});
});
tasksCmd
.command("maintenance")
.description("Preview or apply task ledger 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 () => {
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 () => {
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 () => {
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 () => {
await tasksCancelCommand(
{
lookup,
},
defaultRuntime,
);
});
});
const flowsCmd = program
.command("flows")
.description("Inspect ClawFlow state")

View File

@ -1,424 +0,0 @@
import { loadConfig } from "../config/config.js";
import { info } from "../globals.js";
import type { RuntimeEnv } from "../runtime.js";
import {
listTaskAuditFindings,
summarizeTaskAuditFindings,
type TaskAuditCode,
type TaskAuditFinding,
type TaskAuditSeverity,
} from "../tasks/task-registry.audit.js";
import { cancelTaskById, getTaskById, updateTaskNotifyPolicyById } from "../tasks/task-registry.js";
import {
getInspectableTaskAuditSummary,
getInspectableTaskRegistrySummary,
previewTaskRegistryMaintenance,
runTaskRegistryMaintenance,
} from "../tasks/task-registry.maintenance.js";
import {
reconcileInspectableTasks,
reconcileTaskLookupToken,
} from "../tasks/task-registry.reconcile.js";
import { summarizeTaskRecords } from "../tasks/task-registry.summary.js";
import type { TaskNotifyPolicy, TaskRecord } from "../tasks/task-registry.types.js";
import { isRich, theme } from "../terminal/theme.js";
const RUNTIME_PAD = 8;
const STATUS_PAD = 10;
const DELIVERY_PAD = 14;
const ID_PAD = 10;
const RUN_PAD = 10;
function truncate(value: string, maxChars: number) {
if (value.length <= maxChars) {
return value;
}
if (maxChars <= 1) {
return value.slice(0, maxChars);
}
return `${value.slice(0, maxChars - 1)}`;
}
function shortToken(value: string | undefined, maxChars = ID_PAD): string {
const trimmed = value?.trim();
if (!trimmed) {
return "n/a";
}
return truncate(trimmed, maxChars);
}
function formatTaskStatusCell(status: string, rich: boolean) {
const padded = status.padEnd(STATUS_PAD);
if (!rich) {
return padded;
}
if (status === "succeeded") {
return theme.success(padded);
}
if (status === "failed" || status === "lost" || status === "timed_out") {
return theme.error(padded);
}
if (status === "running") {
return theme.accentBright(padded);
}
return theme.muted(padded);
}
function formatTaskRows(tasks: TaskRecord[], rich: boolean) {
const header = [
"Task".padEnd(ID_PAD),
"Kind".padEnd(RUNTIME_PAD),
"Status".padEnd(STATUS_PAD),
"Delivery".padEnd(DELIVERY_PAD),
"Run".padEnd(RUN_PAD),
"Child Session",
"Summary",
].join(" ");
const lines = [rich ? theme.heading(header) : header];
for (const task of tasks) {
const summary = truncate(
task.terminalSummary?.trim() ||
task.progressSummary?.trim() ||
task.label?.trim() ||
task.task.trim(),
80,
);
const line = [
shortToken(task.taskId).padEnd(ID_PAD),
task.runtime.padEnd(RUNTIME_PAD),
formatTaskStatusCell(task.status, rich),
task.deliveryStatus.padEnd(DELIVERY_PAD),
shortToken(task.runId, RUN_PAD).padEnd(RUN_PAD),
truncate(task.childSessionKey?.trim() || "n/a", 36).padEnd(36),
summary,
].join(" ");
lines.push(line.trimEnd());
}
return lines;
}
function formatTaskListSummary(tasks: TaskRecord[]) {
const summary = summarizeTaskRecords(tasks);
return `${summary.byStatus.queued} queued · ${summary.byStatus.running} running · ${summary.failures} issues`;
}
function formatAgeMs(ageMs: number | undefined): string {
if (typeof ageMs !== "number" || ageMs < 1000) {
return "fresh";
}
const totalSeconds = Math.floor(ageMs / 1000);
const days = Math.floor(totalSeconds / 86_400);
const hours = Math.floor((totalSeconds % 86_400) / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
if (days > 0) {
return `${days}d${hours}h`;
}
if (hours > 0) {
return `${hours}h${minutes}m`;
}
if (minutes > 0) {
return `${minutes}m`;
}
return `${totalSeconds}s`;
}
function formatAuditRows(findings: TaskAuditFinding[], rich: boolean) {
const header = [
"Severity".padEnd(8),
"Code".padEnd(22),
"Task".padEnd(ID_PAD),
"Status".padEnd(STATUS_PAD),
"Age".padEnd(8),
"Detail",
].join(" ");
const lines = [rich ? theme.heading(header) : header];
for (const finding of findings) {
const severity = finding.severity.padEnd(8);
const status = formatTaskStatusCell(finding.task.status, rich);
const severityCell = !rich
? severity
: finding.severity === "error"
? theme.error(severity)
: theme.warn(severity);
lines.push(
[
severityCell,
finding.code.padEnd(22),
shortToken(finding.task.taskId).padEnd(ID_PAD),
status,
formatAgeMs(finding.ageMs).padEnd(8),
truncate(finding.detail, 88),
]
.join(" ")
.trimEnd(),
);
}
return lines;
}
export async function tasksListCommand(
opts: { json?: boolean; runtime?: string; status?: string },
runtime: RuntimeEnv,
) {
const runtimeFilter = opts.runtime?.trim();
const statusFilter = opts.status?.trim();
const tasks = reconcileInspectableTasks().filter((task) => {
if (runtimeFilter && task.runtime !== runtimeFilter) {
return false;
}
if (statusFilter && task.status !== statusFilter) {
return false;
}
return true;
});
if (opts.json) {
runtime.log(
JSON.stringify(
{
count: tasks.length,
runtime: runtimeFilter ?? null,
status: statusFilter ?? null,
tasks,
},
null,
2,
),
);
return;
}
runtime.log(info(`Background tasks: ${tasks.length}`));
runtime.log(info(`Task pressure: ${formatTaskListSummary(tasks)}`));
if (runtimeFilter) {
runtime.log(info(`Runtime filter: ${runtimeFilter}`));
}
if (statusFilter) {
runtime.log(info(`Status filter: ${statusFilter}`));
}
if (tasks.length === 0) {
runtime.log("No background tasks found.");
return;
}
const rich = isRich();
for (const line of formatTaskRows(tasks, rich)) {
runtime.log(line);
}
}
export async function tasksShowCommand(
opts: { json?: boolean; lookup: string },
runtime: RuntimeEnv,
) {
const task = reconcileTaskLookupToken(opts.lookup);
if (!task) {
runtime.error(`Task not found: ${opts.lookup}`);
runtime.exit(1);
return;
}
if (opts.json) {
runtime.log(JSON.stringify(task, null, 2));
return;
}
const lines = [
"Background task:",
`taskId: ${task.taskId}`,
`kind: ${task.runtime}`,
`sourceId: ${task.sourceId ?? "n/a"}`,
`status: ${task.status}`,
`result: ${task.terminalOutcome ?? "n/a"}`,
`delivery: ${task.deliveryStatus}`,
`notify: ${task.notifyPolicy}`,
`requesterSessionKey: ${task.requesterSessionKey}`,
`childSessionKey: ${task.childSessionKey ?? "n/a"}`,
`parentTaskId: ${task.parentTaskId ?? "n/a"}`,
`agentId: ${task.agentId ?? "n/a"}`,
`runId: ${task.runId ?? "n/a"}`,
`label: ${task.label ?? "n/a"}`,
`task: ${task.task}`,
`createdAt: ${new Date(task.createdAt).toISOString()}`,
`startedAt: ${task.startedAt ? new Date(task.startedAt).toISOString() : "n/a"}`,
`endedAt: ${task.endedAt ? new Date(task.endedAt).toISOString() : "n/a"}`,
`lastEventAt: ${task.lastEventAt ? new Date(task.lastEventAt).toISOString() : "n/a"}`,
`cleanupAfter: ${task.cleanupAfter ? new Date(task.cleanupAfter).toISOString() : "n/a"}`,
...(task.error ? [`error: ${task.error}`] : []),
...(task.progressSummary ? [`progressSummary: ${task.progressSummary}`] : []),
...(task.terminalSummary ? [`terminalSummary: ${task.terminalSummary}`] : []),
];
for (const line of lines) {
runtime.log(line);
}
}
export async function tasksNotifyCommand(
opts: { lookup: string; notify: TaskNotifyPolicy },
runtime: RuntimeEnv,
) {
const task = reconcileTaskLookupToken(opts.lookup);
if (!task) {
runtime.error(`Task not found: ${opts.lookup}`);
runtime.exit(1);
return;
}
const updated = updateTaskNotifyPolicyById({
taskId: task.taskId,
notifyPolicy: opts.notify,
});
if (!updated) {
runtime.error(`Task not found: ${opts.lookup}`);
runtime.exit(1);
return;
}
runtime.log(`Updated ${updated.taskId} notify policy to ${updated.notifyPolicy}.`);
}
export async function tasksCancelCommand(opts: { lookup: string }, runtime: RuntimeEnv) {
const task = reconcileTaskLookupToken(opts.lookup);
if (!task) {
runtime.error(`Task not found: ${opts.lookup}`);
runtime.exit(1);
return;
}
const result = await cancelTaskById({
cfg: loadConfig(),
taskId: task.taskId,
});
if (!result.found) {
runtime.error(result.reason ?? `Task not found: ${opts.lookup}`);
runtime.exit(1);
return;
}
if (!result.cancelled) {
runtime.error(result.reason ?? `Could not cancel task: ${opts.lookup}`);
runtime.exit(1);
return;
}
const updated = getTaskById(task.taskId);
runtime.log(
`Cancelled ${updated?.taskId ?? task.taskId} (${updated?.runtime ?? task.runtime})${updated?.runId ? ` run ${updated.runId}` : ""}.`,
);
}
export async function tasksAuditCommand(
opts: {
json?: boolean;
severity?: TaskAuditSeverity;
code?: TaskAuditCode;
limit?: number;
},
runtime: RuntimeEnv,
) {
const severityFilter = opts.severity?.trim() as TaskAuditSeverity | undefined;
const codeFilter = opts.code?.trim() as TaskAuditCode | undefined;
const allFindings = listTaskAuditFindings();
const findings = allFindings.filter((finding) => {
if (severityFilter && finding.severity !== severityFilter) {
return false;
}
if (codeFilter && finding.code !== codeFilter) {
return false;
}
return true;
});
const limit = typeof opts.limit === "number" && opts.limit > 0 ? opts.limit : undefined;
const displayed = limit ? findings.slice(0, limit) : findings;
const summary = summarizeTaskAuditFindings(allFindings);
if (opts.json) {
runtime.log(
JSON.stringify(
{
count: allFindings.length,
filteredCount: findings.length,
displayed: displayed.length,
filters: {
severity: severityFilter ?? null,
code: codeFilter ?? null,
limit: limit ?? null,
},
summary,
findings: displayed,
},
null,
2,
),
);
return;
}
runtime.log(
info(
`Task audit: ${summary.total} findings · ${summary.errors} errors · ${summary.warnings} warnings`,
),
);
if (severityFilter || codeFilter) {
runtime.log(info(`Showing ${findings.length} matching findings.`));
}
if (severityFilter) {
runtime.log(info(`Severity filter: ${severityFilter}`));
}
if (codeFilter) {
runtime.log(info(`Code filter: ${codeFilter}`));
}
if (limit) {
runtime.log(info(`Limit: ${limit}`));
}
if (displayed.length === 0) {
runtime.log("No task audit findings.");
return;
}
const rich = isRich();
for (const line of formatAuditRows(displayed, rich)) {
runtime.log(line);
}
}
export async function tasksMaintenanceCommand(
opts: { json?: boolean; apply?: boolean },
runtime: RuntimeEnv,
) {
const auditBefore = getInspectableTaskAuditSummary();
const maintenance = opts.apply ? runTaskRegistryMaintenance() : previewTaskRegistryMaintenance();
const summary = getInspectableTaskRegistrySummary();
const auditAfter = opts.apply ? getInspectableTaskAuditSummary() : auditBefore;
if (opts.json) {
runtime.log(
JSON.stringify(
{
mode: opts.apply ? "apply" : "preview",
maintenance,
tasks: summary,
auditBefore,
auditAfter,
},
null,
2,
),
);
return;
}
runtime.log(
info(
`Task maintenance (${opts.apply ? "applied" : "preview"}): ${maintenance.reconciled} reconcile · ${maintenance.cleanupStamped} cleanup stamp · ${maintenance.pruned} prune`,
),
);
runtime.log(
info(
`${opts.apply ? "Task health after apply" : "Task health"}: ${summary.byStatus.queued} queued · ${summary.byStatus.running} running · ${auditAfter.errors} audit errors · ${auditAfter.warnings} audit warnings`,
),
);
if (opts.apply) {
runtime.log(
info(
`Task health before apply: ${auditBefore.errors} audit errors · ${auditBefore.warnings} audit warnings`,
),
);
}
if (!opts.apply) {
runtime.log("Dry run only. Re-run with `openclaw tasks maintenance --apply` to write changes.");
}
}

View File

@ -75,10 +75,7 @@ import {
} from "../secrets/runtime.js";
import { onSessionLifecycleEvent } from "../sessions/session-lifecycle-events.js";
import { onSessionTranscriptUpdate } from "../sessions/transcript-events.js";
import {
getInspectableTaskRegistrySummary,
startTaskRegistryMaintenance,
} from "../tasks/task-registry.maintenance.js";
import { getInspectableTaskRegistrySummary } from "../tasks/task-registry.maintenance.js";
import { runSetupWizard } from "../wizard/setup.js";
import { createAuthRateLimiter, type AuthRateLimiter } from "./auth-rate-limit.js";
import { startChannelHealthMonitor } from "./channel-health-monitor.js";
@ -897,7 +894,6 @@ export async function startGatewayServer(
});
if (!minimalTestGateway) {
startTaskRegistryMaintenance();
({ tickInterval, healthInterval, dedupeCleanup, mediaCleanup } =
startGatewayMaintenanceTimers({
broadcast,

View File

@ -41,10 +41,7 @@ export type ExecApprovalChannelRuntimeAdapter<
resolved: TResolved;
entries: TPending[];
}) => Promise<void>;
finalizeExpired?: (params: {
request: TRequest;
entries: TPending[];
}) => Promise<void>;
finalizeExpired?: (params: { request: TRequest; entries: TPending[] }) => Promise<void>;
nowMs?: () => number;
};

View File

@ -148,7 +148,8 @@ export function parseExecApprovalCommandText(
const rawDecision = match[2].toLowerCase();
return {
approvalId: match[1],
decision: rawDecision === "always" ? "allow-always" : (rawDecision as ExecApprovalReplyDecision),
decision:
rawDecision === "always" ? "allow-always" : (rawDecision as ExecApprovalReplyDecision),
};
}

View File

@ -0,0 +1,14 @@
import type { OpenClawPluginService } from "../plugins/types.js";
import { defaultTaskOperationsRuntime } from "../tasks/operations-runtime.js";
import { startTaskRegistryMaintenance } from "../tasks/task-registry.maintenance.js";
export const defaultOperationsRuntime = defaultTaskOperationsRuntime;
export function createDefaultOperationsMaintenanceService(): OpenClawPluginService {
return {
id: "default-operations-maintenance",
start() {
startTaskRegistryMaintenance();
},
};
}

View File

@ -1,5 +1,21 @@
import type { OpenClawConfig } from "../config/config.js";
import { emptyPluginConfigSchema } from "../plugins/config-schema.js";
import type {
PluginOperationAuditFinding,
PluginOperationAuditQuery,
PluginOperationAuditSeverity,
PluginOperationAuditSummary,
PluginOperationDispatchEvent,
PluginOperationDispatchResult,
PluginOperationListQuery,
PluginOperationMaintenanceQuery,
PluginOperationMaintenanceSummary,
PluginOperationPatchEvent,
PluginOperationRecord,
PluginOperationSummary,
PluginOperationsCancelResult,
PluginOperationsRuntime,
} from "../plugins/operations-state.js";
import type {
AnyAgentTool,
MediaUnderstandingProviderPlugin,
@ -96,6 +112,20 @@ export type {
OpenClawPluginDefinition,
PluginLogger,
PluginInteractiveTelegramHandlerContext,
PluginOperationAuditFinding,
PluginOperationAuditQuery,
PluginOperationAuditSeverity,
PluginOperationAuditSummary,
PluginOperationDispatchEvent,
PluginOperationDispatchResult,
PluginOperationListQuery,
PluginOperationMaintenanceQuery,
PluginOperationMaintenanceSummary,
PluginOperationPatchEvent,
PluginOperationRecord,
PluginOperationSummary,
PluginOperationsCancelResult,
PluginOperationsRuntime,
};
export type { OpenClawConfig };

View File

@ -39,6 +39,7 @@ export type BuildPluginApiParams = {
| "registerMemoryFlushPlan"
| "registerMemoryRuntime"
| "registerMemoryEmbeddingProvider"
| "registerOperationsRuntime"
| "on"
>
>;
@ -69,6 +70,7 @@ const noopRegisterMemoryFlushPlan: OpenClawPluginApi["registerMemoryFlushPlan"]
const noopRegisterMemoryRuntime: OpenClawPluginApi["registerMemoryRuntime"] = () => {};
const noopRegisterMemoryEmbeddingProvider: OpenClawPluginApi["registerMemoryEmbeddingProvider"] =
() => {};
const noopRegisterOperationsRuntime: OpenClawPluginApi["registerOperationsRuntime"] = () => {};
const noopOn: OpenClawPluginApi["on"] = () => {};
export function buildPluginApi(params: BuildPluginApiParams): OpenClawPluginApi {
@ -112,6 +114,7 @@ export function buildPluginApi(params: BuildPluginApiParams): OpenClawPluginApi
registerMemoryRuntime: handlers.registerMemoryRuntime ?? noopRegisterMemoryRuntime,
registerMemoryEmbeddingProvider:
handlers.registerMemoryEmbeddingProvider ?? noopRegisterMemoryEmbeddingProvider,
registerOperationsRuntime: handlers.registerOperationsRuntime ?? noopRegisterOperationsRuntime,
resolvePath: params.resolvePath,
on: handlers.on ?? noopOn,
};

View File

@ -48,5 +48,6 @@ describe("captured plugin registration", () => {
expect(captured.tools.map((tool) => tool.name)).toEqual(["captured-tool"]);
expect(captured.providers.map((provider) => provider.id)).toEqual(["captured-provider"]);
expect(captured.api.registerMemoryEmbeddingProvider).toBeTypeOf("function");
expect(captured.api.registerOperationsRuntime).toBeTypeOf("function");
});
});

View File

@ -30,6 +30,10 @@ import {
registerMemoryRuntime,
resolveMemoryFlushPlan,
} from "./memory-state.js";
import {
getRegisteredOperationsRuntime,
registerOperationsRuntimeForOwner,
} from "./operations-state.js";
import { createEmptyPluginRegistry } from "./registry.js";
import {
getActivePluginRegistry,
@ -1461,6 +1465,181 @@ module.exports = { id: "skipped-scoped-only", register() { throw new Error("skip
expect(listMemoryEmbeddingProviders()).toEqual([]);
});
it("restores the active operations runtime during snapshot loads", () => {
const activeRuntime = {
async dispatch() {
return { matched: true, created: true, record: null };
},
async getById() {
return null;
},
async findByRunId() {
return null;
},
async list() {
return [];
},
async summarize() {
return {
total: 0,
active: 0,
terminal: 0,
failures: 0,
byNamespace: { active: 0 },
byKind: {},
byStatus: {},
};
},
async audit() {
return [];
},
async maintenance() {
return {
reconciled: 0,
cleanupStamped: 0,
pruned: 0,
};
},
async cancel() {
return { found: false, cancelled: false, reason: "active" };
},
};
registerOperationsRuntimeForOwner(activeRuntime, "active-operations");
const plugin = writePlugin({
id: "snapshot-operations",
filename: "snapshot-operations.cjs",
body: `module.exports = {
id: "snapshot-operations",
register(api) {
api.registerOperationsRuntime({
async dispatch() {
return { matched: true, created: true, record: null };
},
async getById() {
return null;
},
async findByRunId() {
return null;
},
async list() {
return [];
},
async summarize() {
return {
total: 1,
active: 1,
terminal: 0,
failures: 0,
byNamespace: { snapshot: 1 },
byKind: { snapshot: 1 },
byStatus: { queued: 1 },
};
},
async audit() {
return [];
},
async maintenance() {
return {
reconciled: 0,
cleanupStamped: 0,
pruned: 0,
};
},
async cancel() {
return { found: false, cancelled: false, reason: "snapshot" };
},
});
},
};`,
});
const scoped = loadOpenClawPlugins({
cache: false,
activate: false,
workspaceDir: plugin.dir,
config: {
plugins: {
load: { paths: [plugin.file] },
allow: ["snapshot-operations"],
},
},
onlyPluginIds: ["snapshot-operations"],
});
expect(scoped.plugins.find((entry) => entry.id === "snapshot-operations")?.status).toBe(
"loaded",
);
expect(getRegisteredOperationsRuntime()).toBe(activeRuntime);
});
it("clears newly-registered operations runtime when plugin register fails", () => {
const plugin = writePlugin({
id: "failing-operations",
filename: "failing-operations.cjs",
body: `module.exports = {
id: "failing-operations",
register(api) {
api.registerOperationsRuntime({
async dispatch() {
return { matched: true, created: true, record: null };
},
async getById() {
return null;
},
async findByRunId() {
return null;
},
async list() {
return [];
},
async summarize() {
return {
total: 1,
active: 1,
terminal: 0,
failures: 0,
byNamespace: { failing: 1 },
byKind: { failing: 1 },
byStatus: { queued: 1 },
};
},
async audit() {
return [];
},
async maintenance() {
return {
reconciled: 0,
cleanupStamped: 0,
pruned: 0,
};
},
async cancel() {
return { found: false, cancelled: false, reason: "failing" };
},
});
throw new Error("operations register failed");
},
};`,
});
const registry = loadOpenClawPlugins({
cache: false,
workspaceDir: plugin.dir,
config: {
plugins: {
load: { paths: [plugin.file] },
allow: ["failing-operations"],
},
},
onlyPluginIds: ["failing-operations"],
});
expect(registry.plugins.find((entry) => entry.id === "failing-operations")?.status).toBe(
"error",
);
expect(getRegisteredOperationsRuntime()).toBeUndefined();
});
it("throws when activate:false is used without cache:false", () => {
expect(() => loadOpenClawPlugins({ activate: false })).toThrow(
"activate:false requires cache:false",

View File

@ -35,6 +35,12 @@ import {
getMemoryRuntime,
restoreMemoryPluginState,
} from "./memory-state.js";
import {
clearOperationsRuntimeState,
getRegisteredOperationsRuntime,
getRegisteredOperationsRuntimeOwner,
restoreOperationsRuntimeState,
} from "./operations-state.js";
import { isPathInside, safeStatSync } from "./path-safety.js";
import { createPluginRegistry, type PluginRecord, type PluginRegistry } from "./registry.js";
import { resolvePluginCacheInputs } from "./roots.js";
@ -116,6 +122,8 @@ type CachedPluginState = {
memoryFlushPlanResolver: ReturnType<typeof getMemoryFlushPlanResolver>;
memoryPromptBuilder: ReturnType<typeof getMemoryPromptSectionBuilder>;
memoryRuntime: ReturnType<typeof getMemoryRuntime>;
operationsRuntime: ReturnType<typeof getRegisteredOperationsRuntime>;
operationsRuntimeOwner: ReturnType<typeof getRegisteredOperationsRuntimeOwner>;
};
const MAX_PLUGIN_REGISTRY_CACHE_ENTRIES = 128;
@ -136,6 +144,7 @@ const LAZY_RUNTIME_REFLECTION_KEYS = [
"logging",
"state",
"modelAuth",
"operations",
] as const satisfies readonly (keyof PluginRuntime)[];
export function clearPluginLoaderCache(): void {
@ -143,6 +152,7 @@ export function clearPluginLoaderCache(): void {
openAllowlistWarningCache.clear();
clearMemoryEmbeddingProviders();
clearMemoryPluginState();
clearOperationsRuntimeState();
}
const defaultLogger = () => createSubsystemLogger("plugins");
@ -843,6 +853,10 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
flushPlanResolver: cached.memoryFlushPlanResolver,
runtime: cached.memoryRuntime,
});
restoreOperationsRuntimeState({
runtime: cached.operationsRuntime,
ownerPluginId: cached.operationsRuntimeOwner,
});
if (shouldActivate) {
activatePluginRegistry(cached.registry, cacheKey, runtimeSubagentMode);
}
@ -1336,6 +1350,8 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
const previousMemoryFlushPlanResolver = getMemoryFlushPlanResolver();
const previousMemoryPromptBuilder = getMemoryPromptSectionBuilder();
const previousMemoryRuntime = getMemoryRuntime();
const previousOperationsRuntime = getRegisteredOperationsRuntime();
const previousOperationsRuntimeOwner = getRegisteredOperationsRuntimeOwner();
try {
const result = register(api);
@ -1355,6 +1371,10 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
flushPlanResolver: previousMemoryFlushPlanResolver,
runtime: previousMemoryRuntime,
});
restoreOperationsRuntimeState({
runtime: previousOperationsRuntime,
ownerPluginId: previousOperationsRuntimeOwner,
});
}
registry.plugins.push(record);
seenIds.set(pluginId, candidate.origin);
@ -1365,6 +1385,10 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
flushPlanResolver: previousMemoryFlushPlanResolver,
runtime: previousMemoryRuntime,
});
restoreOperationsRuntimeState({
runtime: previousOperationsRuntime,
ownerPluginId: previousOperationsRuntimeOwner,
});
recordPluginError({
logger,
registry,
@ -1404,6 +1428,8 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
memoryFlushPlanResolver: getMemoryFlushPlanResolver(),
memoryPromptBuilder: getMemoryPromptSectionBuilder(),
memoryRuntime: getMemoryRuntime(),
operationsRuntime: getRegisteredOperationsRuntime(),
operationsRuntimeOwner: getRegisteredOperationsRuntimeOwner(),
});
}
if (shouldActivate) {

View File

@ -0,0 +1,134 @@
import { describe, expect, it } from "vitest";
import {
clearOperationsRuntimeState,
getRegisteredOperationsRuntime,
getRegisteredOperationsRuntimeOwner,
registerOperationsRuntimeForOwner,
restoreOperationsRuntimeState,
summarizeOperationRecords,
type PluginOperationsRuntime,
} from "./operations-state.js";
function createRuntime(label: string): PluginOperationsRuntime {
return {
async dispatch() {
return { matched: true, created: true, record: null };
},
async getById() {
return null;
},
async findByRunId() {
return null;
},
async list() {
return [];
},
async summarize() {
return {
total: 0,
active: 0,
terminal: 0,
failures: 0,
byNamespace: { [label]: 0 },
byKind: {},
byStatus: {},
};
},
async audit() {
return [];
},
async maintenance() {
return {
reconciled: 0,
cleanupStamped: 0,
pruned: 0,
};
},
async cancel() {
return { found: false, cancelled: false, reason: label };
},
};
}
describe("operations-state", () => {
it("registers an operations runtime and tracks the owner", () => {
clearOperationsRuntimeState();
const runtime = createRuntime("one");
expect(registerOperationsRuntimeForOwner(runtime, "plugin-one")).toEqual({ ok: true });
expect(getRegisteredOperationsRuntime()).toBe(runtime);
expect(getRegisteredOperationsRuntimeOwner()).toBe("plugin-one");
});
it("rejects a second owner and allows same-owner refresh", () => {
clearOperationsRuntimeState();
const first = createRuntime("one");
const second = createRuntime("two");
const replacement = createRuntime("three");
expect(registerOperationsRuntimeForOwner(first, "plugin-one")).toEqual({ ok: true });
expect(registerOperationsRuntimeForOwner(second, "plugin-two")).toEqual({
ok: false,
existingOwner: "plugin-one",
});
expect(
registerOperationsRuntimeForOwner(replacement, "plugin-one", {
allowSameOwnerRefresh: true,
}),
).toEqual({ ok: true });
expect(getRegisteredOperationsRuntime()).toBe(replacement);
});
it("restores and clears runtime state", () => {
clearOperationsRuntimeState();
const runtime = createRuntime("restore");
restoreOperationsRuntimeState({
runtime,
ownerPluginId: "plugin-restore",
});
expect(getRegisteredOperationsRuntime()).toBe(runtime);
expect(getRegisteredOperationsRuntimeOwner()).toBe("plugin-restore");
clearOperationsRuntimeState();
expect(getRegisteredOperationsRuntime()).toBeUndefined();
expect(getRegisteredOperationsRuntimeOwner()).toBeUndefined();
});
it("summarizes generic operation records", () => {
const summary = summarizeOperationRecords([
{
operationId: "op-1",
namespace: "tasks",
kind: "cli",
status: "queued",
description: "Queued task",
createdAt: 1,
updatedAt: 1,
},
{
operationId: "op-2",
namespace: "imports",
kind: "csv",
status: "failed",
description: "Failed import",
createdAt: 2,
updatedAt: 2,
},
]);
expect(summary).toEqual({
total: 2,
active: 1,
terminal: 1,
failures: 1,
byNamespace: {
imports: 1,
tasks: 1,
},
byKind: {
cli: 1,
csv: 1,
},
byStatus: {
failed: 1,
queued: 1,
},
});
});
});

View File

@ -0,0 +1,277 @@
import type { OpenClawConfig } from "../config/config.js";
export type PluginOperationRecord = {
operationId: string;
namespace: string;
kind: string;
status: string;
sourceId?: string;
requesterSessionKey?: string;
childSessionKey?: string;
parentOperationId?: string;
agentId?: string;
runId?: string;
title?: string;
description: string;
createdAt: number;
startedAt?: number;
endedAt?: number;
updatedAt: number;
error?: string;
progressSummary?: string;
terminalSummary?: string;
metadata?: Record<string, unknown>;
};
export type PluginOperationListQuery = {
namespace?: string;
kind?: string;
status?: string;
sessionKey?: string;
runId?: string;
sourceId?: string;
parentOperationId?: string;
limit?: number;
};
export type PluginOperationSummary = {
total: number;
active: number;
terminal: number;
failures: number;
byNamespace: Record<string, number>;
byKind: Record<string, number>;
byStatus: Record<string, number>;
};
export type PluginOperationCreateEvent = {
type: "create";
namespace: string;
kind: string;
status?: string;
sourceId?: string;
requesterSessionKey?: string;
childSessionKey?: string;
parentOperationId?: string;
agentId?: string;
runId?: string;
title?: string;
description: string;
createdAt?: number;
startedAt?: number;
endedAt?: number;
updatedAt?: number;
error?: string;
progressSummary?: string | null;
terminalSummary?: string | null;
metadata?: Record<string, unknown>;
};
export type PluginOperationTransitionEvent = {
type: "transition";
operationId?: string;
runId?: string;
status: string;
at?: number;
startedAt?: number;
endedAt?: number;
error?: string | null;
progressSummary?: string | null;
terminalSummary?: string | null;
metadataPatch?: Record<string, unknown>;
};
export type PluginOperationPatchEvent = {
type: "patch";
operationId?: string;
runId?: string;
at?: number;
title?: string | null;
description?: string | null;
error?: string | null;
progressSummary?: string | null;
terminalSummary?: string | null;
metadataPatch?: Record<string, unknown>;
};
export type PluginOperationDispatchEvent =
| PluginOperationCreateEvent
| PluginOperationTransitionEvent
| PluginOperationPatchEvent;
export type PluginOperationDispatchResult = {
matched: boolean;
created?: boolean;
record: PluginOperationRecord | null;
};
export type PluginOperationsCancelResult = {
found: boolean;
cancelled: boolean;
reason?: string;
record?: PluginOperationRecord | null;
};
export type PluginOperationAuditSeverity = "warn" | "error";
export type PluginOperationAuditFinding = {
severity: PluginOperationAuditSeverity;
code: string;
operation: PluginOperationRecord;
detail: string;
ageMs?: number;
};
export type PluginOperationAuditSummary = {
total: number;
warnings: number;
errors: number;
byCode: Record<string, number>;
};
export type PluginOperationAuditQuery = {
namespace?: string;
severity?: PluginOperationAuditSeverity;
code?: string;
};
export type PluginOperationMaintenanceQuery = {
namespace?: string;
apply?: boolean;
};
export type PluginOperationMaintenanceSummary = {
reconciled: number;
cleanupStamped: number;
pruned: number;
};
export type PluginOperationsRuntime = {
dispatch(event: PluginOperationDispatchEvent): Promise<PluginOperationDispatchResult>;
getById(operationId: string): Promise<PluginOperationRecord | null>;
findByRunId(runId: string): Promise<PluginOperationRecord | null>;
list(query?: PluginOperationListQuery): Promise<PluginOperationRecord[]>;
summarize(query?: PluginOperationListQuery): Promise<PluginOperationSummary>;
audit(query?: PluginOperationAuditQuery): Promise<PluginOperationAuditFinding[]>;
maintenance(query?: PluginOperationMaintenanceQuery): Promise<PluginOperationMaintenanceSummary>;
cancel(params: {
cfg: OpenClawConfig;
operationId: string;
}): Promise<PluginOperationsCancelResult>;
};
type OperationsRuntimeState = {
runtime?: PluginOperationsRuntime;
ownerPluginId?: string;
};
type RegisterOperationsRuntimeResult = { ok: true } | { ok: false; existingOwner?: string };
const operationsRuntimeState: OperationsRuntimeState = {};
function normalizeOwnedPluginId(ownerPluginId: string): string {
return ownerPluginId.trim();
}
export function registerOperationsRuntimeForOwner(
runtime: PluginOperationsRuntime,
ownerPluginId: string,
opts?: { allowSameOwnerRefresh?: boolean },
): RegisterOperationsRuntimeResult {
const nextOwner = normalizeOwnedPluginId(ownerPluginId);
const existingOwner = operationsRuntimeState.ownerPluginId?.trim();
if (
operationsRuntimeState.runtime &&
existingOwner &&
existingOwner !== nextOwner &&
!(opts?.allowSameOwnerRefresh === true && existingOwner === nextOwner)
) {
return {
ok: false,
existingOwner,
};
}
operationsRuntimeState.runtime = runtime;
operationsRuntimeState.ownerPluginId = nextOwner;
return { ok: true };
}
export function getRegisteredOperationsRuntime(): PluginOperationsRuntime | undefined {
return operationsRuntimeState.runtime;
}
export function getRegisteredOperationsRuntimeOwner(): string | undefined {
return operationsRuntimeState.ownerPluginId;
}
export function hasRegisteredOperationsRuntime(): boolean {
return operationsRuntimeState.runtime !== undefined;
}
export function restoreOperationsRuntimeState(state: OperationsRuntimeState): void {
operationsRuntimeState.runtime = state.runtime;
operationsRuntimeState.ownerPluginId = state.ownerPluginId?.trim() || undefined;
}
export function clearOperationsRuntimeState(): void {
operationsRuntimeState.runtime = undefined;
operationsRuntimeState.ownerPluginId = undefined;
}
export function isActiveOperationStatus(status: string): boolean {
return status === "queued" || status === "running";
}
export function isFailureOperationStatus(status: string): boolean {
return status === "failed" || status === "timed_out" || status === "lost";
}
export function summarizeOperationRecords(
records: Iterable<PluginOperationRecord>,
): PluginOperationSummary {
const summary: PluginOperationSummary = {
total: 0,
active: 0,
terminal: 0,
failures: 0,
byNamespace: {},
byKind: {},
byStatus: {},
};
for (const record of records) {
summary.total += 1;
summary.byNamespace[record.namespace] = (summary.byNamespace[record.namespace] ?? 0) + 1;
summary.byKind[record.kind] = (summary.byKind[record.kind] ?? 0) + 1;
summary.byStatus[record.status] = (summary.byStatus[record.status] ?? 0) + 1;
if (isActiveOperationStatus(record.status)) {
summary.active += 1;
} else {
summary.terminal += 1;
}
if (isFailureOperationStatus(record.status)) {
summary.failures += 1;
}
}
return summary;
}
export function summarizeOperationAuditFindings(
findings: Iterable<PluginOperationAuditFinding>,
): PluginOperationAuditSummary {
const summary: PluginOperationAuditSummary = {
total: 0,
warnings: 0,
errors: 0,
byCode: {},
};
for (const finding of findings) {
summary.total += 1;
summary.byCode[finding.code] = (summary.byCode[finding.code] ?? 0) + 1;
if (finding.severity === "error") {
summary.errors += 1;
continue;
}
summary.warnings += 1;
}
return summary;
}

View File

@ -24,6 +24,7 @@ import {
registerMemoryPromptSection,
registerMemoryRuntime,
} from "./memory-state.js";
import { registerOperationsRuntimeForOwner } from "./operations-state.js";
import { normalizeRegisteredProvider } from "./provider-validation.js";
import { createEmptyPluginRegistry } from "./registry-empty.js";
import { withPluginRuntimePluginIdScope } from "./runtime/gateway-request-scope.js";
@ -1153,6 +1154,20 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
ownerPluginId: record.id,
});
},
registerOperationsRuntime: (runtime) => {
const result = registerOperationsRuntimeForOwner(runtime, record.id, {
allowSameOwnerRefresh: true,
});
if (!result.ok) {
const ownerDetail = result.existingOwner ? ` (${result.existingOwner})` : "";
pushDiagnostic({
level: "error",
pluginId: record.id,
source: record.source,
message: `operations runtime already registered${ownerDetail}`,
});
}
},
on: (hookName, handler, opts) =>
registerTypedHook(record, hookName, handler, opts, params.hookPolicy),
}

View File

@ -215,6 +215,20 @@ describe("plugin runtime command execution", () => {
]);
},
},
{
name: "exposes runtime.operations helpers",
assert: (runtime: ReturnType<typeof createPluginRuntime>) => {
expect(runtime.operations).toBeDefined();
expectFunctionKeys(runtime.operations as Record<string, unknown>, [
"dispatch",
"getById",
"findByRunId",
"list",
"summarize",
"cancel",
]);
},
},
] as const)("$name", ({ assert }) => {
expectRuntimeShape(assert);
});

View File

@ -6,8 +6,10 @@ import {
createLazyRuntimeMethodBinder,
createLazyRuntimeModule,
} from "../../shared/lazy-runtime.js";
import { defaultTaskOperationsRuntime } from "../../tasks/operations-runtime.js";
import { VERSION } from "../../version.js";
import { listWebSearchProviders, runWebSearch } from "../../web-search/runtime.js";
import { getRegisteredOperationsRuntime } from "../operations-state.js";
import { createRuntimeAgent } from "./runtime-agent.js";
import { defineCachedValue } from "./runtime-cache.js";
import { createRuntimeChannel } from "./runtime-channel.js";
@ -96,6 +98,20 @@ function createRuntimeModelAuth(): PluginRuntime["modelAuth"] {
};
}
function createRuntimeOperations(): PluginRuntime["operations"] {
const resolveRuntime = () => getRegisteredOperationsRuntime() ?? defaultTaskOperationsRuntime;
return {
dispatch: (event) => resolveRuntime().dispatch(event),
getById: (operationId) => resolveRuntime().getById(operationId),
findByRunId: (runId) => resolveRuntime().findByRunId(runId),
list: (query) => resolveRuntime().list(query),
summarize: (query) => resolveRuntime().summarize(query),
audit: (query) => resolveRuntime().audit(query),
maintenance: (query) => resolveRuntime().maintenance(query),
cancel: (params) => resolveRuntime().cancel(params),
};
}
function createUnavailableSubagentRuntime(): PluginRuntime["subagent"] {
const unavailable = () => {
throw new Error("Plugin runtime subagent methods are only available during a gateway request.");
@ -203,6 +219,7 @@ export function createPluginRuntime(_options: CreatePluginRuntimeOptions = {}):
events: createRuntimeEvents(),
logging: createRuntimeLogging(),
state: { resolveStateDir },
operations: createRuntimeOperations(),
} satisfies Omit<
PluginRuntime,
"tts" | "mediaUnderstanding" | "stt" | "modelAuth" | "imageGeneration"

View File

@ -1,5 +1,17 @@
import type { HeartbeatRunResult } from "../../infra/heartbeat-wake.js";
import type { LogLevel } from "../../logging/levels.js";
import type {
PluginOperationAuditFinding,
PluginOperationAuditQuery,
PluginOperationDispatchEvent,
PluginOperationDispatchResult,
PluginOperationListQuery,
PluginOperationMaintenanceQuery,
PluginOperationMaintenanceSummary,
PluginOperationRecord,
PluginOperationSummary,
PluginOperationsCancelResult,
} from "../operations-state.js";
export type { HeartbeatRunResult };
@ -115,4 +127,19 @@ export type PluginRuntimeCore = {
cfg?: import("../../config/config.js").OpenClawConfig;
}) => Promise<import("../../agents/model-auth.js").ResolvedProviderAuth>;
};
operations: {
dispatch: (event: PluginOperationDispatchEvent) => Promise<PluginOperationDispatchResult>;
getById: (operationId: string) => Promise<PluginOperationRecord | null>;
findByRunId: (runId: string) => Promise<PluginOperationRecord | null>;
list: (query?: PluginOperationListQuery) => Promise<PluginOperationRecord[]>;
summarize: (query?: PluginOperationListQuery) => Promise<PluginOperationSummary>;
audit: (query?: PluginOperationAuditQuery) => Promise<PluginOperationAuditFinding[]>;
maintenance: (
query?: PluginOperationMaintenanceQuery,
) => Promise<PluginOperationMaintenanceSummary>;
cancel: (params: {
cfg: import("../../config/config.js").OpenClawConfig;
operationId: string;
}) => Promise<PluginOperationsCancelResult>;
};
};

View File

@ -54,6 +54,7 @@ import type {
} from "../tts/provider-types.js";
import type { DeliveryContext } from "../utils/delivery-context.js";
import type { WizardPrompter } from "../wizard/prompts.js";
import type { PluginOperationsRuntime } from "./operations-state.js";
import type { SecretInputMode } from "./provider-auth-types.js";
import type { createVpsAwareOAuthHandlers } from "./provider-oauth-flow.js";
import type { PluginRuntime } from "./runtime/types.js";
@ -1767,6 +1768,8 @@ export type OpenClawPluginApi = {
registerMemoryEmbeddingProvider: (
adapter: import("./memory-embedding-providers.js").MemoryEmbeddingProviderAdapter,
) => void;
/** Register the active operations runtime adapter (exclusive slot — only one active at a time). */
registerOperationsRuntime: (runtime: PluginOperationsRuntime) => void;
resolvePath: (input: string) => string;
/** Register a lifecycle hook handler */
on: <K extends PluginHookName>(

View File

@ -0,0 +1,183 @@
import { afterEach, describe, expect, it } from "vitest";
import { withTempDir } from "../test-helpers/temp-dir.js";
import { defaultTaskOperationsRuntime } from "./operations-runtime.js";
import { findTaskByRunId, resetTaskRegistryForTests } from "./task-registry.js";
const ORIGINAL_STATE_DIR = process.env.OPENCLAW_STATE_DIR;
async function withTaskStateDir(run: () => Promise<void>): Promise<void> {
await withTempDir({ prefix: "openclaw-task-operations-" }, async (root) => {
process.env.OPENCLAW_STATE_DIR = root;
resetTaskRegistryForTests();
try {
await run();
} finally {
resetTaskRegistryForTests();
}
});
}
describe("task operations runtime", () => {
afterEach(() => {
if (ORIGINAL_STATE_DIR === undefined) {
delete process.env.OPENCLAW_STATE_DIR;
} else {
process.env.OPENCLAW_STATE_DIR = ORIGINAL_STATE_DIR;
}
resetTaskRegistryForTests();
});
it("creates and transitions task records through the generic operations runtime", async () => {
await withTaskStateDir(async () => {
const created = await defaultTaskOperationsRuntime.dispatch({
type: "create",
namespace: "tasks",
kind: "cli",
status: "queued",
requesterSessionKey: "agent:test:main",
childSessionKey: "agent:test:child",
runId: "run-ops-create",
title: "Task title",
description: "Do the thing",
});
expect(created.matched).toBe(true);
expect(created.created).toBe(true);
expect(created.record).toMatchObject({
namespace: "tasks",
kind: "cli",
status: "queued",
title: "Task title",
description: "Do the thing",
runId: "run-ops-create",
});
const progressed = await defaultTaskOperationsRuntime.dispatch({
type: "transition",
runId: "run-ops-create",
status: "running",
at: 100,
startedAt: 100,
progressSummary: "Started work",
});
expect(progressed.record).toMatchObject({
status: "running",
progressSummary: "Started work",
});
const completed = await defaultTaskOperationsRuntime.dispatch({
type: "transition",
runId: "run-ops-create",
status: "succeeded",
at: 200,
endedAt: 200,
terminalSummary: "All done",
});
expect(completed.record).toMatchObject({
status: "succeeded",
terminalSummary: "All done",
});
expect(findTaskByRunId("run-ops-create")).toMatchObject({
status: "succeeded",
terminalSummary: "All done",
});
});
});
it("lists and summarizes task-backed operations", async () => {
await withTaskStateDir(async () => {
await defaultTaskOperationsRuntime.dispatch({
type: "create",
namespace: "tasks",
kind: "acp",
status: "running",
requesterSessionKey: "agent:test:main",
runId: "run-ops-list-1",
description: "One",
startedAt: 10,
});
await defaultTaskOperationsRuntime.dispatch({
type: "create",
namespace: "tasks",
kind: "cron",
status: "failed",
requesterSessionKey: "agent:test:main",
runId: "run-ops-list-2",
description: "Two",
endedAt: 20,
terminalSummary: "Failed",
});
const listed = await defaultTaskOperationsRuntime.list({
namespace: "tasks",
});
const summary = await defaultTaskOperationsRuntime.summarize({
namespace: "tasks",
});
expect(listed).toHaveLength(2);
expect(summary).toEqual({
total: 2,
active: 1,
terminal: 1,
failures: 1,
byNamespace: { tasks: 2 },
byKind: { acp: 1, cron: 1 },
byStatus: { failed: 1, running: 1 },
});
});
});
it("patches notify policy and exposes audit plus maintenance", async () => {
await withTaskStateDir(async () => {
const created = await defaultTaskOperationsRuntime.dispatch({
type: "create",
namespace: "tasks",
kind: "cli",
status: "running",
requesterSessionKey: "agent:test:main",
runId: "run-ops-patch",
description: "Patch me",
startedAt: Date.now() - 31 * 60_000,
});
expect(created.record?.metadata?.notifyPolicy).toBe("done_only");
const findings = await defaultTaskOperationsRuntime.audit({
namespace: "tasks",
severity: "error",
code: "stale_running",
});
const patched = await defaultTaskOperationsRuntime.dispatch({
type: "patch",
operationId: created.record?.operationId,
metadataPatch: {
notifyPolicy: "silent",
},
});
expect(patched.record?.metadata?.notifyPolicy).toBe("silent");
const preview = await defaultTaskOperationsRuntime.maintenance({
namespace: "tasks",
});
expect(findings).toHaveLength(1);
expect(findings[0]).toMatchObject({
severity: "error",
code: "stale_running",
operation: {
operationId: created.record?.operationId,
},
});
expect(preview).toEqual({
reconciled: 0,
cleanupStamped: 0,
pruned: 0,
});
});
});
});

View File

@ -0,0 +1,389 @@
import type {
PluginOperationAuditFinding,
PluginOperationAuditQuery,
PluginOperationDispatchEvent,
PluginOperationDispatchResult,
PluginOperationListQuery,
PluginOperationMaintenanceQuery,
PluginOperationMaintenanceSummary,
PluginOperationRecord,
PluginOperationSummary,
PluginOperationsCancelResult,
PluginOperationsRuntime,
} from "../plugins/operations-state.js";
import { summarizeOperationRecords } from "../plugins/operations-state.js";
import {
listTaskAuditFindings,
type TaskAuditFinding,
type TaskAuditSeverity,
} from "./task-registry.audit.js";
import {
cancelTaskById,
createTaskRecord,
findTaskByRunId,
getTaskById,
listTaskRecords,
listTasksForSessionKey,
markTaskLostById,
markTaskRunningByRunId,
markTaskTerminalByRunId,
recordTaskProgressByRunId,
updateTaskNotifyPolicyById,
} from "./task-registry.js";
import {
previewTaskRegistryMaintenance,
runTaskRegistryMaintenance,
} from "./task-registry.maintenance.js";
import type {
TaskRecord,
TaskRuntime,
TaskStatus,
TaskTerminalOutcome,
} from "./task-registry.types.js";
const TASK_NAMESPACE = "tasks";
function isTaskNamespace(namespace: string | undefined): boolean {
const trimmed = namespace?.trim().toLowerCase();
return !trimmed || trimmed === "task" || trimmed === TASK_NAMESPACE;
}
function normalizeTaskRuntime(kind: string): TaskRuntime {
const trimmed = kind.trim();
if (trimmed === "acp" || trimmed === "subagent" || trimmed === "cli" || trimmed === "cron") {
return trimmed;
}
throw new Error(`Unsupported task operation kind: ${kind}`);
}
function normalizeTaskStatus(status: string | undefined): TaskStatus {
const trimmed = status?.trim();
if (
trimmed === "queued" ||
trimmed === "running" ||
trimmed === "succeeded" ||
trimmed === "failed" ||
trimmed === "timed_out" ||
trimmed === "cancelled" ||
trimmed === "lost"
) {
return trimmed;
}
return "queued";
}
function normalizeTaskTerminalOutcome(status: TaskStatus): TaskTerminalOutcome | undefined {
return status === "succeeded" ? "succeeded" : undefined;
}
function toOperationRecord(task: TaskRecord): PluginOperationRecord {
const metadata: Record<string, unknown> = {
deliveryStatus: task.deliveryStatus,
notifyPolicy: task.notifyPolicy,
};
if (typeof task.cleanupAfter === "number") {
metadata.cleanupAfter = task.cleanupAfter;
}
if (task.terminalOutcome) {
metadata.terminalOutcome = task.terminalOutcome;
}
return {
operationId: task.taskId,
namespace: TASK_NAMESPACE,
kind: task.runtime,
status: task.status,
sourceId: task.sourceId,
requesterSessionKey: task.requesterSessionKey,
childSessionKey: task.childSessionKey,
parentOperationId: task.parentTaskId,
agentId: task.agentId,
runId: task.runId,
title: task.label,
description: task.task,
createdAt: task.createdAt,
startedAt: task.startedAt,
endedAt: task.endedAt,
updatedAt: task.lastEventAt ?? task.endedAt ?? task.startedAt ?? task.createdAt,
error: task.error,
progressSummary: task.progressSummary,
terminalSummary: task.terminalSummary,
metadata,
};
}
function resolveTaskRecordForTransition(event: {
operationId?: string;
runId?: string;
}): TaskRecord | undefined {
const operationId = event.operationId?.trim();
if (operationId) {
return getTaskById(operationId);
}
const runId = event.runId?.trim();
if (runId) {
return findTaskByRunId(runId);
}
return undefined;
}
function filterOperationRecord(
record: PluginOperationRecord,
query: PluginOperationListQuery,
): boolean {
if (query.namespace && !isTaskNamespace(query.namespace)) {
return false;
}
if (query.kind && record.kind !== query.kind) {
return false;
}
if (query.status && record.status !== query.status) {
return false;
}
if (query.runId && record.runId !== query.runId) {
return false;
}
if (query.sourceId && record.sourceId !== query.sourceId) {
return false;
}
if (query.parentOperationId && record.parentOperationId !== query.parentOperationId) {
return false;
}
if (
query.sessionKey &&
record.requesterSessionKey !== query.sessionKey &&
record.childSessionKey !== query.sessionKey
) {
return false;
}
return true;
}
async function dispatchTaskOperation(
event: PluginOperationDispatchEvent,
): Promise<PluginOperationDispatchResult> {
if (event.type === "create") {
if (!isTaskNamespace(event.namespace)) {
throw new Error(
`Default operations runtime only supports the "${TASK_NAMESPACE}" namespace.`,
);
}
const status = normalizeTaskStatus(event.status);
const record = createTaskRecord({
runtime: normalizeTaskRuntime(event.kind),
sourceId: event.sourceId,
requesterSessionKey: event.requesterSessionKey?.trim() || "",
childSessionKey: event.childSessionKey,
parentTaskId: event.parentOperationId,
agentId: event.agentId,
runId: event.runId,
label: event.title,
task: event.description,
status,
startedAt: event.startedAt,
lastEventAt: event.updatedAt ?? event.startedAt ?? event.createdAt,
progressSummary: event.progressSummary,
terminalSummary: event.terminalSummary,
terminalOutcome: normalizeTaskTerminalOutcome(status),
});
return {
matched: true,
created: true,
record: toOperationRecord(record),
};
}
if (event.type === "patch") {
const current = resolveTaskRecordForTransition(event);
if (!current) {
return {
matched: false,
record: null,
};
}
const nextNotifyPolicy = event.metadataPatch?.notifyPolicy;
const next =
nextNotifyPolicy === "done_only" ||
nextNotifyPolicy === "state_changes" ||
nextNotifyPolicy === "silent"
? (updateTaskNotifyPolicyById({
taskId: current.taskId,
notifyPolicy: nextNotifyPolicy,
}) ?? current)
: current;
return {
matched: true,
record: toOperationRecord(next),
};
}
const current = resolveTaskRecordForTransition(event);
if (!current) {
return {
matched: false,
record: null,
};
}
const at = event.at ?? event.endedAt ?? event.startedAt ?? Date.now();
const runId = event.runId?.trim() || current.runId?.trim();
const status = normalizeTaskStatus(event.status);
let next: TaskRecord | null | undefined;
if (status === "running") {
if (!runId) {
throw new Error("Task transition to running requires a runId.");
}
next = markTaskRunningByRunId({
runId,
startedAt: event.startedAt,
lastEventAt: at,
progressSummary: event.progressSummary,
eventSummary: event.progressSummary,
})[0];
} else if (status === "queued") {
if (!runId) {
throw new Error("Task transition to queued requires a runId.");
}
next = recordTaskProgressByRunId({
runId,
lastEventAt: at,
progressSummary: event.progressSummary,
eventSummary: event.progressSummary,
})[0];
} else if (
status === "succeeded" ||
status === "failed" ||
status === "timed_out" ||
status === "cancelled"
) {
if (!runId) {
throw new Error(`Task transition to ${status} requires a runId.`);
}
next = markTaskTerminalByRunId({
runId,
status,
startedAt: event.startedAt,
endedAt: event.endedAt ?? at,
lastEventAt: at,
error: event.error ?? undefined,
progressSummary: event.progressSummary,
terminalSummary: event.terminalSummary,
terminalOutcome: status === "succeeded" ? "succeeded" : undefined,
})[0];
} else if (status === "lost") {
next = markTaskLostById({
taskId: current.taskId,
endedAt: event.endedAt ?? at,
lastEventAt: at,
error: event.error ?? undefined,
});
}
return {
matched: true,
record: next ? toOperationRecord(next) : toOperationRecord(current),
};
}
async function getTaskOperationList(
query: PluginOperationListQuery = {},
): Promise<PluginOperationRecord[]> {
if (query.namespace && !isTaskNamespace(query.namespace)) {
return [];
}
const records = (
query.sessionKey ? listTasksForSessionKey(query.sessionKey) : listTaskRecords()
).map(toOperationRecord);
const filtered = records.filter((record) => filterOperationRecord(record, query));
const limit =
typeof query.limit === "number" && Number.isFinite(query.limit) && query.limit > 0
? Math.floor(query.limit)
: undefined;
return typeof limit === "number" ? filtered.slice(0, limit) : filtered;
}
function isMatchingTaskAuditSeverity(
actual: TaskAuditSeverity,
requested: PluginOperationAuditQuery["severity"],
): boolean {
return !requested || actual === requested;
}
function toOperationAuditFinding(finding: TaskAuditFinding): PluginOperationAuditFinding {
return {
severity: finding.severity,
code: finding.code,
operation: toOperationRecord(finding.task),
detail: finding.detail,
...(typeof finding.ageMs === "number" ? { ageMs: finding.ageMs } : {}),
};
}
async function auditTaskOperations(
query: PluginOperationAuditQuery = {},
): Promise<PluginOperationAuditFinding[]> {
if (query.namespace && !isTaskNamespace(query.namespace)) {
return [];
}
return listTaskAuditFindings()
.filter((finding) => {
if (!isMatchingTaskAuditSeverity(finding.severity, query.severity)) {
return false;
}
if (query.code && finding.code !== query.code) {
return false;
}
return true;
})
.map(toOperationAuditFinding);
}
async function maintainTaskOperations(
query: PluginOperationMaintenanceQuery = {},
): Promise<PluginOperationMaintenanceSummary> {
if (query.namespace && !isTaskNamespace(query.namespace)) {
return {
reconciled: 0,
cleanupStamped: 0,
pruned: 0,
};
}
return query.apply ? runTaskRegistryMaintenance() : previewTaskRegistryMaintenance();
}
export const defaultTaskOperationsRuntime: PluginOperationsRuntime = {
dispatch: dispatchTaskOperation,
async getById(operationId: string) {
const record = getTaskById(operationId.trim());
return record ? toOperationRecord(record) : null;
},
async findByRunId(runId: string) {
const record = findTaskByRunId(runId.trim());
return record ? toOperationRecord(record) : null;
},
list: getTaskOperationList,
async summarize(query) {
const records = await getTaskOperationList(query);
return summarizeOperationRecords(records);
},
audit: auditTaskOperations,
maintenance: maintainTaskOperations,
async cancel(params): Promise<PluginOperationsCancelResult> {
const result = await cancelTaskById({
cfg: params.cfg,
taskId: params.operationId,
});
return {
found: result.found,
cancelled: result.cancelled,
reason: result.reason,
record: result.task ? toOperationRecord(result.task) : null,
};
},
};
export async function summarizeTaskOperations(
query: PluginOperationListQuery = {},
): Promise<PluginOperationSummary> {
return defaultTaskOperationsRuntime.summarize(query);
}

View File

@ -14,6 +14,7 @@ const RAW_TASK_MUTATORS = [
] as const;
const ALLOWED_CALLERS = new Set([
"tasks/operations-runtime.ts",
"tasks/task-executor.ts",
"tasks/task-registry.ts",
"tasks/task-registry.maintenance.ts",

View File

@ -11,8 +11,8 @@ const ALLOWED_IMPORTERS = new Set([
"auto-reply/reply/commands-subagents/action-info.ts",
"commands/doctor-workspace-status.ts",
"commands/flows.ts",
"commands/tasks.ts",
"tasks/flow-runtime.ts",
"tasks/operations-runtime.ts",
"tasks/task-executor.ts",
"tasks/task-registry.maintenance.ts",
]);

View File

@ -31,6 +31,7 @@ export function createTestPluginApi(api: TestPluginApiInput): OpenClawPluginApi
registerMemoryFlushPlan() {},
registerMemoryRuntime() {},
registerMemoryEmbeddingProvider() {},
registerOperationsRuntime() {},
resolvePath(input: string) {
return input;
},

View File

@ -132,6 +132,36 @@ export function createPluginRuntimeMock(overrides: DeepPartial<PluginRuntime> =
stt: {
transcribeAudioFile: vi.fn() as unknown as PluginRuntime["stt"]["transcribeAudioFile"],
},
operations: {
dispatch: vi.fn().mockResolvedValue({
matched: false,
record: null,
}) as unknown as PluginRuntime["operations"]["dispatch"],
getById: vi.fn().mockResolvedValue(null) as unknown as PluginRuntime["operations"]["getById"],
findByRunId: vi
.fn()
.mockResolvedValue(null) as unknown as PluginRuntime["operations"]["findByRunId"],
list: vi.fn().mockResolvedValue([]) as unknown as PluginRuntime["operations"]["list"],
summarize: vi.fn().mockResolvedValue({
total: 0,
active: 0,
terminal: 0,
failures: 0,
byNamespace: {},
byKind: {},
byStatus: {},
}) as unknown as PluginRuntime["operations"]["summarize"],
audit: vi.fn().mockResolvedValue([]) as unknown as PluginRuntime["operations"]["audit"],
maintenance: vi.fn().mockResolvedValue({
reconciled: 0,
cleanupStamped: 0,
pruned: 0,
}) as unknown as PluginRuntime["operations"]["maintenance"],
cancel: vi.fn().mockResolvedValue({
found: false,
cancelled: false,
}) as unknown as PluginRuntime["operations"]["cancel"],
},
channel: {
text: {
chunkByNewline: vi.fn((text: string) => (text ? [text] : [])),