refactor: move task state to shared sqlite

Move task run, delivery, and flow registry persistence onto the shared OpenClaw state SQLite database.

Summary:
- Store task runs, delivery state, and flow runs in state/openclaw.sqlite via the generated Kysely schema.
- Migrate shipped task sidecars into the shared state DB and archive old sidecars, including invalid-config/read-only CLI paths.
- Keep startup migration lightweight for read-only status/tasks paths while still detecting known legacy state markers and custom session stores.

Verification:
- .agents/skills/autoreview/scripts/autoreview --mode local: clean after final fix
- pnpm test src/tasks/task-registry.store.test.ts src/tasks/task-flow-registry.store.test.ts src/commands/doctor-state-migrations.test.ts -- --reporter=verbose
- pnpm test src/commands/doctor-state-migrations.test.ts src/cli/program/config-guard.test.ts src/cli/route.test.ts src/cli/command-path-policy.test.ts -- --reporter=verbose
- pnpm test src/cli/program/config-guard.test.ts src/cli/route.test.ts src/cli/command-startup-policy.test.ts src/cli/command-path-policy.test.ts src/cli/command-execution-startup.test.ts -- --reporter=verbose
- pnpm test src/cli/program/config-guard.test.ts src/cli/argv.test.ts src/cli/route.test.ts src/commands/doctor-config-preflight.state-migration.test.ts -- --reporter=verbose
- pnpm test src/tasks/task-flow-registry.store.test.ts -- --reporter=verbose
- pnpm test test/scripts/lint-suppressions.test.ts -- --reporter=verbose
- pnpm db:kysely:check
- pnpm lint:kysely
- git diff --check HEAD
- pnpm test:startup:memory
- PR CI green on 2f7d76f0d5
This commit is contained in:
Peter Steinberger
2026-05-30 04:54:37 +02:00
committed by GitHub
parent e9dee8dfe1
commit d115fb4cf9
29 changed files with 2450 additions and 1340 deletions

View File

@@ -582,10 +582,10 @@ describe("argv helpers", () => {
});
it.each([
{ argv: ["node", "openclaw", "status"], expected: false },
{ argv: ["node", "openclaw", "status"], expected: true },
{ argv: ["node", "openclaw", "health"], expected: false },
{ argv: ["node", "openclaw", "sessions"], expected: false },
{ argv: ["node", "openclaw", "--profile", "work", "status"], expected: false },
{ argv: ["node", "openclaw", "--profile", "work", "status"], expected: true },
{ argv: ["node", "openclaw", "--log-level=debug", "models", "list"], expected: false },
{ argv: ["node", "openclaw", "config", "get", "update"], expected: false },
{ argv: ["node", "openclaw", "config", "unset", "update"], expected: false },
@@ -600,7 +600,7 @@ describe("argv helpers", () => {
});
it.each([
{ path: ["status"], expected: false },
{ path: ["status"], expected: true },
{ path: ["update", "status"], expected: false },
{ path: ["config", "get"], expected: false },
{ path: ["models", "status"], expected: false },

View File

@@ -452,7 +452,7 @@ export function shouldMigrateStateFromPath(path: string[]): boolean {
return true;
}
const [primary, secondary] = path;
if (primary === "health" || primary === "status" || primary === "sessions") {
if (primary === "health" || primary === "sessions") {
return false;
}
if (primary === "update" && secondary === "status") {

View File

@@ -124,7 +124,6 @@ export const cliCommandCatalog: readonly CliCommandCatalogEntry[] = [
policy: {
loadPlugins: "never",
pluginRegistry: { scope: "channels" },
routeConfigGuard: "when-suppressed",
ensureCliPath: false,
networkProxy: "bypass",
},
@@ -180,7 +179,6 @@ export const cliCommandCatalog: readonly CliCommandCatalogEntry[] = [
commandPath: ["commitments"],
policy: {
ensureCliPath: false,
routeConfigGuard: "when-suppressed",
loadPlugins: "never",
networkProxy: "bypass",
},
@@ -225,7 +223,6 @@ export const cliCommandCatalog: readonly CliCommandCatalogEntry[] = [
exact: true,
policy: {
ensureCliPath: false,
routeConfigGuard: "when-suppressed",
loadPlugins: "never",
networkProxy: "bypass",
},
@@ -236,7 +233,6 @@ export const cliCommandCatalog: readonly CliCommandCatalogEntry[] = [
exact: true,
policy: {
ensureCliPath: false,
routeConfigGuard: "when-suppressed",
loadPlugins: "never",
networkProxy: "bypass",
},
@@ -246,7 +242,6 @@ export const cliCommandCatalog: readonly CliCommandCatalogEntry[] = [
commandPath: ["tasks"],
policy: {
ensureCliPath: false,
routeConfigGuard: "when-suppressed",
loadPlugins: "never",
networkProxy: "bypass",
},

View File

@@ -45,7 +45,7 @@ describe("command-execution-startup", () => {
startupPolicy: {
suppressDoctorStdout: true,
hideBanner: false,
skipConfigGuard: true,
skipConfigGuard: false,
loadPlugins: false,
pluginRegistry: { scope: "channels" },
},
@@ -168,7 +168,7 @@ describe("command-execution-startup", () => {
startupPolicy: {
suppressDoctorStdout: true,
hideBanner: false,
skipConfigGuard: true,
skipConfigGuard: false,
loadPlugins: false,
pluginRegistry: { scope: "channels" },
},
@@ -181,7 +181,7 @@ describe("command-execution-startup", () => {
allowInvalid: undefined,
loadPlugins: false,
pluginRegistry: { scope: "channels" },
skipConfigGuard: true,
skipConfigGuard: false,
});
const messageRuntime = {} as never;

View File

@@ -56,7 +56,7 @@ describe("command-path-policy", () => {
it("resolves status policy with shared startup semantics", () => {
expectResolvedPolicy(["status"], {
routeConfigGuard: "when-suppressed",
routeConfigGuard: "never",
loadPlugins: "never",
pluginRegistry: { scope: "channels" },
ensureCliPath: false,
@@ -221,6 +221,13 @@ describe("command-path-policy", () => {
loadPlugins: "never",
networkProxy: "bypass",
});
for (const commandPath of [["tasks"], ["tasks", "list"], ["tasks", "audit"]]) {
expectResolvedPolicy(commandPath, {
ensureCliPath: false,
loadPlugins: "never",
networkProxy: "bypass",
});
}
for (const commandPath of [
["plugins", "install"],
["plugins", "inspect"],

View File

@@ -24,7 +24,7 @@ describe("command-startup-policy", () => {
commandPath: ["status"],
suppressDoctorStdout: true,
}),
).toBe(true);
).toBe(false);
expect(
shouldSkipRouteConfigGuardForCommandPath({
commandPath: ["gateway", "status"],
@@ -239,7 +239,7 @@ describe("command-startup-policy", () => {
).toEqual({
suppressDoctorStdout: true,
hideBanner: false,
skipConfigGuard: true,
skipConfigGuard: false,
loadPlugins: false,
pluginRegistry: { scope: "channels" },
});

View File

@@ -1,4 +1,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { note } from "../../terminal/note.js";
import { formatCliCommand } from "../command-format.js";
import { ensureConfigReady, testApi } from "./config-guard.js";
@@ -68,6 +71,10 @@ async function withCapturedStdout(run: () => Promise<void>): Promise<string> {
describe("ensureConfigReady", () => {
const resetConfigGuardStateForTests = testApi.resetConfigGuardStateForTests;
const originalHome = process.env.HOME;
const originalOpenClawHome = process.env.OPENCLAW_HOME;
const originalOpenClawStateDir = process.env.OPENCLAW_STATE_DIR;
const tempRoots: string[] = [];
async function runEnsureConfigReady(commandPath: string[], suppressDoctorStdout = false) {
const runtime = makeRuntime();
@@ -90,9 +97,38 @@ describe("ensureConfigReady", () => {
});
}
function useTempOpenClawHome(): string {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-config-guard-"));
tempRoots.push(root);
process.env.OPENCLAW_HOME = root;
delete process.env.OPENCLAW_STATE_DIR;
return root;
}
function writeLegacyTaskSidecarMarker(root: string): void {
const markerPath = path.join(root, ".openclaw", "tasks", "runs.sqlite");
fs.mkdirSync(path.dirname(markerPath), { recursive: true });
fs.writeFileSync(markerPath, "");
}
function writeStateMarker(root: string, relativePath: string): void {
const markerPath = path.join(root, ".openclaw", relativePath);
fs.mkdirSync(path.dirname(markerPath), { recursive: true });
fs.writeFileSync(markerPath, "{}");
}
beforeEach(() => {
vi.clearAllMocks();
resetConfigGuardStateForTests();
if (originalHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = originalHome;
}
for (const root of tempRoots.splice(0)) {
fs.rmSync(root, { recursive: true, force: true });
}
useTempOpenClawHome();
readConfigFileSnapshotMock.mockResolvedValue(makeSnapshot());
loadAndMaybeMigrateDoctorConfigMock.mockImplementation(async () => ({
snapshot: makeSnapshot(),
@@ -100,9 +136,30 @@ describe("ensureConfigReady", () => {
}));
});
afterEach(() => {
if (originalHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = originalHome;
}
if (originalOpenClawHome === undefined) {
delete process.env.OPENCLAW_HOME;
} else {
process.env.OPENCLAW_HOME = originalOpenClawHome;
}
if (originalOpenClawStateDir === undefined) {
delete process.env.OPENCLAW_STATE_DIR;
} else {
process.env.OPENCLAW_STATE_DIR = originalOpenClawStateDir;
}
for (const root of tempRoots.splice(0)) {
fs.rmSync(root, { recursive: true, force: true });
}
});
it.each([
{
name: "skips doctor flow for read-only fast path commands",
name: "skips doctor flow for status task reads without legacy state",
commandPath: ["status"],
expectedDoctorCalls: 0,
},
@@ -112,7 +169,7 @@ describe("ensureConfigReady", () => {
expectedDoctorCalls: 0,
},
{
name: "runs doctor flow for commands that may mutate state",
name: "runs doctor flow for commands that may mutate state without legacy state",
commandPath: ["message"],
expectedDoctorCalls: 1,
},
@@ -121,13 +178,82 @@ describe("ensureConfigReady", () => {
expect(loadAndMaybeMigrateDoctorConfigMock).toHaveBeenCalledTimes(expectedDoctorCalls);
if (expectedDoctorCalls > 0) {
expect(loadAndMaybeMigrateDoctorConfigMock).toHaveBeenCalledWith({
migrateState: false,
migrateState: true,
migrateLegacyConfig: false,
invalidConfigNote: false,
});
}
});
it("runs doctor flow when lightweight startup detection finds legacy state", async () => {
const root = useTempOpenClawHome();
writeLegacyTaskSidecarMarker(root);
await runEnsureConfigReady(["status"]);
expect(loadAndMaybeMigrateDoctorConfigMock).toHaveBeenCalledWith({
migrateState: true,
migrateLegacyConfig: false,
invalidConfigNote: false,
});
});
it("runs doctor flow for legacy sessions without task sidecars", async () => {
const root = useTempOpenClawHome();
fs.mkdirSync(path.join(root, ".openclaw", "sessions"), { recursive: true });
await runEnsureConfigReady(["status"]);
expect(loadAndMaybeMigrateDoctorConfigMock).toHaveBeenCalledOnce();
});
it.each([
["Discord model picker preferences", "discord/model-picker-preferences.json"],
["Feishu dedupe sidecar", "feishu/dedup/default.json"],
["Telegram bot info cache", "telegram/bot-info-default.json"],
["Telegram pairing allowFrom", "credentials/telegram-allowFrom.json"],
["WhatsApp root auth", "credentials/creds.json"],
])("runs doctor flow for bundled channel legacy state: %s", async (_label, relativePath) => {
const root = useTempOpenClawHome();
writeStateMarker(root, relativePath);
await runEnsureConfigReady(["status"]);
expect(loadAndMaybeMigrateDoctorConfigMock).toHaveBeenCalledOnce();
});
it("uses shared tilde expansion for OPENCLAW_HOME in the startup detector", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-config-guard-home-"));
tempRoots.push(root);
process.env.HOME = root;
process.env.OPENCLAW_HOME = "~/svc";
delete process.env.OPENCLAW_STATE_DIR;
writeLegacyTaskSidecarMarker(path.join(root, "svc"));
await runEnsureConfigReady(["status"]);
expect(loadAndMaybeMigrateDoctorConfigMock).toHaveBeenCalledOnce();
});
it("runs doctor flow for read-only commands with configured custom session stores", async () => {
const root = useTempOpenClawHome();
const customStore = path.join(root, "sessions", "sessions.json");
const snapshot = {
...makeSnapshot(),
config: { session: { store: customStore } },
runtimeConfig: { session: { store: customStore } },
};
readConfigFileSnapshotMock.mockResolvedValue(snapshot);
loadAndMaybeMigrateDoctorConfigMock.mockResolvedValue({
snapshot,
baseConfig: {},
});
await runEnsureConfigReady(["status"]);
expect(loadAndMaybeMigrateDoctorConfigMock).toHaveBeenCalledOnce();
});
it("pins a valid preflight snapshot for command code reuse", async () => {
const snapshot = {
...makeSnapshot(),
@@ -137,7 +263,7 @@ describe("ensureConfigReady", () => {
};
readConfigFileSnapshotMock.mockResolvedValue(snapshot);
await runEnsureConfigReady(["status"]);
await runEnsureConfigReady(["health"]);
expect(setRuntimeConfigSnapshotMock).toHaveBeenCalledWith(
snapshot.runtimeConfig,
@@ -155,9 +281,9 @@ describe("ensureConfigReady", () => {
.mockResolvedValueOnce(recoveredSnapshot);
try {
await expect(runEnsureConfigReady(["status"])).rejects.toThrow(transientError);
await expect(runEnsureConfigReady(["status"])).resolves.toBeDefined();
await expect(runEnsureConfigReady(["status"])).resolves.toBeDefined();
await expect(runEnsureConfigReady(["health"])).rejects.toThrow(transientError);
await expect(runEnsureConfigReady(["health"])).resolves.toBeDefined();
await expect(runEnsureConfigReady(["health"])).resolves.toBeDefined();
} finally {
if (originalVitest === undefined) {
delete process.env.VITEST;
@@ -182,7 +308,7 @@ describe("ensureConfigReady", () => {
"",
`Fix: ${formatCliCommand("openclaw doctor --fix")}`,
`Inspect: ${formatCliCommand("openclaw config validate")}`,
"Status, health, logs, and doctor commands still run with invalid config.",
"Status, health, logs, tasks list/audit, and doctor commands still run with invalid config.",
]);
expect(runtime.exit).toHaveBeenCalledWith(1);
});
@@ -227,6 +353,18 @@ describe("ensureConfigReady", () => {
const gatewayRuntime = await runEnsureConfigReady(["gateway", "health"]);
expect(gatewayRuntime.exit).not.toHaveBeenCalled();
const tasksListRuntime = await runEnsureConfigReady(["tasks", "list"]);
expect(tasksListRuntime.exit).not.toHaveBeenCalled();
const tasksParentRuntime = await runEnsureConfigReady(["tasks"]);
expect(tasksParentRuntime.exit).not.toHaveBeenCalled();
const tasksAuditRuntime = await runEnsureConfigReady(["tasks", "audit"]);
expect(tasksAuditRuntime.exit).not.toHaveBeenCalled();
const tasksRunRuntime = await runEnsureConfigReady(["tasks", "run"]);
expect(tasksRunRuntime.exit).toHaveBeenCalledWith(1);
const doctorRuntime = await runEnsureConfigReady(["doctor", "fix"]);
expect(doctorRuntime.exit).not.toHaveBeenCalled();
expect(doctorRuntime.error).toHaveBeenCalledWith(expect.stringContaining("agentRuntime"));
@@ -244,6 +382,7 @@ describe("ensureConfigReady", () => {
});
it("runs doctor migration flow only once per module instance", async () => {
writeLegacyTaskSidecarMarker(useTempOpenClawHome());
const runtimeA = makeRuntime();
const runtimeB = makeRuntime();
@@ -253,11 +392,13 @@ describe("ensureConfigReady", () => {
});
it("still runs doctor flow when stdout suppression is enabled", async () => {
writeLegacyTaskSidecarMarker(useTempOpenClawHome());
await runEnsureConfigReady(["message"], true);
expect(loadAndMaybeMigrateDoctorConfigMock).toHaveBeenCalledTimes(1);
});
it("prevents preflight note noise when suppression is enabled", async () => {
writeLegacyTaskSidecarMarker(useTempOpenClawHome());
loadAndMaybeMigrateDoctorConfigMock.mockImplementation(async () => {
note("Doctor warnings", "Config warnings");
return {
@@ -272,6 +413,7 @@ describe("ensureConfigReady", () => {
});
it("allows preflight note noise when suppression is not enabled", async () => {
writeLegacyTaskSidecarMarker(useTempOpenClawHome());
loadAndMaybeMigrateDoctorConfigMock.mockImplementation(async () => {
note("Doctor warnings", "Config warnings");
return {
@@ -286,6 +428,7 @@ describe("ensureConfigReady", () => {
});
it("does not suppress unrelated concurrent stdout writes while suppressing preflight notes", async () => {
writeLegacyTaskSidecarMarker(useTempOpenClawHome());
let releasePreflight: (() => void) | undefined;
let preflightStarted: (() => void) | undefined;
const preflightStartedPromise = new Promise<void>((resolve) => {

View File

@@ -1,4 +1,9 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { readConfigFileSnapshot, setRuntimeConfigSnapshot } from "../../config/config.js";
import { resolveLegacyStateDirs, resolveOAuthDir, resolveStateDir } from "../../config/paths.js";
import { resolveRequiredHomeDir } from "../../infra/home-dir.js";
import type { RuntimeEnv } from "../../runtime.js";
import { withSuppressedNotes } from "../../terminal/note.js";
import { shouldMigrateStateFromPath } from "../argv.js";
@@ -17,6 +22,7 @@ const ALLOWED_INVALID_GATEWAY_SUBCOMMANDS = new Set([
"stop",
"restart",
]);
const ALLOWED_INVALID_TASK_SUBCOMMANDS = new Set(["list", "audit"]);
let didRunDoctorConfigFlow = false;
let configSnapshotPromise: Promise<Awaited<ReturnType<typeof readConfigFileSnapshot>>> | null =
null;
@@ -26,6 +32,91 @@ function resetConfigGuardStateForTests() {
configSnapshotPromise = null;
}
function fileOrDirExists(pathname: string): boolean {
try {
return fs.existsSync(pathname);
} catch {
return false;
}
}
function dirHasFile(dir: string, predicate: (name: string) => boolean): boolean {
try {
return fs
.readdirSync(dir, { withFileTypes: true })
.some((entry) => entry.isFile() && predicate(entry.name));
} catch {
return false;
}
}
function isLegacyWhatsAppAuthFile(name: string): boolean {
if (name === "creds.json" || name === "creds.json.bak") {
return true;
}
return name.endsWith(".json") && /^(app-state-sync|session|sender-key|pre-key)-/.test(name);
}
function hasBundledChannelLegacyStateMigrationInputs(stateDir: string, oauthDir: string): boolean {
if (fileOrDirExists(path.join(stateDir, "discord", "model-picker-preferences.json"))) {
return true;
}
if (dirHasFile(path.join(stateDir, "feishu", "dedup"), (name) => name.endsWith(".json"))) {
return true;
}
if (
fileOrDirExists(path.join(oauthDir, "telegram-allowFrom.json")) ||
dirHasFile(
path.join(stateDir, "telegram"),
(name) => name.startsWith("bot-info-") && name.endsWith(".json"),
)
) {
return true;
}
return dirHasFile(oauthDir, isLegacyWhatsAppAuthFile);
}
function hasLegacyStateMigrationInputs(): boolean {
const stateDir = resolveStateDir(process.env, os.homedir);
const oauthDir = resolveOAuthDir(process.env, stateDir);
if (
!process.env.OPENCLAW_STATE_DIR?.trim() &&
resolveLegacyStateDirs(() => resolveRequiredHomeDir(process.env, os.homedir)).some(
fileOrDirExists,
)
) {
return true;
}
return (
[
path.join(stateDir, "agent"),
path.join(stateDir, "agents"),
path.join(stateDir, "flows", "registry.sqlite"),
path.join(stateDir, "plugin-state", "state.sqlite"),
path.join(stateDir, "sessions"),
path.join(stateDir, "tasks", "runs.sqlite"),
].some(fileOrDirExists) || hasBundledChannelLegacyStateMigrationInputs(stateDir, oauthDir)
);
}
function isReadOnlyStateMigrationCommand(commandPath: string[]): boolean {
const commandName = commandPath[0];
const subcommandName = commandPath[1];
return (
commandName === "status" ||
(commandName === "tasks" &&
(subcommandName === undefined || ALLOWED_INVALID_TASK_SUBCOMMANDS.has(subcommandName)))
);
}
function snapshotHasConfiguredSessionStore(
snapshot: Awaited<ReturnType<typeof readConfigFileSnapshot>>,
): boolean {
const cfg = snapshot.runtimeConfig ?? snapshot.config;
const store = cfg?.session?.store;
return typeof store === "string" && store.trim().length > 0;
}
async function getConfigSnapshot() {
// Tests often mutate config fixtures; caching can make those flaky.
if (process.env.VITEST === "true") {
@@ -51,30 +142,51 @@ export async function ensureConfigReady(params: {
}): Promise<void> {
const commandPath = params.commandPath ?? [];
let preflightSnapshot: Awaited<ReturnType<typeof readConfigFileSnapshot>> | null = null;
if (!didRunDoctorConfigFlow && shouldMigrateStateFromPath(commandPath)) {
const shouldConsiderStateMigration = shouldMigrateStateFromPath(commandPath);
const isReadOnlyMigrationCommand = isReadOnlyStateMigrationCommand(commandPath);
const runStateMigrationPreflight = async () => {
didRunDoctorConfigFlow = true;
const runDoctorConfigPreflight = async () =>
(await import("../../commands/doctor-config-preflight.js")).runDoctorConfigPreflight({
// Keep ordinary CLI startup on the lightweight validation path.
migrateState: false,
migrateState: true,
migrateLegacyConfig: false,
invalidConfigNote: false,
});
if (!params.suppressDoctorStdout) {
preflightSnapshot = (await runDoctorConfigPreflight()).snapshot;
} else {
preflightSnapshot = (await withSuppressedNotes(runDoctorConfigPreflight)).snapshot;
}
return !params.suppressDoctorStdout
? (await runDoctorConfigPreflight()).snapshot
: (await withSuppressedNotes(runDoctorConfigPreflight)).snapshot;
};
if (
!didRunDoctorConfigFlow &&
shouldConsiderStateMigration &&
(!isReadOnlyMigrationCommand || hasLegacyStateMigrationInputs())
) {
preflightSnapshot = await runStateMigrationPreflight();
}
const snapshot = preflightSnapshot ?? (await getConfigSnapshot());
let snapshot = preflightSnapshot ?? (await getConfigSnapshot());
if (
!preflightSnapshot &&
!didRunDoctorConfigFlow &&
shouldConsiderStateMigration &&
isReadOnlyMigrationCommand &&
snapshot.valid &&
snapshotHasConfiguredSessionStore(snapshot)
) {
preflightSnapshot = await runStateMigrationPreflight();
snapshot = preflightSnapshot;
}
const commandName = commandPath[0];
const subcommandName = commandPath[1];
const isBareGatewayForegroundRun =
commandName === "gateway" && (subcommandName === undefined || subcommandName.trim() === "");
const isReadOnlyTaskStateCommand =
commandName === "tasks" &&
(subcommandName === undefined || ALLOWED_INVALID_TASK_SUBCOMMANDS.has(subcommandName));
const allowInvalid = commandName
? params.allowInvalid === true ||
ALLOWED_INVALID_COMMANDS.has(commandName) ||
isReadOnlyTaskStateCommand ||
isBareGatewayForegroundRun ||
(commandName === "gateway" &&
subcommandName &&
@@ -134,7 +246,9 @@ export async function ensureConfigReady(params: {
`${muted("Inspect:")} ${commandText(formatCliCommand("openclaw config validate"))}`,
);
params.runtime.error(
muted("Status, health, logs, and doctor commands still run with invalid config."),
muted(
"Status, health, logs, tasks list/audit, and doctor commands still run with invalid config.",
),
);
if (!allowInvalid) {
params.runtime.exit(1);

View File

@@ -83,10 +83,19 @@ describe("tryRouteCli", () => {
}
});
it("skips config guard for routed status --json commands", async () => {
it("keeps config guard for routed status --json commands", async () => {
await expect(tryRouteCli(["node", "openclaw", "status", "--json"])).resolves.toBe(true);
expect(ensureConfigReadyMock).not.toHaveBeenCalled();
expect(ensureConfigReadyMock).toHaveBeenCalledTimes(1);
expect(firstConfigReadyCall()?.commandPath).toEqual(["status"]);
expect(ensurePluginRegistryLoadedMock).not.toHaveBeenCalled();
});
it("keeps config guard for the parent tasks JSON list alias", async () => {
await expect(tryRouteCli(["node", "openclaw", "tasks", "--json"])).resolves.toBe(true);
expect(ensureConfigReadyMock).toHaveBeenCalledTimes(1);
expect(firstConfigReadyCall()?.commandPath).toEqual(["tasks"]);
expect(ensurePluginRegistryLoadedMock).not.toHaveBeenCalled();
});

View File

@@ -0,0 +1,78 @@
import { describe, expect, it, vi } from "vitest";
const autoMigrateLegacyStateDir = vi.hoisted(() =>
vi.fn(async () => ({ migrated: false, skipped: false, changes: [], warnings: [] })),
);
const autoMigrateLegacyState = vi.hoisted(() =>
vi.fn(async () => ({ migrated: true, skipped: false, changes: ["imported"], warnings: [] })),
);
const autoMigrateLegacyTaskStateSidecars = vi.hoisted(() =>
vi.fn(async () => ({ migrated: true, skipped: false, changes: ["task-imported"], warnings: [] })),
);
const readConfigFileSnapshot = vi.hoisted(() =>
vi.fn(async () => ({
exists: true,
valid: true,
config: { gateway: { mode: "local", port: 19091 } } as Record<string, unknown>,
sourceConfig: { gateway: { mode: "local", port: 19091 } } as Record<string, unknown>,
legacyIssues: [] as Array<{ path: string; message: string }>,
warnings: [] as Array<{ path: string; message: string }>,
issues: [] as Array<{ path: string; message: string }>,
})),
);
const note = vi.hoisted(() => vi.fn());
vi.mock("./doctor-state-migrations.js", () => ({
autoMigrateLegacyState,
autoMigrateLegacyStateDir,
autoMigrateLegacyTaskStateSidecars,
}));
vi.mock("../config/io.js", () => ({
readConfigFileSnapshot,
recoverConfigFromJsonRootSuffix: vi.fn(),
recoverConfigFromLastKnownGood: vi.fn(),
}));
vi.mock("../terminal/note.js", () => ({ note }));
const { runDoctorConfigPreflight } = await import("./doctor-config-preflight.js");
describe("runDoctorConfigPreflight state migration", () => {
it("runs full state migrations after reading the config snapshot", async () => {
await runDoctorConfigPreflight({
migrateLegacyConfig: false,
invalidConfigNote: false,
});
expect(autoMigrateLegacyStateDir).toHaveBeenCalledOnce();
expect(readConfigFileSnapshot).toHaveBeenCalledOnce();
expect(autoMigrateLegacyState).toHaveBeenCalledWith({
cfg: { gateway: { mode: "local", port: 19091 } },
env: process.env,
});
expect(note).toHaveBeenCalledWith("- imported", "Doctor changes");
});
it("limits invalid-config preflight to task sidecar migration", async () => {
vi.clearAllMocks();
readConfigFileSnapshot.mockResolvedValueOnce({
exists: true,
valid: false,
config: {},
sourceConfig: {},
legacyIssues: [],
warnings: [],
issues: [{ path: "gateway", message: "invalid" }],
});
await runDoctorConfigPreflight({
migrateLegacyConfig: false,
invalidConfigNote: false,
});
expect(autoMigrateLegacyState).not.toHaveBeenCalled();
expect(autoMigrateLegacyTaskStateSidecars).toHaveBeenCalledWith({ env: process.env });
expect(note).toHaveBeenCalledWith("- task-imported", "Doctor changes");
});
});

View File

@@ -89,6 +89,15 @@ export function shouldSkipPluginValidationForDoctorConfigPreflight(
return isTruthyEnvValue(env.OPENCLAW_UPDATE_IN_PROGRESS);
}
function noteStateMigrationResult(result: { changes: string[]; warnings: string[] }): void {
if (result.changes.length > 0) {
note(result.changes.map((entry) => `- ${entry}`).join("\n"), "Doctor changes");
}
if (result.warnings.length > 0) {
note(result.warnings.map((entry) => `- ${entry}`).join("\n"), "Doctor warnings");
}
}
export async function runDoctorConfigPreflight(
options: {
migrateState?: boolean;
@@ -100,12 +109,7 @@ export async function runDoctorConfigPreflight(
if (options.migrateState !== false) {
const { autoMigrateLegacyStateDir } = await import("./doctor-state-migrations.js");
const stateDirResult = await autoMigrateLegacyStateDir({ env: process.env });
if (stateDirResult.changes.length > 0) {
note(stateDirResult.changes.map((entry) => `- ${entry}`).join("\n"), "Doctor changes");
}
if (stateDirResult.warnings.length > 0) {
note(stateDirResult.warnings.map((entry) => `- ${entry}`).join("\n"), "Doctor warnings");
}
noteStateMigrationResult(stateDirResult);
}
if (options.migrateLegacyConfig !== false) {
@@ -150,8 +154,18 @@ export async function runDoctorConfigPreflight(
note(formatConfigIssueLines(warnings, "-").join("\n"), "Config warnings");
}
const baseConfig = snapshot.sourceConfig ?? snapshot.config ?? {};
if (options.migrateState !== false) {
const { autoMigrateLegacyState, autoMigrateLegacyTaskStateSidecars } =
await import("./doctor-state-migrations.js");
const stateResult = snapshot.valid
? await autoMigrateLegacyState({ cfg: baseConfig, env: process.env })
: await autoMigrateLegacyTaskStateSidecars({ env: process.env });
noteStateMigrationResult(stateResult);
}
return {
snapshot,
baseConfig: snapshot.sourceConfig ?? snapshot.config ?? {},
baseConfig,
};
}

View File

@@ -10,9 +10,12 @@ import {
resetPluginStateStoreForTests,
} from "../plugin-state/plugin-state-store.js";
import { seedPluginStateEntriesForTests } from "../plugin-state/plugin-state-store.test-helpers.js";
import { loadTaskFlowRegistryStateFromSqlite } from "../tasks/task-flow-registry.store.sqlite.js";
import { loadTaskRegistryStateFromSqlite } from "../tasks/task-registry.store.sqlite.js";
import {
autoMigrateLegacyStateDir,
autoMigrateLegacyState,
autoMigrateLegacyTaskStateSidecars,
detectLegacyStateMigrations,
resetAutoMigrateLegacyStateDirForTest,
resetAutoMigrateLegacyStateForTest,
@@ -288,6 +291,126 @@ function writeLegacyPluginStateSidecar(root: string): string {
return sourcePath;
}
function writeLegacyTaskStateSidecars(root: string): {
taskRunsPath: string;
flowRunsPath: string;
} {
const taskRunsPath = path.join(root, "tasks", "runs.sqlite");
fs.mkdirSync(path.dirname(taskRunsPath), { recursive: true });
const sqlite = requireNodeSqlite();
const tasksDb = new sqlite.DatabaseSync(taskRunsPath);
try {
tasksDb.exec(`
CREATE TABLE task_runs (
task_id TEXT PRIMARY KEY,
runtime TEXT NOT NULL,
source_id TEXT,
requester_session_key TEXT NOT NULL,
child_session_key TEXT,
parent_task_id TEXT,
agent_id TEXT,
run_id TEXT,
label TEXT,
task TEXT NOT NULL,
status TEXT NOT NULL,
delivery_status TEXT NOT NULL,
notify_policy TEXT NOT NULL,
created_at INTEGER NOT NULL,
started_at INTEGER,
ended_at INTEGER,
last_event_at INTEGER,
cleanup_after INTEGER,
error TEXT,
progress_summary TEXT,
terminal_summary TEXT,
terminal_outcome TEXT
);
CREATE TABLE task_delivery_state (
task_id TEXT PRIMARY KEY,
requester_origin_json TEXT,
last_notified_event_at INTEGER
);
`);
tasksDb
.prepare(
`
INSERT INTO task_runs (
task_id, runtime, source_id, requester_session_key, child_session_key, run_id, task,
status, delivery_status, notify_policy, created_at, last_event_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
)
.run(
"legacy-task",
"cron",
"nightly",
"",
"agent:main:cron:nightly",
"legacy-task-run",
"Legacy cron task",
"running",
"not_applicable",
"silent",
100,
110,
);
tasksDb
.prepare(
`
INSERT INTO task_delivery_state (
task_id, requester_origin_json, last_notified_event_at
) VALUES (?, ?, ?)
`,
)
.run("legacy-task", '{"channel":"test","to":"target"}', 120);
} finally {
tasksDb.close();
}
const flowRunsPath = path.join(root, "flows", "registry.sqlite");
fs.mkdirSync(path.dirname(flowRunsPath), { recursive: true });
const flowsDb = new sqlite.DatabaseSync(flowRunsPath);
try {
flowsDb.exec(`
CREATE TABLE flow_runs (
flow_id TEXT PRIMARY KEY,
owner_session_key TEXT NOT NULL,
requester_origin_json TEXT,
status TEXT NOT NULL,
notify_policy TEXT NOT NULL,
goal TEXT NOT NULL,
current_step TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
ended_at INTEGER
);
`);
flowsDb
.prepare(
`
INSERT INTO flow_runs (
flow_id, owner_session_key, status, notify_policy, goal, current_step, created_at,
updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`,
)
.run(
"legacy-flow",
"agent:main:legacy-flow",
"running",
"done_only",
"Legacy flow",
"spawn_task",
200,
210,
);
} finally {
flowsDb.close();
}
return { taskRunsPath, flowRunsPath };
}
async function detectAndRunMigrations(params: {
root: string;
cfg: OpenClawConfig;
@@ -986,6 +1109,197 @@ describe("doctor legacy state migrations", () => {
});
});
it("imports shipped task registry and flow SQLite sidecars into shared state", async () => {
const root = await makeTempRoot();
const { taskRunsPath, flowRunsPath } = writeLegacyTaskStateSidecars(root);
const detected = await detectLegacyStateMigrations({
cfg: {},
env: { OPENCLAW_STATE_DIR: root } as NodeJS.ProcessEnv,
});
expect(detected.taskStateSidecars).toEqual({
taskRunsPath,
flowRunsPath,
hasLegacy: true,
});
expect(detected.preview).toContain(
`- Task registry sidecar: ${taskRunsPath} → shared SQLite state`,
);
expect(detected.preview).toContain(
`- Task flow sidecar: ${flowRunsPath} → shared SQLite state`,
);
const result = await runLegacyStateMigrations({ detected });
expect(result.warnings).toStrictEqual([]);
expect(result.changes).toContain("Migrated 1 task registry sidecar row → shared SQLite state");
expect(result.changes).toContain("Migrated 1 task delivery sidecar row → shared SQLite state");
expect(result.changes).toContain("Migrated 1 task flow sidecar row → shared SQLite state");
expect(fs.existsSync(taskRunsPath)).toBe(false);
expect(fs.existsSync(`${taskRunsPath}.migrated`)).toBe(true);
expect(fs.existsSync(flowRunsPath)).toBe(false);
expect(fs.existsSync(`${flowRunsPath}.migrated`)).toBe(true);
await withStateDir(root, async () => {
const taskState = loadTaskRegistryStateFromSqlite();
const task = taskState.tasks.get("legacy-task");
expect(task).toMatchObject({
taskId: "legacy-task",
ownerKey: "system:cron:nightly",
scopeKind: "system",
requesterSessionKey: "",
runId: "legacy-task-run",
});
expect(taskState.deliveryStates.get("legacy-task")).toMatchObject({
taskId: "legacy-task",
lastNotifiedEventAt: 120,
});
const flowState = loadTaskFlowRegistryStateFromSqlite();
expect(flowState.flows.get("legacy-flow")).toMatchObject({
flowId: "legacy-flow",
ownerKey: "agent:main:legacy-flow",
syncMode: "managed",
controllerId: "core/legacy-restored",
revision: 0,
});
});
});
it("skips orphan task delivery sidecar rows while importing valid task rows", async () => {
const root = await makeTempRoot();
const { taskRunsPath } = writeLegacyTaskStateSidecars(root);
const sqlite = requireNodeSqlite();
const db = new sqlite.DatabaseSync(taskRunsPath);
try {
db.prepare(
`
INSERT INTO task_delivery_state (
task_id, requester_origin_json, last_notified_event_at
) VALUES (?, ?, ?)
`,
).run("missing-task", '{"channel":"stale","to":"target"}', 130);
} finally {
db.close();
}
const result = await autoMigrateLegacyTaskStateSidecars({
env: { OPENCLAW_STATE_DIR: root } as NodeJS.ProcessEnv,
});
expect(result.changes).toContain("Migrated 1 task registry sidecar row → shared SQLite state");
expect(result.changes).toContain("Migrated 1 task delivery sidecar row → shared SQLite state");
expect(result.warnings).toContain(
"Skipped 1 orphan task delivery sidecar row with no task run",
);
expect(fs.existsSync(`${taskRunsPath}.migrated`)).toBe(true);
await withStateDir(root, async () => {
const taskState = loadTaskRegistryStateFromSqlite();
expect(taskState.tasks.has("legacy-task")).toBe(true);
expect(taskState.deliveryStates.has("legacy-task")).toBe(true);
expect(taskState.deliveryStates.has("missing-task")).toBe(false);
});
});
it("auto-migrates task sidecars without config-dependent state moves", async () => {
const root = await makeTempRoot();
const { taskRunsPath, flowRunsPath } = writeLegacyTaskStateSidecars(root);
const result = await autoMigrateLegacyTaskStateSidecars({
env: { OPENCLAW_STATE_DIR: root } as NodeJS.ProcessEnv,
});
expect(result.warnings).toStrictEqual([]);
expect(result.changes).toContain("Migrated 1 task registry sidecar row → shared SQLite state");
expect(result.changes).toContain("Migrated 1 task flow sidecar row → shared SQLite state");
expect(fs.existsSync(`${taskRunsPath}.migrated`)).toBe(true);
expect(fs.existsSync(`${flowRunsPath}.migrated`)).toBe(true);
await withStateDir(root, async () => {
expect(loadTaskRegistryStateFromSqlite().tasks.has("legacy-task")).toBe(true);
expect(loadTaskFlowRegistryStateFromSqlite().flows.has("legacy-flow")).toBe(true);
});
});
it("keeps task sidecars when shared state already has conflicting task rows", async () => {
const root = await makeTempRoot();
const { taskRunsPath, flowRunsPath } = writeLegacyTaskStateSidecars(root);
await withStateDir(root, async () => {
const sqlite = requireNodeSqlite();
const sharedPath = path.join(root, "state", "openclaw.sqlite");
fs.mkdirSync(path.dirname(sharedPath), { recursive: true });
const db = new sqlite.DatabaseSync(sharedPath);
try {
db.exec(`
CREATE TABLE IF NOT EXISTS task_runs (
task_id TEXT NOT NULL PRIMARY KEY,
runtime TEXT NOT NULL,
task_kind TEXT,
source_id TEXT,
requester_session_key TEXT,
owner_key TEXT NOT NULL,
scope_kind TEXT NOT NULL,
child_session_key TEXT,
parent_flow_id TEXT,
parent_task_id TEXT,
agent_id TEXT,
run_id TEXT,
label TEXT,
task TEXT NOT NULL,
status TEXT NOT NULL,
delivery_status TEXT NOT NULL,
notify_policy TEXT NOT NULL,
created_at INTEGER NOT NULL,
started_at INTEGER,
ended_at INTEGER,
last_event_at INTEGER,
cleanup_after INTEGER,
error TEXT,
progress_summary TEXT,
terminal_summary TEXT,
terminal_outcome TEXT
);
`);
db.prepare(`
INSERT INTO task_runs (
task_id, runtime, requester_session_key, owner_key, scope_kind, task, status,
delivery_status, notify_policy, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
"legacy-task",
"cron",
"",
"system:cron:nightly",
"system",
"Different task",
"running",
"not_applicable",
"silent",
100,
);
} finally {
db.close();
}
});
const detected = await detectLegacyStateMigrations({
cfg: {},
env: { OPENCLAW_STATE_DIR: root } as NodeJS.ProcessEnv,
});
const result = await runLegacyStateMigrations({ detected });
expect(result.warnings).toStrictEqual([
"Left task registry sidecar in place because 1 row already existed in shared state: legacy-task",
]);
expect(fs.existsSync(taskRunsPath)).toBe(true);
expect(fs.existsSync(`${taskRunsPath}.migrated`)).toBe(false);
expect(fs.existsSync(flowRunsPath)).toBe(false);
expect(fs.existsSync(`${flowRunsPath}.migrated`)).toBe(true);
});
it("routes legacy state to the default agent entry", async () => {
const root = await makeTempRoot();
const cfg: OpenClawConfig = {

View File

@@ -1,11 +1,13 @@
export type { LegacyStateDetection } from "../infra/state-migrations.js";
export {
autoMigrateLegacyStateDir,
autoMigrateLegacyTaskStateSidecars,
autoMigrateLegacyAgentDir,
autoMigrateLegacyState,
detectLegacyStateMigrations,
migrateLegacyAgentDir,
resetAutoMigrateLegacyStateDirForTest,
resetAutoMigrateLegacyTaskStateSidecarsForTest,
resetAutoMigrateLegacyAgentDirForTest,
resetAutoMigrateLegacyStateForTest,
runLegacyStateMigrations,

View File

@@ -210,6 +210,11 @@ function createLegacyStateMigrationDetectionResult(params?: {
sourcePath: "/tmp/state/plugin-state/state.sqlite",
hasLegacy: false,
},
taskStateSidecars: {
taskRunsPath: "/tmp/state/tasks/runs.sqlite",
flowRunsPath: "/tmp/state/flows/registry.sqlite",
hasLegacy: false,
},
channelPlans: {
hasLegacy: false,
plans: [],

View File

@@ -1,6 +1,7 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import type { DatabaseSync, SQLInputValue } from "node:sqlite";
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
import {
listBundledChannelLegacySessionSurfaces,
@@ -81,6 +82,11 @@ export type LegacyStateDetection = {
sourcePath: string;
hasLegacy: boolean;
};
taskStateSidecars: {
taskRunsPath: string;
flowRunsPath: string;
hasLegacy: boolean;
};
preview: string[];
};
@@ -91,6 +97,7 @@ type MigrationLogger = {
let autoMigrateChecked = false;
let autoMigrateStateDirChecked = false;
let autoMigrateTaskStateSidecarsChecked = false;
let cachedLegacySessionSurfaces: LegacySessionSurface[] | null = null;
type LegacySessionSurface = {
@@ -111,8 +118,10 @@ type LegacyPluginStateSidecarRow = {
};
type LegacyPluginStateImportDatabase = Pick<OpenClawStateKyselyDatabase, "plugin_state_entries">;
type SqliteBindRow = Record<string, SQLInputValue>;
const PLUGIN_STATE_SQLITE_SIDECAR_SUFFIXES = ["", "-shm", "-wal"] as const;
const TASK_STATE_SQLITE_SIDECAR_SUFFIXES = ["", "-shm", "-wal"] as const;
class LegacyPluginStateSidecarConflictError extends Error {
constructor(readonly conflictedKeys: string[]) {
@@ -120,6 +129,12 @@ class LegacyPluginStateSidecarConflictError extends Error {
}
}
class LegacyTaskStateSidecarConflictError extends Error {
constructor(readonly conflictedKeys: string[]) {
super("legacy task-state sidecar conflicts with shared state");
}
}
function getLegacySessionSurfaces(): LegacySessionSurface[] {
// Legacy migrations run on cold doctor/startup paths. Prefer the narrower
// setup plugin surface here so session-key cleanup does not materialize full
@@ -160,6 +175,14 @@ function resolveLegacyPluginStateSidecarPath(stateDir: string): string {
return path.join(stateDir, "plugin-state", "state.sqlite");
}
function resolveLegacyTaskRunsSidecarPath(stateDir: string): string {
return path.join(stateDir, "tasks", "runs.sqlite");
}
function resolveLegacyFlowRunsSidecarPath(stateDir: string): string {
return path.join(stateDir, "flows", "registry.sqlite");
}
function readLegacyPluginStateSidecarRows(sourcePath: string): LegacyPluginStateSidecarRow[] {
const sqlite = requireNodeSqlite();
const db = new sqlite.DatabaseSync(sourcePath, { readOnly: true });
@@ -230,6 +253,550 @@ function archiveLegacyPluginStateSidecar(params: {
);
}
function archiveLegacyTaskStateSidecar(params: {
sourcePath: string;
label: string;
changes: string[];
warnings: string[];
}): void {
const existingSources = TASK_STATE_SQLITE_SIDECAR_SUFFIXES.map(
(suffix) => `${params.sourcePath}${suffix}`,
).filter(fileExists);
const existingArchives = existingSources
.map((sourcePath) => `${sourcePath}.migrated`)
.filter(fileExists);
if (existingArchives.length > 0) {
params.warnings.push(
`Left migrated ${params.label} sidecar in place because archive already exists: ${existingArchives[0]}`,
);
return;
}
for (const sourcePath of existingSources) {
try {
fs.renameSync(sourcePath, `${sourcePath}.migrated`);
} catch (err) {
params.warnings.push(
`Failed archiving ${params.label} sidecar ${sourcePath}: ${String(err)}`,
);
return;
}
}
params.changes.push(
`Archived ${params.label} sidecar legacy source → ${params.sourcePath}.migrated`,
);
}
function listSqliteColumns(db: DatabaseSync, table: string): Set<string> {
const rows = db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name?: string }>;
return new Set(rows.flatMap((row) => (row.name ? [row.name] : [])));
}
function pickLegacyColumn(columns: Set<string>, name: string, fallbackSql = "NULL"): string {
return columns.has(name) ? name : `${fallbackSql} AS ${name}`;
}
function legacyBindValue(value: unknown): SQLInputValue {
if (
value == null ||
typeof value === "string" ||
typeof value === "number" ||
typeof value === "bigint" ||
value instanceof Uint8Array
) {
return value ?? null;
}
return JSON.stringify(value);
}
function legacyStringValue(value: unknown): string {
return typeof value === "string" ? value : "";
}
function legacyKeyValue(value: SQLInputValue): string {
if (typeof value === "string") {
return value;
}
if (typeof value === "number" || typeof value === "bigint") {
return `${value}`;
}
return "";
}
function normalizeLegacyTaskRow(row: Record<string, unknown>): SqliteBindRow {
const runtime = legacyStringValue(row.runtime);
const sourceId = typeof row.source_id === "string" ? row.source_id : "";
const taskId = legacyStringValue(row.task_id);
const ownerRaw = typeof row.owner_key === "string" ? row.owner_key.trim() : "";
const requesterRaw =
typeof row.requester_session_key === "string" ? row.requester_session_key.trim() : "";
const ownerKey = ownerRaw || requesterRaw || `system:${runtime}:${sourceId || taskId}`;
const scopeRaw = typeof row.scope_kind === "string" ? row.scope_kind : "";
const scopeKind = scopeRaw === "system" || ownerKey.startsWith("system:") ? "system" : "session";
return {
task_id: taskId,
runtime,
task_kind: legacyBindValue(row.task_kind),
source_id: legacyBindValue(row.source_id),
requester_session_key: scopeKind === "system" ? "" : requesterRaw || ownerKey,
owner_key: ownerKey,
scope_kind: scopeKind,
child_session_key: legacyBindValue(row.child_session_key),
parent_flow_id: legacyBindValue(row.parent_flow_id),
parent_task_id: legacyBindValue(row.parent_task_id),
agent_id: legacyBindValue(row.agent_id),
run_id: legacyBindValue(row.run_id),
label: legacyBindValue(row.label),
task: legacyBindValue(row.task ?? ""),
status: legacyBindValue(row.status ?? ""),
delivery_status: legacyBindValue(row.delivery_status ?? ""),
notify_policy: legacyBindValue(row.notify_policy ?? ""),
created_at: normalizeLegacySqliteInteger(row.created_at as number | bigint | null) ?? 0,
started_at: normalizeLegacySqliteInteger(row.started_at as number | bigint | null),
ended_at: normalizeLegacySqliteInteger(row.ended_at as number | bigint | null),
last_event_at: normalizeLegacySqliteInteger(row.last_event_at as number | bigint | null),
cleanup_after: normalizeLegacySqliteInteger(row.cleanup_after as number | bigint | null),
error: legacyBindValue(row.error),
progress_summary: legacyBindValue(row.progress_summary),
terminal_summary: legacyBindValue(row.terminal_summary),
terminal_outcome: legacyBindValue(row.terminal_outcome),
};
}
function normalizeLegacyFlowRow(row: Record<string, unknown>): SqliteBindRow {
const syncMode =
row.sync_mode === "task_mirrored" || row.shape === "single_task" ? "task_mirrored" : "managed";
const ownerKey =
typeof row.owner_key === "string" && row.owner_key.trim()
? row.owner_key.trim()
: typeof row.owner_session_key === "string"
? row.owner_session_key.trim()
: "";
const controllerId =
syncMode === "managed"
? typeof row.controller_id === "string" && row.controller_id.trim()
? row.controller_id.trim()
: "core/legacy-restored"
: null;
return {
flow_id: legacyBindValue(row.flow_id ?? ""),
shape: legacyBindValue(row.shape),
sync_mode: syncMode,
owner_key: ownerKey,
requester_origin_json: legacyBindValue(row.requester_origin_json),
controller_id: controllerId,
revision: normalizeLegacySqliteInteger(row.revision as number | bigint | null) ?? 0,
status: legacyBindValue(row.status ?? ""),
notify_policy: legacyBindValue(row.notify_policy ?? ""),
goal: legacyBindValue(row.goal ?? ""),
current_step: legacyBindValue(row.current_step),
blocked_task_id: legacyBindValue(row.blocked_task_id),
blocked_summary: legacyBindValue(row.blocked_summary),
state_json: legacyBindValue(row.state_json),
wait_json: legacyBindValue(row.wait_json),
cancel_requested_at: normalizeLegacySqliteInteger(
row.cancel_requested_at as number | bigint | null,
),
created_at: normalizeLegacySqliteInteger(row.created_at as number | bigint | null) ?? 0,
updated_at: normalizeLegacySqliteInteger(row.updated_at as number | bigint | null) ?? 0,
ended_at: normalizeLegacySqliteInteger(row.ended_at as number | bigint | null),
};
}
function legacyRowsMatch(
existing: Record<string, unknown>,
incoming: Record<string, unknown>,
columns: string[],
): boolean {
return columns.every(
(column) =>
normalizeLegacySqliteInteger(existing[column] as number | bigint | null) ===
normalizeLegacySqliteInteger(incoming[column] as number | bigint | null),
);
}
function readLegacyTaskRows(sourcePath: string): SqliteBindRow[] {
const sqlite = requireNodeSqlite();
const db = new sqlite.DatabaseSync(sourcePath, { readOnly: true });
try {
const columns = listSqliteColumns(db, "task_runs");
if (columns.size === 0) {
return [];
}
const selectColumns = [
"task_id",
"runtime",
pickLegacyColumn(columns, "task_kind"),
pickLegacyColumn(columns, "source_id"),
pickLegacyColumn(columns, "requester_session_key"),
pickLegacyColumn(columns, "owner_key"),
pickLegacyColumn(columns, "scope_kind"),
pickLegacyColumn(columns, "child_session_key"),
pickLegacyColumn(columns, "parent_flow_id"),
pickLegacyColumn(columns, "parent_task_id"),
pickLegacyColumn(columns, "agent_id"),
pickLegacyColumn(columns, "run_id"),
pickLegacyColumn(columns, "label"),
"task",
"status",
"delivery_status",
"notify_policy",
"created_at",
pickLegacyColumn(columns, "started_at"),
pickLegacyColumn(columns, "ended_at"),
pickLegacyColumn(columns, "last_event_at"),
pickLegacyColumn(columns, "cleanup_after"),
pickLegacyColumn(columns, "error"),
pickLegacyColumn(columns, "progress_summary"),
pickLegacyColumn(columns, "terminal_summary"),
pickLegacyColumn(columns, "terminal_outcome"),
];
return db
.prepare(
`SELECT ${selectColumns.join(", ")} FROM task_runs ORDER BY created_at ASC, task_id ASC`,
)
.all()
.map((row) => normalizeLegacyTaskRow(row as Record<string, unknown>));
} finally {
db.close();
}
}
function readLegacyTaskDeliveryRows(sourcePath: string): SqliteBindRow[] {
const sqlite = requireNodeSqlite();
const db = new sqlite.DatabaseSync(sourcePath, { readOnly: true });
try {
const columns = listSqliteColumns(db, "task_delivery_state");
if (columns.size === 0) {
return [];
}
return db
.prepare(
`SELECT task_id, requester_origin_json, last_notified_event_at FROM task_delivery_state ORDER BY task_id ASC`,
)
.all() as SqliteBindRow[];
} finally {
db.close();
}
}
function readLegacyFlowRows(sourcePath: string): SqliteBindRow[] {
const sqlite = requireNodeSqlite();
const db = new sqlite.DatabaseSync(sourcePath, { readOnly: true });
try {
const columns = listSqliteColumns(db, "flow_runs");
if (columns.size === 0) {
return [];
}
const selectColumns = [
"flow_id",
pickLegacyColumn(columns, "shape"),
pickLegacyColumn(columns, "sync_mode"),
pickLegacyColumn(columns, "owner_key"),
pickLegacyColumn(columns, "owner_session_key"),
pickLegacyColumn(columns, "requester_origin_json"),
pickLegacyColumn(columns, "controller_id"),
pickLegacyColumn(columns, "revision", "0"),
"status",
"notify_policy",
"goal",
pickLegacyColumn(columns, "current_step"),
pickLegacyColumn(columns, "blocked_task_id"),
pickLegacyColumn(columns, "blocked_summary"),
pickLegacyColumn(columns, "state_json"),
pickLegacyColumn(columns, "wait_json"),
pickLegacyColumn(columns, "cancel_requested_at"),
"created_at",
"updated_at",
pickLegacyColumn(columns, "ended_at"),
];
return db
.prepare(
`SELECT ${selectColumns.join(", ")} FROM flow_runs ORDER BY created_at ASC, flow_id ASC`,
)
.all()
.map((row) => normalizeLegacyFlowRow(row as Record<string, unknown>));
} finally {
db.close();
}
}
function insertTaskRunRowSql(db: DatabaseSync, row: SqliteBindRow): void {
db.prepare(
`
INSERT INTO task_runs (
task_id, runtime, task_kind, source_id, requester_session_key, owner_key, scope_kind,
child_session_key, parent_flow_id, parent_task_id, agent_id, run_id, label, task, status,
delivery_status, notify_policy, created_at, started_at, ended_at, last_event_at,
cleanup_after, error, progress_summary, terminal_summary, terminal_outcome
) VALUES (
@task_id, @runtime, @task_kind, @source_id, @requester_session_key, @owner_key,
@scope_kind, @child_session_key, @parent_flow_id, @parent_task_id, @agent_id, @run_id,
@label, @task, @status, @delivery_status, @notify_policy, @created_at, @started_at,
@ended_at, @last_event_at, @cleanup_after, @error, @progress_summary, @terminal_summary,
@terminal_outcome
)
`,
).run(row);
}
function insertTaskDeliveryRowSql(db: DatabaseSync, row: SqliteBindRow): void {
db.prepare(
`
INSERT INTO task_delivery_state (
task_id, requester_origin_json, last_notified_event_at
) VALUES (
@task_id, @requester_origin_json, @last_notified_event_at
)
`,
).run(row);
}
function insertFlowRunRowSql(db: DatabaseSync, row: SqliteBindRow): void {
db.prepare(
`
INSERT INTO flow_runs (
flow_id, shape, sync_mode, owner_key, requester_origin_json, controller_id, revision,
status, notify_policy, goal, current_step, blocked_task_id, blocked_summary, state_json,
wait_json, cancel_requested_at, created_at, updated_at, ended_at
) VALUES (
@flow_id, @shape, @sync_mode, @owner_key, @requester_origin_json, @controller_id,
@revision, @status, @notify_policy, @goal, @current_step, @blocked_task_id,
@blocked_summary, @state_json, @wait_json, @cancel_requested_at, @created_at,
@updated_at, @ended_at
)
`,
).run(row);
}
async function migrateLegacyTaskRunsSidecar(params: {
stateDir: string;
}): Promise<{ changes: string[]; warnings: string[] }> {
const sourcePath = resolveLegacyTaskRunsSidecarPath(params.stateDir);
if (!fileExists(sourcePath)) {
return { changes: [], warnings: [] };
}
const changes: string[] = [];
const warnings: string[] = [];
let taskRows: SqliteBindRow[];
let deliveryRows: SqliteBindRow[];
try {
taskRows = readLegacyTaskRows(sourcePath);
deliveryRows = readLegacyTaskDeliveryRows(sourcePath);
} catch (err) {
return {
changes,
warnings: [`Failed reading task registry sidecar ${sourcePath}: ${String(err)}`],
};
}
try {
const conflicts: string[] = [];
let importedTasks = 0;
let importedDeliveryStates = 0;
let skippedOrphanDeliveryStates = 0;
runOpenClawStateWriteTransaction(
({ db }) => {
const taskColumns = [
"runtime",
"task_kind",
"source_id",
"requester_session_key",
"owner_key",
"scope_kind",
"child_session_key",
"parent_flow_id",
"parent_task_id",
"agent_id",
"run_id",
"label",
"task",
"status",
"delivery_status",
"notify_policy",
"created_at",
"started_at",
"ended_at",
"last_event_at",
"cleanup_after",
"error",
"progress_summary",
"terminal_summary",
"terminal_outcome",
];
for (const row of taskRows) {
const existing = db
.prepare(`SELECT ${taskColumns.join(", ")} FROM task_runs WHERE task_id = ?`)
.get(legacyKeyValue(row.task_id));
if (existing) {
if (!legacyRowsMatch(existing as Record<string, unknown>, row, taskColumns)) {
conflicts.push(legacyKeyValue(row.task_id));
}
continue;
}
insertTaskRunRowSql(db, row);
importedTasks++;
}
const deliveryColumns = ["requester_origin_json", "last_notified_event_at"];
for (const row of deliveryRows) {
const existing = db
.prepare(
`SELECT requester_origin_json, last_notified_event_at FROM task_delivery_state WHERE task_id = ?`,
)
.get(legacyKeyValue(row.task_id));
if (existing) {
if (!legacyRowsMatch(existing as Record<string, unknown>, row, deliveryColumns)) {
conflicts.push(`${legacyKeyValue(row.task_id)}/delivery`);
}
continue;
}
const taskExists = db
.prepare("SELECT 1 FROM task_runs WHERE task_id = ?")
.get(legacyKeyValue(row.task_id));
if (!taskExists) {
skippedOrphanDeliveryStates++;
continue;
}
insertTaskDeliveryRowSql(db, row);
importedDeliveryStates++;
}
if (conflicts.length > 0) {
throw new LegacyTaskStateSidecarConflictError(conflicts);
}
},
{ env: { ...process.env, OPENCLAW_STATE_DIR: params.stateDir } },
);
if (importedTasks > 0) {
changes.push(
`Migrated ${importedTasks} task registry sidecar ${importedTasks === 1 ? "row" : "rows"} → shared SQLite state`,
);
}
if (importedDeliveryStates > 0) {
changes.push(
`Migrated ${importedDeliveryStates} task delivery sidecar ${importedDeliveryStates === 1 ? "row" : "rows"} → shared SQLite state`,
);
}
if (skippedOrphanDeliveryStates > 0) {
warnings.push(
`Skipped ${skippedOrphanDeliveryStates} orphan task delivery sidecar ${skippedOrphanDeliveryStates === 1 ? "row" : "rows"} with no task run`,
);
}
} catch (err) {
if (err instanceof LegacyTaskStateSidecarConflictError) {
return {
changes,
warnings: [
`Left task registry sidecar in place because ${err.conflictedKeys.length} ${err.conflictedKeys.length === 1 ? "row" : "rows"} already existed in shared state: ${err.conflictedKeys[0]}`,
],
};
}
return {
changes,
warnings: [`Failed migrating task registry sidecar ${sourcePath}: ${String(err)}`],
};
}
archiveLegacyTaskStateSidecar({ sourcePath, label: "task registry", changes, warnings });
return { changes, warnings };
}
async function migrateLegacyFlowRunsSidecar(params: {
stateDir: string;
}): Promise<{ changes: string[]; warnings: string[] }> {
const sourcePath = resolveLegacyFlowRunsSidecarPath(params.stateDir);
if (!fileExists(sourcePath)) {
return { changes: [], warnings: [] };
}
const changes: string[] = [];
const warnings: string[] = [];
let rows: SqliteBindRow[];
try {
rows = readLegacyFlowRows(sourcePath);
} catch (err) {
return {
changes,
warnings: [`Failed reading task flow sidecar ${sourcePath}: ${String(err)}`],
};
}
try {
const conflicts: string[] = [];
let imported = 0;
runOpenClawStateWriteTransaction(
({ db }) => {
const columns = [
"shape",
"sync_mode",
"owner_key",
"requester_origin_json",
"controller_id",
"revision",
"status",
"notify_policy",
"goal",
"current_step",
"blocked_task_id",
"blocked_summary",
"state_json",
"wait_json",
"cancel_requested_at",
"created_at",
"updated_at",
"ended_at",
];
for (const row of rows) {
const existing = db
.prepare(`SELECT ${columns.join(", ")} FROM flow_runs WHERE flow_id = ?`)
.get(legacyKeyValue(row.flow_id));
if (existing) {
if (!legacyRowsMatch(existing as Record<string, unknown>, row, columns)) {
conflicts.push(legacyKeyValue(row.flow_id));
}
continue;
}
insertFlowRunRowSql(db, row);
imported++;
}
if (conflicts.length > 0) {
throw new LegacyTaskStateSidecarConflictError(conflicts);
}
},
{ env: { ...process.env, OPENCLAW_STATE_DIR: params.stateDir } },
);
if (imported > 0) {
changes.push(
`Migrated ${imported} task flow sidecar ${imported === 1 ? "row" : "rows"} → shared SQLite state`,
);
}
} catch (err) {
if (err instanceof LegacyTaskStateSidecarConflictError) {
return {
changes,
warnings: [
`Left task flow sidecar in place because ${err.conflictedKeys.length} ${err.conflictedKeys.length === 1 ? "row" : "rows"} already existed in shared state: ${err.conflictedKeys[0]}`,
],
};
}
return {
changes,
warnings: [`Failed migrating task flow sidecar ${sourcePath}: ${String(err)}`],
};
}
archiveLegacyTaskStateSidecar({ sourcePath, label: "task flow", changes, warnings });
return { changes, warnings };
}
async function migrateLegacyTaskStateSidecars(params: {
stateDir: string;
}): Promise<{ changes: string[]; warnings: string[] }> {
const taskRuns = await migrateLegacyTaskRunsSidecar(params);
const flowRuns = await migrateLegacyFlowRunsSidecar(params);
return {
changes: [...taskRuns.changes, ...flowRuns.changes],
warnings: [...taskRuns.warnings, ...flowRuns.warnings],
};
}
async function migrateLegacyPluginStateSidecar(params: {
stateDir: string;
}): Promise<{ changes: string[]; warnings: string[] }> {
@@ -985,6 +1552,7 @@ function removeDirIfEmpty(dir: string) {
export function resetAutoMigrateLegacyStateForTest() {
autoMigrateChecked = false;
autoMigrateTaskStateSidecarsChecked = false;
cachedLegacySessionSurfaces = null;
}
@@ -996,6 +1564,10 @@ export function resetAutoMigrateLegacyStateDirForTest() {
autoMigrateStateDirChecked = false;
}
export function resetAutoMigrateLegacyTaskStateSidecarsForTest() {
autoMigrateTaskStateSidecarsChecked = false;
}
type StateDirMigrationResult = {
migrated: boolean;
skipped: boolean;
@@ -1227,6 +1799,42 @@ export async function autoMigrateLegacyStateDir(params: {
return { migrated: changes.length > 0, skipped: false, changes, warnings };
}
export async function autoMigrateLegacyTaskStateSidecars(params: {
env?: NodeJS.ProcessEnv;
homedir?: () => string;
log?: MigrationLogger;
}): Promise<{
migrated: boolean;
skipped: boolean;
changes: string[];
warnings: string[];
}> {
if (autoMigrateTaskStateSidecarsChecked) {
return { migrated: false, skipped: true, changes: [], warnings: [] };
}
autoMigrateTaskStateSidecarsChecked = true;
const stateDir = resolveStateDir(params.env ?? process.env, params.homedir);
const result = await migrateLegacyTaskStateSidecars({ stateDir });
const logger = params.log ?? createSubsystemLogger("state-migrations");
if (result.changes.length > 0) {
logger.info(
`Auto-migrated legacy task state:\n${result.changes.map((entry) => `- ${entry}`).join("\n")}`,
);
}
if (result.warnings.length > 0) {
logger.warn(
`Legacy task state migration warnings:\n${result.warnings.map((entry) => `- ${entry}`).join("\n")}`,
);
}
return {
migrated: result.changes.length > 0,
skipped: false,
changes: result.changes,
warnings: result.warnings,
};
}
async function collectChannelLegacyStateMigrationPlans(params: {
cfg: OpenClawConfig;
env: NodeJS.ProcessEnv;
@@ -1301,6 +1909,9 @@ export async function detectLegacyStateMigrations(params: {
const hasLegacyAgentDir = existsDir(legacyAgentDir);
const pluginStateSidecarPath = resolveLegacyPluginStateSidecarPath(stateDir);
const hasPluginStateSidecar = fileExists(pluginStateSidecarPath);
const taskRunsSidecarPath = resolveLegacyTaskRunsSidecarPath(stateDir);
const flowRunsSidecarPath = resolveLegacyFlowRunsSidecarPath(stateDir);
const hasTaskStateSidecars = fileExists(taskRunsSidecarPath) || fileExists(flowRunsSidecarPath);
const channelPlans = await collectChannelLegacyStateMigrationPlans({
cfg: params.cfg,
env,
@@ -1321,6 +1932,12 @@ export async function detectLegacyStateMigrations(params: {
if (hasPluginStateSidecar) {
preview.push(`- Plugin state sidecar: ${pluginStateSidecarPath} → shared SQLite state`);
}
if (fileExists(taskRunsSidecarPath)) {
preview.push(`- Task registry sidecar: ${taskRunsSidecarPath} → shared SQLite state`);
}
if (fileExists(flowRunsSidecarPath)) {
preview.push(`- Task flow sidecar: ${flowRunsSidecarPath} → shared SQLite state`);
}
if (channelPlans.length > 0) {
preview.push(...channelPlans.map(buildLegacyMigrationPreview));
}
@@ -1352,6 +1969,11 @@ export async function detectLegacyStateMigrations(params: {
sourcePath: pluginStateSidecarPath,
hasLegacy: hasPluginStateSidecar,
},
taskStateSidecars: {
taskRunsPath: taskRunsSidecarPath,
flowRunsPath: flowRunsSidecarPath,
hasLegacy: hasTaskStateSidecars,
},
preview,
};
}
@@ -1539,6 +2161,9 @@ export async function runLegacyStateMigrations(params: {
const pluginStateSidecar = await migrateLegacyPluginStateSidecar({
stateDir: detected.stateDir,
});
const taskStateSidecars = await migrateLegacyTaskStateSidecars({
stateDir: detected.stateDir,
});
const preSessionChannelPlans = await runLegacyMigrationPlans(
detected.channelPlans.plans.filter((plan) => plan.kind === "plugin-state-import"),
);
@@ -1550,6 +2175,7 @@ export async function runLegacyStateMigrations(params: {
return {
changes: [
...pluginStateSidecar.changes,
...taskStateSidecars.changes,
...preSessionChannelPlans.changes,
...sessions.changes,
...agentDir.changes,
@@ -1557,6 +2183,7 @@ export async function runLegacyStateMigrations(params: {
],
warnings: [
...pluginStateSidecar.warnings,
...taskStateSidecars.warnings,
...preSessionChannelPlans.warnings,
...sessions.warnings,
...agentDir.warnings,
@@ -1793,6 +2420,9 @@ export async function autoMigrateLegacyState(params: {
const pluginStateSidecar = await migrateLegacyPluginStateSidecar({
stateDir: detected.stateDir,
});
const taskStateSidecars = await migrateLegacyTaskStateSidecars({
stateDir: detected.stateDir,
});
const preSessionChannelPlans = await runLegacyMigrationPlans(
detected.channelPlans.plans.filter((plan) => plan.kind === "plugin-state-import"),
);
@@ -1800,12 +2430,14 @@ export async function autoMigrateLegacyState(params: {
...stateDirResult.changes,
...orphanKeys.changes,
...pluginStateSidecar.changes,
...taskStateSidecars.changes,
...preSessionChannelPlans.changes,
];
const warnings = [
...stateDirResult.warnings,
...orphanKeys.warnings,
...pluginStateSidecar.warnings,
...taskStateSidecars.warnings,
...preSessionChannelPlans.warnings,
];
logMigrationResults(changes, warnings);
@@ -1814,6 +2446,7 @@ export async function autoMigrateLegacyState(params: {
stateDirResult.migrated ||
orphanKeys.changes.length > 0 ||
pluginStateSidecar.changes.length > 0 ||
taskStateSidecars.changes.length > 0 ||
preSessionChannelPlans.changes.length > 0,
skipped: true,
changes,
@@ -1824,7 +2457,8 @@ export async function autoMigrateLegacyState(params: {
!detected.sessions.hasLegacy &&
!detected.agentDir.hasLegacy &&
!detected.channelPlans.hasLegacy &&
!detected.pluginStateSidecar.hasLegacy
!detected.pluginStateSidecar.hasLegacy &&
!detected.taskStateSidecars.hasLegacy
) {
const changes = [...stateDirResult.changes, ...orphanKeys.changes];
const warnings = [...stateDirResult.warnings, ...orphanKeys.warnings];
@@ -1841,6 +2475,9 @@ export async function autoMigrateLegacyState(params: {
const pluginStateSidecar = await migrateLegacyPluginStateSidecar({
stateDir: detected.stateDir,
});
const taskStateSidecars = await migrateLegacyTaskStateSidecars({
stateDir: detected.stateDir,
});
const preSessionChannelPlans = await runLegacyMigrationPlans(
detected.channelPlans.plans.filter((plan) => plan.kind === "plugin-state-import"),
);
@@ -1853,6 +2490,7 @@ export async function autoMigrateLegacyState(params: {
...stateDirResult.changes,
...orphanKeys.changes,
...pluginStateSidecar.changes,
...taskStateSidecars.changes,
...preSessionChannelPlans.changes,
...sessions.changes,
...agentDir.changes,
@@ -1862,6 +2500,7 @@ export async function autoMigrateLegacyState(params: {
...stateDirResult.warnings,
...orphanKeys.warnings,
...pluginStateSidecar.warnings,
...taskStateSidecars.warnings,
...preSessionChannelPlans.warnings,
...sessions.warnings,
...agentDir.warnings,

View File

@@ -1,8 +1,30 @@
import os from "node:os";
import path from "node:path";
import { isMainThread, threadId } from "node:worker_threads";
import { resolveStateDir } from "../config/paths.js";
import { parseStrictNonNegativeInteger } from "../infra/parse-finite-number.js";
function resolveOpenClawStateRootDir(env: NodeJS.ProcessEnv): string {
if (env.OPENCLAW_STATE_DIR?.trim()) {
return resolveStateDir(env);
}
if (env.VITEST || env.NODE_ENV === "test") {
const workerId = parseStrictNonNegativeInteger(
env.VITEST_WORKER_ID ?? env.VITEST_POOL_ID ?? "",
);
const shardSuffix =
workerId !== undefined
? `${process.pid}-${workerId}`
: isMainThread
? String(process.pid)
: `${process.pid}-${threadId}`;
return path.join(os.tmpdir(), "openclaw-test-state", shardSuffix);
}
return resolveStateDir(env);
}
export function resolveOpenClawStateSqliteDir(env: NodeJS.ProcessEnv = process.env): string {
return path.join(resolveStateDir(env), "state");
return path.join(resolveOpenClawStateRootDir(env), "state");
}
export function resolveOpenClawStateSqlitePath(env: NodeJS.ProcessEnv = process.env): string {

View File

@@ -40,6 +40,17 @@ describe("openclaw state database", () => {
);
});
it("keeps test default state under a worker-sharded temp directory", () => {
expect(
resolveOpenClawStateSqlitePath({
VITEST: "true",
VITEST_WORKER_ID: "7",
} as NodeJS.ProcessEnv),
).toBe(
path.join(os.tmpdir(), "openclaw-test-state", `${process.pid}-7`, "state", "openclaw.sqlite"),
);
});
it("creates the shared state schema from the committed SQL shape", () => {
const stateDir = createTempStateDir();
const database = openOpenClawStateDatabase({

View File

@@ -1,10 +0,0 @@
import path from "node:path";
import { resolveTaskStateDir } from "./task-registry.paths.js";
export function resolveTaskFlowRegistryDir(env: NodeJS.ProcessEnv = process.env): string {
return path.join(resolveTaskStateDir(env), "flows");
}
export function resolveTaskFlowRegistrySqlitePath(env: NodeJS.ProcessEnv = process.env): string {
return path.join(resolveTaskFlowRegistryDir(env), "registry.sqlite");
}

View File

@@ -1,106 +1,84 @@
import type { DatabaseSync, StatementSync } from "node:sqlite";
import { requireNodeSqlite } from "../infra/node-sqlite.js";
import { configureSqliteWalMaintenance, type SqliteWalMaintenance } from "../infra/sqlite-wal.js";
import type { DatabaseSync } from "node:sqlite";
import type { Insertable, Selectable } from "kysely";
import { executeSqliteQuerySync, getNodeSqliteKysely } from "../infra/kysely-sync.js";
import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js";
import {
resolveTaskFlowRegistryDir,
resolveTaskFlowRegistrySqlitePath,
} from "./task-flow-registry.paths.js";
closeOpenClawStateDatabase,
openOpenClawStateDatabase,
runOpenClawStateWriteTransaction,
} from "../state/openclaw-state-db.js";
import type { TaskFlowRegistryStoreSnapshot } from "./task-flow-registry.store.types.js";
import type { TaskFlowRecord, TaskFlowSyncMode, JsonValue } from "./task-flow-registry.types.js";
import {
ensureSqliteStorePermissions,
normalizeSqliteNumber,
parseDeliveryContextJson,
parseSqliteJsonValue,
} from "./task-registry.sqlite.shared.js";
parseOptionalTaskFlowSyncMode,
parseTaskFlowStatus,
type JsonValue,
type TaskFlowRecord,
type TaskFlowSyncMode,
} from "./task-flow-registry.types.js";
import { parseDeliveryContextJson } from "./task-registry.sqlite.shared.js";
import { parseTaskNotifyPolicy } from "./task-registry.types.js";
type FlowRegistryRow = {
flow_id: string;
sync_mode: TaskFlowSyncMode | null;
shape?: string | null;
owner_key: string;
requester_origin_json: string | null;
controller_id: string | null;
revision: number | bigint | null;
status: TaskFlowRecord["status"];
notify_policy: TaskFlowRecord["notifyPolicy"];
goal: string;
current_step: string | null;
blocked_task_id: string | null;
blocked_summary: string | null;
state_json: string | null;
wait_json: string | null;
cancel_requested_at: number | bigint | null;
created_at: number | bigint;
updated_at: number | bigint;
ended_at: number | bigint | null;
};
type FlowRunsTable = OpenClawStateKyselyDatabase["flow_runs"];
type FlowRegistryStoreDatabase = Pick<OpenClawStateKyselyDatabase, "flow_runs">;
type FlowRegistryStatements = {
selectAll: StatementSync;
upsertRow: StatementSync;
deleteRow: StatementSync;
clearRows: StatementSync;
type FlowRegistryRow = Selectable<FlowRunsTable> & {
sync_mode: string | null;
status: string;
notify_policy: string;
};
type FlowRegistryDatabase = {
db: DatabaseSync;
path: string;
statements: FlowRegistryStatements;
walMaintenance: SqliteWalMaintenance;
};
let cachedDatabase: FlowRegistryDatabase | null = null;
const FLOW_REGISTRY_DIR_MODE = 0o700;
const FLOW_REGISTRY_FILE_MODE = 0o600;
const FLOW_RUNS_COLUMNS = `
flow_id TEXT PRIMARY KEY,
shape TEXT,
sync_mode TEXT NOT NULL DEFAULT 'managed',
owner_key TEXT NOT NULL,
requester_origin_json TEXT,
controller_id TEXT,
revision INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL,
notify_policy TEXT NOT NULL,
goal TEXT NOT NULL,
current_step TEXT,
blocked_task_id TEXT,
blocked_summary TEXT,
state_json TEXT,
wait_json TEXT,
cancel_requested_at INTEGER,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
ended_at INTEGER
`;
function normalizeNumber(value: number | bigint | null): number | undefined {
if (typeof value === "bigint") {
return Number(value);
}
return typeof value === "number" ? value : undefined;
}
function serializeJson(value: unknown): string | null {
return value === undefined ? null : JSON.stringify(value);
}
function parseJsonValue(raw: string | null): JsonValue | undefined {
if (!raw?.trim()) {
return undefined;
}
try {
return JSON.parse(raw) as JsonValue;
} catch {
return undefined;
}
}
function rowToSyncMode(row: FlowRegistryRow): TaskFlowSyncMode {
if (row.sync_mode === "task_mirrored" || row.sync_mode === "managed") {
return row.sync_mode;
const syncMode = parseOptionalTaskFlowSyncMode(row.sync_mode);
if (syncMode) {
return syncMode;
}
return row.shape === "single_task" ? "task_mirrored" : "managed";
}
function rowToFlowRecord(row: FlowRegistryRow): TaskFlowRecord {
const endedAt = normalizeSqliteNumber(row.ended_at);
const cancelRequestedAt = normalizeSqliteNumber(row.cancel_requested_at);
const endedAt = normalizeNumber(row.ended_at);
const cancelRequestedAt = normalizeNumber(row.cancel_requested_at);
const requesterOrigin = parseDeliveryContextJson(row.requester_origin_json);
const stateJson = parseSqliteJsonValue<JsonValue>(row.state_json);
const waitJson = parseSqliteJsonValue<JsonValue>(row.wait_json);
const stateJson = parseJsonValue(row.state_json);
const waitJson = parseJsonValue(row.wait_json);
return {
flowId: row.flow_id,
syncMode: rowToSyncMode(row),
ownerKey: row.owner_key,
...(requesterOrigin ? { requesterOrigin } : {}),
...(row.controller_id ? { controllerId: row.controller_id } : {}),
revision: normalizeSqliteNumber(row.revision) ?? 0,
status: row.status,
notifyPolicy: row.notify_policy,
revision: normalizeNumber(row.revision) ?? 0,
status: parseTaskFlowStatus(row.status),
notifyPolicy: parseTaskNotifyPolicy(row.notify_policy),
goal: row.goal,
...(row.current_step ? { currentStep: row.current_step } : {}),
...(row.blocked_task_id ? { blockedTaskId: row.blocked_task_id } : {}),
@@ -108,16 +86,17 @@ function rowToFlowRecord(row: FlowRegistryRow): TaskFlowRecord {
...(stateJson !== undefined ? { stateJson } : {}),
...(waitJson !== undefined ? { waitJson } : {}),
...(cancelRequestedAt != null ? { cancelRequestedAt } : {}),
createdAt: normalizeSqliteNumber(row.created_at) ?? 0,
updatedAt: normalizeSqliteNumber(row.updated_at) ?? 0,
createdAt: normalizeNumber(row.created_at) ?? 0,
updatedAt: normalizeNumber(row.updated_at) ?? 0,
...(endedAt != null ? { endedAt } : {}),
};
}
function bindFlowRecord(record: TaskFlowRecord) {
function bindFlowRecord(record: TaskFlowRecord): Insertable<FlowRunsTable> {
return {
flow_id: record.flowId,
sync_mode: record.syncMode,
shape: null,
owner_key: record.ownerKey,
requester_origin_json: serializeJson(record.requesterOrigin),
controller_id: record.controllerId ?? null,
@@ -137,346 +116,149 @@ function bindFlowRecord(record: TaskFlowRecord) {
};
}
function createStatements(db: DatabaseSync): FlowRegistryStatements {
return {
selectAll: db.prepare(`
SELECT
flow_id,
sync_mode,
shape,
owner_key,
requester_origin_json,
controller_id,
revision,
status,
notify_policy,
goal,
current_step,
blocked_task_id,
blocked_summary,
state_json,
wait_json,
cancel_requested_at,
created_at,
updated_at,
ended_at
FROM flow_runs
ORDER BY created_at ASC, flow_id ASC
`),
upsertRow: db.prepare(`
INSERT INTO flow_runs (
flow_id,
sync_mode,
owner_key,
requester_origin_json,
controller_id,
revision,
status,
notify_policy,
goal,
current_step,
blocked_task_id,
blocked_summary,
state_json,
wait_json,
cancel_requested_at,
created_at,
updated_at,
ended_at
) VALUES (
@flow_id,
@sync_mode,
@owner_key,
@requester_origin_json,
@controller_id,
@revision,
@status,
@notify_policy,
@goal,
@current_step,
@blocked_task_id,
@blocked_summary,
@state_json,
@wait_json,
@cancel_requested_at,
@created_at,
@updated_at,
@ended_at
)
ON CONFLICT(flow_id) DO UPDATE SET
sync_mode = excluded.sync_mode,
owner_key = excluded.owner_key,
requester_origin_json = excluded.requester_origin_json,
controller_id = excluded.controller_id,
revision = excluded.revision,
status = excluded.status,
notify_policy = excluded.notify_policy,
goal = excluded.goal,
current_step = excluded.current_step,
blocked_task_id = excluded.blocked_task_id,
blocked_summary = excluded.blocked_summary,
state_json = excluded.state_json,
wait_json = excluded.wait_json,
cancel_requested_at = excluded.cancel_requested_at,
created_at = excluded.created_at,
updated_at = excluded.updated_at,
ended_at = excluded.ended_at
`),
deleteRow: db.prepare(`DELETE FROM flow_runs WHERE flow_id = ?`),
clearRows: db.prepare(`DELETE FROM flow_runs`),
};
function getFlowRegistryKysely(db: DatabaseSync) {
return getNodeSqliteKysely<FlowRegistryStoreDatabase>(db);
}
function hasFlowRunsColumn(db: DatabaseSync, columnName: string): boolean {
const rows = db.prepare(`PRAGMA table_info(flow_runs)`).all() as Array<{ name?: string }>;
return rows.some((row) => row.name === columnName);
}
function rebuildLegacyFlowRunsTable(db: DatabaseSync) {
// Older live registries can retain owner_session_key TEXT NOT NULL even after owner_key is
// added. Current inserts do not write owner_session_key, so SQLite rejects mirrored flow rows
// until the table is rebuilt into the canonical schema.
db.exec(`
DROP TABLE IF EXISTS flow_runs_canonical_migration;
CREATE TABLE flow_runs_canonical_migration (
${FLOW_RUNS_COLUMNS}
);
INSERT INTO flow_runs_canonical_migration (
flow_id,
sync_mode,
owner_key,
requester_origin_json,
controller_id,
revision,
status,
notify_policy,
goal,
current_step,
blocked_task_id,
blocked_summary,
state_json,
wait_json,
cancel_requested_at,
created_at,
updated_at,
ended_at
function pruneFlowsNotInSnapshot(params: { db: DatabaseSync; ids: readonly string[] }) {
const tempTableName = "openclaw_live_flow_ids";
params.db.exec(`CREATE TEMP TABLE IF NOT EXISTS ${tempTableName} (id TEXT PRIMARY KEY)`);
params.db.exec(`DELETE FROM ${tempTableName}`);
const insert = params.db.prepare(`INSERT OR IGNORE INTO ${tempTableName} (id) VALUES (?)`);
for (const id of params.ids) {
insert.run(id);
}
params.db.exec(`
DELETE FROM flow_runs
WHERE NOT EXISTS (
SELECT 1 FROM ${tempTableName}
WHERE ${tempTableName}.id = flow_runs.flow_id
)
SELECT
flow_id,
CASE
WHEN sync_mode = 'task_mirrored' THEN 'task_mirrored'
ELSE 'managed'
END,
COALESCE(NULLIF(trim(owner_key), ''), owner_session_key),
requester_origin_json,
CASE
WHEN sync_mode = 'task_mirrored' THEN NULL
ELSE COALESCE(NULLIF(trim(controller_id), ''), 'core/legacy-restored')
END,
COALESCE(revision, 0),
status,
notify_policy,
goal,
current_step,
blocked_task_id,
blocked_summary,
state_json,
wait_json,
cancel_requested_at,
created_at,
updated_at,
ended_at
FROM flow_runs;
DROP TABLE flow_runs;
ALTER TABLE flow_runs_canonical_migration RENAME TO flow_runs;
`);
params.db.exec(`DELETE FROM ${tempTableName}`);
}
function ensureSchema(db: DatabaseSync) {
db.exec(`
CREATE TABLE IF NOT EXISTS flow_runs (
${FLOW_RUNS_COLUMNS}
);
`);
if (!hasFlowRunsColumn(db, "owner_key") && hasFlowRunsColumn(db, "owner_session_key")) {
db.exec(`ALTER TABLE flow_runs ADD COLUMN owner_key TEXT;`);
db.exec(`
UPDATE flow_runs
SET owner_key = owner_session_key
WHERE owner_key IS NULL
`);
}
if (!hasFlowRunsColumn(db, "shape")) {
db.exec(`ALTER TABLE flow_runs ADD COLUMN shape TEXT;`);
}
if (!hasFlowRunsColumn(db, "sync_mode")) {
db.exec(`ALTER TABLE flow_runs ADD COLUMN sync_mode TEXT;`);
if (hasFlowRunsColumn(db, "shape")) {
db.exec(`
UPDATE flow_runs
SET sync_mode = CASE
WHEN shape = 'single_task' THEN 'task_mirrored'
ELSE 'managed'
END
WHERE sync_mode IS NULL
`);
} else {
db.exec(`
UPDATE flow_runs
SET sync_mode = 'managed'
WHERE sync_mode IS NULL
`);
}
}
if (!hasFlowRunsColumn(db, "controller_id")) {
db.exec(`ALTER TABLE flow_runs ADD COLUMN controller_id TEXT;`);
}
db.exec(`
UPDATE flow_runs
SET controller_id = 'core/legacy-restored'
WHERE sync_mode = 'managed'
AND (controller_id IS NULL OR trim(controller_id) = '')
`);
if (!hasFlowRunsColumn(db, "revision")) {
db.exec(`ALTER TABLE flow_runs ADD COLUMN revision INTEGER;`);
db.exec(`
UPDATE flow_runs
SET revision = 0
WHERE revision IS NULL
`);
}
if (!hasFlowRunsColumn(db, "blocked_task_id")) {
db.exec(`ALTER TABLE flow_runs ADD COLUMN blocked_task_id TEXT;`);
}
if (!hasFlowRunsColumn(db, "blocked_summary")) {
db.exec(`ALTER TABLE flow_runs ADD COLUMN blocked_summary TEXT;`);
}
if (!hasFlowRunsColumn(db, "state_json")) {
db.exec(`ALTER TABLE flow_runs ADD COLUMN state_json TEXT;`);
}
if (!hasFlowRunsColumn(db, "wait_json")) {
db.exec(`ALTER TABLE flow_runs ADD COLUMN wait_json TEXT;`);
}
if (!hasFlowRunsColumn(db, "cancel_requested_at")) {
db.exec(`ALTER TABLE flow_runs ADD COLUMN cancel_requested_at INTEGER;`);
}
if (hasFlowRunsColumn(db, "owner_session_key")) {
// Populate the canonical fields before rebuilding so existing rows survive the legacy-column
// drop, including pre-sync-mode single-task flows and older managed flows with no controller.
db.exec(`
UPDATE flow_runs
SET owner_key = owner_session_key
WHERE (owner_key IS NULL OR trim(owner_key) = '')
`);
db.exec(`
UPDATE flow_runs
SET sync_mode = CASE
WHEN shape = 'single_task' THEN 'task_mirrored'
ELSE 'managed'
END
WHERE sync_mode IS NULL OR trim(sync_mode) = ''
`);
db.exec(`
UPDATE flow_runs
SET revision = 0
WHERE revision IS NULL
`);
db.exec(`
UPDATE flow_runs
SET controller_id = 'core/legacy-restored'
WHERE sync_mode = 'managed'
AND (controller_id IS NULL OR trim(controller_id) = '')
`);
rebuildLegacyFlowRunsTable(db);
}
db.exec(`CREATE INDEX IF NOT EXISTS idx_flow_runs_status ON flow_runs(status);`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_flow_runs_owner_key ON flow_runs(owner_key);`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_flow_runs_updated_at ON flow_runs(updated_at);`);
function selectFlowRows(db: DatabaseSync): FlowRegistryRow[] {
const query = getFlowRegistryKysely(db)
.selectFrom("flow_runs")
.select([
"flow_id",
"sync_mode",
"shape",
"owner_key",
"requester_origin_json",
"controller_id",
"revision",
"status",
"notify_policy",
"goal",
"current_step",
"blocked_task_id",
"blocked_summary",
"state_json",
"wait_json",
"cancel_requested_at",
"created_at",
"updated_at",
"ended_at",
])
.orderBy("created_at", "asc")
.orderBy("flow_id", "asc");
return executeSqliteQuerySync(db, query).rows;
}
function ensureFlowRegistryPermissions(pathname: string) {
ensureSqliteStorePermissions({
dir: resolveTaskFlowRegistryDir(process.env),
pathname,
dirMode: FLOW_REGISTRY_DIR_MODE,
fileMode: FLOW_REGISTRY_FILE_MODE,
});
function upsertFlowRow(db: DatabaseSync, row: Insertable<FlowRunsTable>): void {
executeSqliteQuerySync(
db,
getFlowRegistryKysely(db)
.insertInto("flow_runs")
.values(row)
.onConflict((conflict) =>
conflict.column("flow_id").doUpdateSet({
sync_mode: (eb) => eb.ref("excluded.sync_mode"),
owner_key: (eb) => eb.ref("excluded.owner_key"),
requester_origin_json: (eb) => eb.ref("excluded.requester_origin_json"),
controller_id: (eb) => eb.ref("excluded.controller_id"),
revision: (eb) => eb.ref("excluded.revision"),
status: (eb) => eb.ref("excluded.status"),
notify_policy: (eb) => eb.ref("excluded.notify_policy"),
goal: (eb) => eb.ref("excluded.goal"),
current_step: (eb) => eb.ref("excluded.current_step"),
blocked_task_id: (eb) => eb.ref("excluded.blocked_task_id"),
blocked_summary: (eb) => eb.ref("excluded.blocked_summary"),
state_json: (eb) => eb.ref("excluded.state_json"),
wait_json: (eb) => eb.ref("excluded.wait_json"),
cancel_requested_at: (eb) => eb.ref("excluded.cancel_requested_at"),
created_at: (eb) => eb.ref("excluded.created_at"),
updated_at: (eb) => eb.ref("excluded.updated_at"),
ended_at: (eb) => eb.ref("excluded.ended_at"),
}),
),
);
}
function openFlowRegistryDatabase(): FlowRegistryDatabase {
const pathname = resolveTaskFlowRegistrySqlitePath(process.env);
if (cachedDatabase && cachedDatabase.path === pathname) {
const database = openOpenClawStateDatabase();
const pathname = database.path;
if (cachedDatabase && cachedDatabase.path === pathname && cachedDatabase.db.isOpen) {
return cachedDatabase;
}
if (cachedDatabase) {
cachedDatabase.walMaintenance.close();
cachedDatabase.db.close();
if (cachedDatabase && !cachedDatabase.db.isOpen) {
cachedDatabase = null;
}
ensureFlowRegistryPermissions(pathname);
const { DatabaseSync } = requireNodeSqlite();
const db = new DatabaseSync(pathname);
const walMaintenance = configureSqliteWalMaintenance(db);
db.exec(`PRAGMA synchronous = NORMAL;`);
db.exec(`PRAGMA busy_timeout = 5000;`);
ensureSchema(db);
ensureFlowRegistryPermissions(pathname);
cachedDatabase = {
db,
db: database.db,
path: pathname,
statements: createStatements(db),
walMaintenance,
};
return cachedDatabase;
}
function withWriteTransaction(write: (statements: FlowRegistryStatements) => void) {
const { db, path, statements } = openFlowRegistryDatabase();
db.exec("BEGIN IMMEDIATE");
try {
write(statements);
db.exec("COMMIT");
ensureFlowRegistryPermissions(path);
} catch (error) {
db.exec("ROLLBACK");
throw error;
}
function withWriteTransaction(write: (database: FlowRegistryDatabase) => void) {
const database = openFlowRegistryDatabase();
runOpenClawStateWriteTransaction(() => {
write(database);
});
}
export function loadTaskFlowRegistryStateFromSqlite(): TaskFlowRegistryStoreSnapshot {
const { statements } = openFlowRegistryDatabase();
const rows = statements.selectAll.all() as FlowRegistryRow[];
const { db } = openFlowRegistryDatabase();
const rows = selectFlowRows(db);
return {
flows: new Map(rows.map((row) => [row.flow_id, rowToFlowRecord(row)])),
};
}
export function saveTaskFlowRegistryStateToSqlite(snapshot: TaskFlowRegistryStoreSnapshot) {
withWriteTransaction((statements) => {
statements.clearRows.run();
withWriteTransaction(({ db }) => {
const kysely = getFlowRegistryKysely(db);
const flowIds = [...snapshot.flows.keys()];
if (flowIds.length === 0) {
executeSqliteQuerySync(db, kysely.deleteFrom("flow_runs"));
return;
}
pruneFlowsNotInSnapshot({ db, ids: flowIds });
for (const flow of snapshot.flows.values()) {
statements.upsertRow.run(bindFlowRecord(flow));
upsertFlowRow(db, bindFlowRecord(flow));
}
});
}
export function upsertTaskFlowRegistryRecordToSqlite(flow: TaskFlowRecord) {
const store = openFlowRegistryDatabase();
store.statements.upsertRow.run(bindFlowRecord(flow));
ensureFlowRegistryPermissions(store.path);
withWriteTransaction(({ db }) => {
upsertFlowRow(db, bindFlowRecord(flow));
});
}
export function deleteTaskFlowRegistryRecordFromSqlite(flowId: string) {
const store = openFlowRegistryDatabase();
store.statements.deleteRow.run(flowId);
ensureFlowRegistryPermissions(store.path);
withWriteTransaction(({ db }) => {
executeSqliteQuerySync(
db,
getFlowRegistryKysely(db).deleteFrom("flow_runs").where("flow_id", "=", flowId),
);
});
}
export function closeTaskFlowRegistrySqliteStore() {
if (!cachedDatabase) {
return;
}
cachedDatabase.walMaintenance.close();
cachedDatabase.db.close();
export function closeTaskFlowRegistryDatabase() {
cachedDatabase = null;
closeOpenClawStateDatabase();
}

View File

@@ -1,6 +1,10 @@
import { statSync } from "node:fs";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { requireNodeSqlite } from "../infra/node-sqlite.js";
import { executeSqliteQuerySync, getNodeSqliteKysely } from "../infra/kysely-sync.js";
import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js";
import { openOpenClawStateDatabase } from "../state/openclaw-state-db.js";
import { resolveOpenClawStateSqlitePath } from "../state/openclaw-state-db.paths.js";
import { withOpenClawTestState } from "../test-utils/openclaw-test-state.js";
import {
createTaskFlowForTask,
@@ -10,12 +14,19 @@ import {
resetTaskFlowRegistryForTests,
setFlowWaiting,
} from "./task-flow-registry.js";
import {
resolveTaskFlowRegistryDir,
resolveTaskFlowRegistrySqlitePath,
} from "./task-flow-registry.paths.js";
import { configureTaskFlowRegistryRuntime } from "./task-flow-registry.store.js";
import type { TaskFlowRecord } from "./task-flow-registry.types.js";
import {
loadTaskFlowRegistryStateFromSqlite,
saveTaskFlowRegistryStateToSqlite,
} from "./task-flow-registry.store.sqlite.js";
import {
parseOptionalTaskFlowSyncMode,
parseTaskFlowStatus,
type TaskFlowRecord,
} from "./task-flow-registry.types.js";
import { parseTaskNotifyPolicy } from "./task-registry.types.js";
type TaskFlowRegistryTestDatabase = Pick<OpenClawStateKyselyDatabase, "flow_runs">;
function createStoredFlow(): TaskFlowRecord {
return {
@@ -126,6 +137,74 @@ describe("task-flow-registry store runtime", () => {
expect(restoredFlow.goal).toBe("Restored flow");
});
it("rejects invalid persisted flow enum values", () => {
expect(parseOptionalTaskFlowSyncMode("managed")).toBe("managed");
expect(parseOptionalTaskFlowSyncMode(null)).toBeUndefined();
expect(parseTaskFlowStatus("waiting")).toBe("waiting");
expect(parseTaskNotifyPolicy("state_changes")).toBe("state_changes");
expect(() => parseOptionalTaskFlowSyncMode("legacy")).toThrow(
"Invalid persisted task flow sync mode",
);
expect(() => parseTaskFlowStatus("done")).toThrow("Invalid persisted task flow status");
expect(() => parseTaskNotifyPolicy("verbose")).toThrow("Invalid persisted task notify policy");
});
it("rejects corrupt persisted flow rows during sqlite restore", async () => {
await withFlowRegistryTempDir(async (root) => {
process.env.OPENCLAW_STATE_DIR = root;
resetTaskFlowRegistryForTests();
const created = createManagedTaskFlow({
ownerKey: "agent:main:main",
controllerId: "tests/corrupt-flow",
goal: "Corrupt flow",
status: "running",
});
const database = openOpenClawStateDatabase();
const db = getNodeSqliteKysely<TaskFlowRegistryTestDatabase>(database.db);
executeSqliteQuerySync(
database.db,
db.updateTable("flow_runs").set({ status: "done" }).where("flow_id", "=", created.flowId),
);
expect(() => loadTaskFlowRegistryStateFromSqlite()).toThrow(
"Invalid persisted task flow status",
);
});
});
it("drops invalid requester origins during sqlite restore", async () => {
await withFlowRegistryTempDir(async (root) => {
process.env.OPENCLAW_STATE_DIR = root;
resetTaskFlowRegistryForTests();
const created = createManagedTaskFlow({
ownerKey: "agent:main:main",
controllerId: "tests/invalid-origin-flow",
goal: "Invalid origin flow",
requesterOrigin: {
channel: "test-channel",
to: "C1234567890",
},
});
const database = openOpenClawStateDatabase();
const db = getNodeSqliteKysely<TaskFlowRegistryTestDatabase>(database.db);
executeSqliteQuerySync(
database.db,
db
.updateTable("flow_runs")
.set({ requester_origin_json: '{"channel":42}' })
.where("flow_id", "=", created.flowId),
);
const restored = loadTaskFlowRegistryStateFromSqlite();
expect(restored.flows.get(created.flowId)?.requesterOrigin).toBeUndefined();
});
});
it("restores persisted wait-state, revision, and cancel intent from sqlite", async () => {
await withFlowRegistryTempDir(async (root) => {
process.env.OPENCLAW_STATE_DIR = root;
@@ -194,194 +273,31 @@ describe("task-flow-registry store runtime", () => {
});
});
it("migrates legacy owner_session_key schema before mirrored flow inserts", async () => {
it("prunes large sqlite snapshots without binding every flow id at once", async () => {
await withFlowRegistryTempDir(async (root) => {
process.env.OPENCLAW_STATE_DIR = root;
resetTaskFlowRegistryForTests();
const sqlitePath = resolveTaskFlowRegistrySqlitePath(process.env);
const { DatabaseSync } = requireNodeSqlite();
const db = new DatabaseSync(sqlitePath);
// This mirrors the live pre-migration table shape that kept owner_session_key as NOT NULL,
// which made current owner_key-only mirrored inserts fail with SQLITE_CONSTRAINT_NOTNULL.
db.exec(`
DROP TABLE IF EXISTS flow_runs;
CREATE TABLE flow_runs (
flow_id TEXT PRIMARY KEY,
owner_session_key TEXT NOT NULL,
requester_origin_json TEXT,
status TEXT NOT NULL,
notify_policy TEXT NOT NULL,
goal TEXT NOT NULL,
current_step TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
ended_at INTEGER
);
INSERT INTO flow_runs (
flow_id,
owner_session_key,
status,
notify_policy,
goal,
current_step,
created_at,
updated_at
) VALUES (
'legacy-flow',
'agent:main:legacy',
'running',
'done_only',
'Legacy flow',
'legacy_step',
10,
15
);
`);
db.close();
resetTaskFlowRegistryForTests({ persist: false });
const flows = new Map<string, TaskFlowRecord>();
for (let index = 0; index < 1_200; index++) {
const flow: TaskFlowRecord = {
...createStoredFlow(),
flowId: `flow-large-${index}`,
controllerId: `tests/large-flow-${index}`,
createdAt: index,
updatedAt: index,
};
flows.set(flow.flowId, flow);
}
const mirrored = createTaskFlowForTask({
task: {
ownerKey: "agent:main:main",
taskId: "task-mirrored",
notifyPolicy: "silent",
status: "queued",
label: "Context engine turn maintenance",
task: "Deferred context-engine maintenance after turn.",
createdAt: 20,
},
});
saveTaskFlowRegistryStateToSqlite({ flows });
const retainedFlows = new Map([...flows].slice(100));
saveTaskFlowRegistryStateToSqlite({ flows: retainedFlows });
expect(mirrored.syncMode).toBe("task_mirrored");
expect(mirrored.ownerKey).toBe("agent:main:main");
expect(mirrored.controllerId).toBeUndefined();
const legacy = getTaskFlowById("legacy-flow");
expect(legacy?.ownerKey).toBe("agent:main:legacy");
expect(legacy?.controllerId).toBe("core/legacy-restored");
const managed = createManagedTaskFlow({
ownerKey: "agent:main:fresh",
controllerId: "tests/migrated-flow",
goal: "Writable after migration",
});
expect(managed).toMatchObject({
ownerKey: "agent:main:fresh",
syncMode: "managed",
controllerId: "tests/migrated-flow",
});
const migratedDb = new DatabaseSync(sqlitePath);
const columns = migratedDb.prepare(`PRAGMA table_info(flow_runs)`).all() as Array<{
name?: string;
notnull?: number;
}>;
migratedDb.close();
expect(columns.map((column) => column.name)).not.toContain("owner_session_key");
expect(columns.find((column) => column.name === "owner_key")?.notnull).toBe(1);
});
});
it("backfills blank hybrid owner_key values before rebuilding legacy flow_runs tables", async () => {
await withFlowRegistryTempDir(async (root) => {
process.env.OPENCLAW_STATE_DIR = root;
resetTaskFlowRegistryForTests();
const sqlitePath = resolveTaskFlowRegistrySqlitePath(process.env);
const { DatabaseSync } = requireNodeSqlite();
const db = new DatabaseSync(sqlitePath);
db.exec(`
DROP TABLE IF EXISTS flow_runs;
CREATE TABLE flow_runs (
flow_id TEXT PRIMARY KEY,
owner_session_key TEXT NOT NULL,
owner_key TEXT,
requester_origin_json TEXT,
status TEXT NOT NULL,
notify_policy TEXT NOT NULL,
goal TEXT NOT NULL,
current_step TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
ended_at INTEGER
);
INSERT INTO flow_runs (
flow_id,
owner_session_key,
owner_key,
status,
notify_policy,
goal,
current_step,
created_at,
updated_at
) VALUES (
'hybrid-flow',
'agent:main:legacy-hybrid',
' ',
'queued',
'done_only',
'Hybrid flow',
NULL,
20,
20
);
`);
db.close();
resetTaskFlowRegistryForTests({ persist: false });
const hybrid = getTaskFlowById("hybrid-flow");
expect(hybrid).toMatchObject({
flowId: "hybrid-flow",
ownerKey: "agent:main:legacy-hybrid",
syncMode: "managed",
controllerId: "core/legacy-restored",
revision: 0,
status: "queued",
});
const migratedDb = new DatabaseSync(sqlitePath);
const columns = migratedDb.prepare(`PRAGMA table_info(flow_runs)`).all() as Array<{
name?: string;
notnull?: number;
}>;
migratedDb.close();
expect(columns.map((column) => column.name)).not.toContain("owner_session_key");
expect(columns.find((column) => column.name === "owner_key")?.notnull).toBe(1);
});
});
it("drops malformed requester origin json from sqlite flow state", async () => {
await withFlowRegistryTempDir(async (root) => {
process.env.OPENCLAW_STATE_DIR = root;
resetTaskFlowRegistryForTests();
const created = createManagedTaskFlow({
ownerKey: "agent:main:main",
requesterOrigin: {
channel: "notifychat",
to: "notifychat:123",
},
controllerId: "tests/malformed-origin",
goal: "Restore malformed origin",
status: "running",
});
const sqlitePath = resolveTaskFlowRegistrySqlitePath(process.env);
const { DatabaseSync } = requireNodeSqlite();
const db = new DatabaseSync(sqlitePath);
db.prepare(`UPDATE flow_runs SET requester_origin_json = ? WHERE flow_id = ?`).run(
JSON.stringify(["notifychat", "123"]),
created.flowId,
);
db.close();
resetTaskFlowRegistryForTests({ persist: false });
const restored = getTaskFlowById(created.flowId);
expect(restored?.flowId).toBe(created.flowId);
expect(restored?.requesterOrigin).toBeUndefined();
const restored = loadTaskFlowRegistryStateFromSqlite();
expect(restored.flows.size).toBe(1_100);
expect(restored.flows.has("flow-large-0")).toBe(false);
expect(restored.flows.has("flow-large-1199")).toBe(true);
});
});
@@ -403,10 +319,11 @@ describe("task-flow-registry store runtime", () => {
waitJson: { kind: "task", taskId: "task-secured" },
});
const registryDir = resolveTaskFlowRegistryDir(process.env);
const sqlitePath = resolveTaskFlowRegistrySqlitePath(process.env);
const databasePath = resolveOpenClawStateSqlitePath(process.env);
const registryDir = path.dirname(databasePath);
expect(databasePath.endsWith(path.join("state", "openclaw.sqlite"))).toBe(true);
expect(statSync(registryDir).mode & 0o777).toBe(0o700);
expect(statSync(sqlitePath).mode & 0o777).toBe(0o600);
expect(statSync(databasePath).mode & 0o777).toBe(0o600);
});
});
});

View File

@@ -1,5 +1,5 @@
import {
closeTaskFlowRegistrySqliteStore,
closeTaskFlowRegistryDatabase,
deleteTaskFlowRegistryRecordFromSqlite,
loadTaskFlowRegistryStateFromSqlite,
saveTaskFlowRegistryStateToSqlite,
@@ -44,7 +44,7 @@ const defaultFlowRegistryStore: TaskFlowRegistryStore = {
saveSnapshot: saveTaskFlowRegistryStateToSqlite,
upsertFlow: upsertTaskFlowRegistryRecordToSqlite,
deleteFlow: deleteTaskFlowRegistryRecordFromSqlite,
close: closeTaskFlowRegistrySqliteStore,
close: closeTaskFlowRegistryDatabase,
};
let configuredFlowRegistryStore: TaskFlowRegistryStore = defaultFlowRegistryStore;

View File

@@ -21,6 +21,40 @@ export type TaskFlowStatus =
| "cancelled"
| "lost";
const TASK_FLOW_SYNC_MODES = new Set<TaskFlowSyncMode>(["task_mirrored", "managed"]);
const TASK_FLOW_STATUSES = new Set<TaskFlowStatus>([
"queued",
"running",
"waiting",
"blocked",
"succeeded",
"failed",
"cancelled",
"lost",
]);
function parsePersistedFlowValue<T extends string>(
value: unknown,
values: ReadonlySet<T>,
label: string,
): T {
if (typeof value === "string" && values.has(value as T)) {
return value as T;
}
throw new Error(`Invalid persisted task flow ${label}: ${JSON.stringify(value)}`);
}
export function parseOptionalTaskFlowSyncMode(value: unknown): TaskFlowSyncMode | undefined {
if (value == null || value === "") {
return undefined;
}
return parsePersistedFlowValue(value, TASK_FLOW_SYNC_MODES, "sync mode");
}
export function parseTaskFlowStatus(value: unknown): TaskFlowStatus {
return parsePersistedFlowValue(value, TASK_FLOW_STATUSES, "status");
}
export type TaskFlowRecord = {
flowId: string;
syncMode: TaskFlowSyncMode;

View File

@@ -1,25 +0,0 @@
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { resolveTaskStateDir } from "./task-registry.paths.js";
describe("task registry paths", () => {
it("uses the Vitest worker id to shard test state dirs", () => {
expect(
resolveTaskStateDir({
VITEST: "true",
VITEST_POOL_ID: "7",
} as NodeJS.ProcessEnv),
).toBe(path.join(os.tmpdir(), "openclaw-test-state", `${process.pid}-7`));
});
it("prefers explicit state dir overrides over Vitest sharding", () => {
expect(
resolveTaskStateDir({
OPENCLAW_STATE_DIR: "/tmp/openclaw-custom-state",
VITEST: "true",
VITEST_POOL_ID: "7",
} as NodeJS.ProcessEnv),
).toBe("/tmp/openclaw-custom-state");
});
});

View File

@@ -1,32 +0,0 @@
import os from "node:os";
import path from "node:path";
import { isMainThread, threadId } from "node:worker_threads";
import { resolveStateDir } from "../config/paths.js";
import { parseStrictNonNegativeInteger } from "../infra/parse-finite-number.js";
export function resolveTaskStateDir(env: NodeJS.ProcessEnv = process.env): string {
const explicit = env.OPENCLAW_STATE_DIR?.trim();
if (explicit) {
return resolveStateDir(env);
}
if (env.VITEST || env.NODE_ENV === "test") {
const workerIdRaw = env.VITEST_WORKER_ID ?? env.VITEST_POOL_ID ?? "";
const workerId = parseStrictNonNegativeInteger(workerIdRaw);
const shardSuffix =
workerId !== undefined
? `${process.pid}-${workerId}`
: isMainThread
? String(process.pid)
: `${process.pid}-${threadId}`;
return path.join(os.tmpdir(), "openclaw-test-state", shardSuffix);
}
return resolveStateDir(env);
}
export function resolveTaskRegistryDir(env: NodeJS.ProcessEnv = process.env): string {
return path.join(resolveTaskStateDir(env), "tasks");
}
export function resolveTaskRegistrySqlitePath(env: NodeJS.ProcessEnv = process.env): string {
return path.join(resolveTaskRegistryDir(env), "runs.sqlite");
}

View File

@@ -1,124 +1,78 @@
import type { DatabaseSync, StatementSync } from "node:sqlite";
import { requireNodeSqlite } from "../infra/node-sqlite.js";
import { configureSqliteWalMaintenance, type SqliteWalMaintenance } from "../infra/sqlite-wal.js";
import { resolveTaskRegistryDir, resolveTaskRegistrySqlitePath } from "./task-registry.paths.js";
import type { DatabaseSync } from "node:sqlite";
import type { Insertable, Selectable } from "kysely";
import { executeSqliteQuerySync, getNodeSqliteKysely } from "../infra/kysely-sync.js";
import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js";
import {
ensureSqliteStorePermissions,
normalizeSqliteNumber,
parseDeliveryContextJson,
} from "./task-registry.sqlite.shared.js";
closeOpenClawStateDatabase,
openOpenClawStateDatabase,
runOpenClawStateWriteTransaction,
} from "../state/openclaw-state-db.js";
import { parseDeliveryContextJson } from "./task-registry.sqlite.shared.js";
import type { TaskRegistryStoreSnapshot } from "./task-registry.store.types.js";
import type { TaskDeliveryState, TaskRecord } from "./task-registry.types.js";
import {
parseOptionalTaskTerminalOutcome,
parseTaskDeliveryStatus,
parseTaskNotifyPolicy,
parseTaskRuntime,
parseTaskScopeKind,
parseTaskStatus,
type TaskDeliveryState,
type TaskRecord,
} from "./task-registry.types.js";
type TaskRegistryRow = {
task_id: string;
runtime: TaskRecord["runtime"];
task_kind: string | null;
source_id: string | null;
requester_session_key: string | null;
owner_key: string;
scope_kind: TaskRecord["scopeKind"];
child_session_key: string | null;
parent_flow_id: string | null;
parent_task_id: string | null;
agent_id: string | null;
run_id: string | null;
label: string | null;
task: string;
status: TaskRecord["status"];
delivery_status: TaskRecord["deliveryStatus"];
notify_policy: TaskRecord["notifyPolicy"];
created_at: number | bigint;
started_at: number | bigint | null;
ended_at: number | bigint | null;
last_event_at: number | bigint | null;
cleanup_after: number | bigint | null;
error: string | null;
progress_summary: string | null;
terminal_summary: string | null;
terminal_outcome: TaskRecord["terminalOutcome"] | null;
type TaskRunsTable = OpenClawStateKyselyDatabase["task_runs"];
type TaskDeliveryStateTable = OpenClawStateKyselyDatabase["task_delivery_state"];
type TaskRegistryStoreDatabase = Pick<
OpenClawStateKyselyDatabase,
"task_delivery_state" | "task_runs"
>;
type TaskRegistryRow = Selectable<TaskRunsTable> & {
runtime: string;
scope_kind: string;
status: string;
delivery_status: string;
notify_policy: string;
terminal_outcome: string | null;
};
type TaskDeliveryStateRow = {
task_id: string;
requester_origin_json: string | null;
last_notified_event_at: number | bigint | null;
};
type TableInfoRow = {
name: string;
};
type TaskRegistryStatements = {
selectAll: StatementSync;
selectByOwnerKey: StatementSync;
selectAllDeliveryStates: StatementSync;
upsertRow: StatementSync;
replaceDeliveryState: StatementSync;
deleteRow: StatementSync;
deleteDeliveryState: StatementSync;
clearRows: StatementSync;
clearDeliveryStates: StatementSync;
};
type TaskDeliveryStateRow = Selectable<TaskDeliveryStateTable>;
type TaskRegistryDatabase = {
db: DatabaseSync;
path: string;
statements: TaskRegistryStatements;
walMaintenance: SqliteWalMaintenance;
};
let cachedDatabase: TaskRegistryDatabase | null = null;
const TASK_REGISTRY_DIR_MODE = 0o700;
const TASK_REGISTRY_FILE_MODE = 0o600;
const TASK_RUN_SELECT_COLUMNS = `
task_id,
runtime,
task_kind,
source_id,
requester_session_key,
owner_key,
scope_kind,
child_session_key,
parent_flow_id,
parent_task_id,
agent_id,
run_id,
label,
task,
status,
delivery_status,
notify_policy,
created_at,
started_at,
ended_at,
last_event_at,
cleanup_after,
error,
progress_summary,
terminal_summary,
terminal_outcome
`;
function normalizeNumber(value: number | bigint | null): number | undefined {
if (typeof value === "bigint") {
return Number(value);
}
return typeof value === "number" ? value : undefined;
}
function serializeJson(value: unknown): string | null {
return value == null ? null : JSON.stringify(value);
}
function rowToTaskRecord(row: TaskRegistryRow): TaskRecord {
const startedAt = normalizeSqliteNumber(row.started_at);
const endedAt = normalizeSqliteNumber(row.ended_at);
const lastEventAt = normalizeSqliteNumber(row.last_event_at);
const cleanupAfter = normalizeSqliteNumber(row.cleanup_after);
const startedAt = normalizeNumber(row.started_at);
const endedAt = normalizeNumber(row.ended_at);
const lastEventAt = normalizeNumber(row.last_event_at);
const cleanupAfter = normalizeNumber(row.cleanup_after);
const scopeKind = parseTaskScopeKind(row.scope_kind);
const terminalOutcome = parseOptionalTaskTerminalOutcome(row.terminal_outcome);
const requesterSessionKey =
row.scope_kind === "system" ? "" : row.requester_session_key?.trim() || row.owner_key;
scopeKind === "system" ? "" : row.requester_session_key?.trim() || row.owner_key;
return {
taskId: row.task_id,
runtime: row.runtime,
runtime: parseTaskRuntime(row.runtime),
...(row.task_kind ? { taskKind: row.task_kind } : {}),
...(row.source_id ? { sourceId: row.source_id } : {}),
requesterSessionKey,
ownerKey: row.owner_key,
scopeKind: row.scope_kind,
scopeKind,
...(row.child_session_key ? { childSessionKey: row.child_session_key } : {}),
...(row.parent_flow_id ? { parentFlowId: row.parent_flow_id } : {}),
...(row.parent_task_id ? { parentTaskId: row.parent_task_id } : {}),
@@ -126,10 +80,10 @@ function rowToTaskRecord(row: TaskRegistryRow): TaskRecord {
...(row.run_id ? { runId: row.run_id } : {}),
...(row.label ? { label: row.label } : {}),
task: row.task,
status: row.status,
deliveryStatus: row.delivery_status,
notifyPolicy: row.notify_policy,
createdAt: normalizeSqliteNumber(row.created_at) ?? 0,
status: parseTaskStatus(row.status),
deliveryStatus: parseTaskDeliveryStatus(row.delivery_status),
notifyPolicy: parseTaskNotifyPolicy(row.notify_policy),
createdAt: normalizeNumber(row.created_at) ?? 0,
...(startedAt != null ? { startedAt } : {}),
...(endedAt != null ? { endedAt } : {}),
...(lastEventAt != null ? { lastEventAt } : {}),
@@ -137,13 +91,13 @@ function rowToTaskRecord(row: TaskRegistryRow): TaskRecord {
...(row.error ? { error: row.error } : {}),
...(row.progress_summary ? { progressSummary: row.progress_summary } : {}),
...(row.terminal_summary ? { terminalSummary: row.terminal_summary } : {}),
...(row.terminal_outcome ? { terminalOutcome: row.terminal_outcome } : {}),
...(terminalOutcome ? { terminalOutcome } : {}),
};
}
function rowToTaskDeliveryState(row: TaskDeliveryStateRow): TaskDeliveryState {
const requesterOrigin = parseDeliveryContextJson(row.requester_origin_json);
const lastNotifiedEventAt = normalizeSqliteNumber(row.last_notified_event_at);
const lastNotifiedEventAt = normalizeNumber(row.last_notified_event_at);
return {
taskId: row.task_id,
...(requesterOrigin ? { requesterOrigin } : {}),
@@ -151,7 +105,7 @@ function rowToTaskDeliveryState(row: TaskDeliveryStateRow): TaskDeliveryState {
};
}
function bindTaskRecordBase(record: TaskRecord) {
function bindTaskRecordBase(record: TaskRecord): Insertable<TaskRunsTable> {
return {
task_id: record.taskId,
runtime: record.runtime,
@@ -182,7 +136,7 @@ function bindTaskRecordBase(record: TaskRecord) {
};
}
function bindTaskDeliveryState(state: TaskDeliveryState) {
function bindTaskDeliveryState(state: TaskDeliveryState): Insertable<TaskDeliveryStateTable> {
return {
task_id: state.taskId,
requester_origin_json: serializeJson(state.requesterOrigin),
@@ -190,287 +144,160 @@ function bindTaskDeliveryState(state: TaskDeliveryState) {
};
}
function createStatements(db: DatabaseSync): TaskRegistryStatements {
return {
selectAll: db.prepare(`
SELECT
${TASK_RUN_SELECT_COLUMNS}
FROM task_runs
ORDER BY created_at ASC, task_id ASC
`),
selectByOwnerKey: db.prepare(`
SELECT
${TASK_RUN_SELECT_COLUMNS}
FROM task_runs
WHERE owner_key = ?
ORDER BY created_at ASC, task_id ASC
`),
selectAllDeliveryStates: db.prepare(`
SELECT
task_id,
requester_origin_json,
last_notified_event_at
FROM task_delivery_state
ORDER BY task_id ASC
`),
upsertRow: db.prepare(`
INSERT INTO task_runs (
task_id,
runtime,
task_kind,
source_id,
requester_session_key,
owner_key,
scope_kind,
child_session_key,
parent_flow_id,
parent_task_id,
agent_id,
run_id,
label,
task,
status,
delivery_status,
notify_policy,
created_at,
started_at,
ended_at,
last_event_at,
cleanup_after,
error,
progress_summary,
terminal_summary,
terminal_outcome
) VALUES (
@task_id,
@runtime,
@task_kind,
@source_id,
@requester_session_key,
@owner_key,
@scope_kind,
@child_session_key,
@parent_flow_id,
@parent_task_id,
@agent_id,
@run_id,
@label,
@task,
@status,
@delivery_status,
@notify_policy,
@created_at,
@started_at,
@ended_at,
@last_event_at,
@cleanup_after,
@error,
@progress_summary,
@terminal_summary,
@terminal_outcome
)
ON CONFLICT(task_id) DO UPDATE SET
runtime = excluded.runtime,
task_kind = excluded.task_kind,
source_id = excluded.source_id,
requester_session_key = excluded.requester_session_key,
owner_key = excluded.owner_key,
scope_kind = excluded.scope_kind,
child_session_key = excluded.child_session_key,
parent_flow_id = excluded.parent_flow_id,
parent_task_id = excluded.parent_task_id,
agent_id = excluded.agent_id,
run_id = excluded.run_id,
label = excluded.label,
task = excluded.task,
status = excluded.status,
delivery_status = excluded.delivery_status,
notify_policy = excluded.notify_policy,
created_at = excluded.created_at,
started_at = excluded.started_at,
ended_at = excluded.ended_at,
last_event_at = excluded.last_event_at,
cleanup_after = excluded.cleanup_after,
error = excluded.error,
progress_summary = excluded.progress_summary,
terminal_summary = excluded.terminal_summary,
terminal_outcome = excluded.terminal_outcome
`),
replaceDeliveryState: db.prepare(`
INSERT OR REPLACE INTO task_delivery_state (
task_id,
requester_origin_json,
last_notified_event_at
) VALUES (
@task_id,
@requester_origin_json,
@last_notified_event_at
)
`),
deleteRow: db.prepare(`DELETE FROM task_runs WHERE task_id = ?`),
deleteDeliveryState: db.prepare(`DELETE FROM task_delivery_state WHERE task_id = ?`),
clearRows: db.prepare(`DELETE FROM task_runs`),
clearDeliveryStates: db.prepare(`DELETE FROM task_delivery_state`),
};
function getTaskRegistryKysely(db: DatabaseSync) {
return getNodeSqliteKysely<TaskRegistryStoreDatabase>(db);
}
function hasTaskRunsColumn(db: DatabaseSync, columnName: string): boolean {
const rows = db.prepare(`PRAGMA table_info(task_runs)`).all() as TableInfoRow[];
return rows.some((row) => row.name === columnName);
function pruneRowsNotInSnapshot(params: {
db: DatabaseSync;
tableName: "task_delivery_state" | "task_runs";
columnName: "task_id";
tempTableName: string;
ids: readonly string[];
}) {
params.db.exec(`CREATE TEMP TABLE IF NOT EXISTS ${params.tempTableName} (id TEXT PRIMARY KEY)`);
params.db.exec(`DELETE FROM ${params.tempTableName}`);
const insert = params.db.prepare(`INSERT OR IGNORE INTO ${params.tempTableName} (id) VALUES (?)`);
for (const id of params.ids) {
insert.run(id);
}
params.db.exec(`
DELETE FROM ${params.tableName}
WHERE NOT EXISTS (
SELECT 1 FROM ${params.tempTableName}
WHERE ${params.tempTableName}.id = ${params.tableName}.${params.columnName}
)
`);
params.db.exec(`DELETE FROM ${params.tempTableName}`);
}
function migrateLegacyOwnerColumns(db: DatabaseSync) {
if (!hasTaskRunsColumn(db, "owner_key")) {
db.exec(`ALTER TABLE task_runs ADD COLUMN owner_key TEXT;`);
}
if (!hasTaskRunsColumn(db, "requester_session_key")) {
db.exec(`ALTER TABLE task_runs ADD COLUMN requester_session_key TEXT;`);
}
if (!hasTaskRunsColumn(db, "scope_kind")) {
db.exec(`ALTER TABLE task_runs ADD COLUMN scope_kind TEXT NOT NULL DEFAULT 'session';`);
}
if (hasTaskRunsColumn(db, "requester_session_key")) {
db.exec(`
UPDATE task_runs
SET owner_key = requester_session_key
WHERE owner_key IS NULL
`);
}
db.exec(`
UPDATE task_runs
SET owner_key = CASE
WHEN trim(COALESCE(owner_key, '')) <> '' THEN trim(owner_key)
ELSE 'system:' || runtime || ':' || COALESCE(NULLIF(source_id, ''), task_id)
END
`);
db.exec(`
UPDATE task_runs
SET scope_kind = CASE
WHEN scope_kind = 'system' THEN 'system'
WHEN owner_key LIKE 'system:%' THEN 'system'
ELSE 'session'
END
`);
db.exec(`
UPDATE task_runs
SET requester_session_key = CASE
WHEN scope_kind = 'system' THEN ''
WHEN trim(COALESCE(requester_session_key, '')) <> '' THEN trim(requester_session_key)
ELSE owner_key
END
`);
function selectTaskRows(db: DatabaseSync): TaskRegistryRow[] {
const query = getTaskRegistryKysely(db)
.selectFrom("task_runs")
.select([
"task_id",
"runtime",
"task_kind",
"source_id",
"requester_session_key",
"owner_key",
"scope_kind",
"child_session_key",
"parent_flow_id",
"parent_task_id",
"agent_id",
"run_id",
"label",
"task",
"status",
"delivery_status",
"notify_policy",
"created_at",
"started_at",
"ended_at",
"last_event_at",
"cleanup_after",
"error",
"progress_summary",
"terminal_summary",
"terminal_outcome",
])
.orderBy("created_at", "asc")
.orderBy("task_id", "asc");
return executeSqliteQuerySync(db, query).rows;
}
function ensureSchema(db: DatabaseSync) {
db.exec(`
CREATE TABLE IF NOT EXISTS task_runs (
task_id TEXT PRIMARY KEY,
runtime TEXT NOT NULL,
task_kind TEXT,
source_id TEXT,
requester_session_key TEXT,
owner_key TEXT NOT NULL,
scope_kind TEXT NOT NULL,
child_session_key TEXT,
parent_flow_id TEXT,
parent_task_id TEXT,
agent_id TEXT,
run_id TEXT,
label TEXT,
task TEXT NOT NULL,
status TEXT NOT NULL,
delivery_status TEXT NOT NULL,
notify_policy TEXT NOT NULL,
created_at INTEGER NOT NULL,
started_at INTEGER,
ended_at INTEGER,
last_event_at INTEGER,
cleanup_after INTEGER,
error TEXT,
progress_summary TEXT,
terminal_summary TEXT,
terminal_outcome TEXT
);
`);
migrateLegacyOwnerColumns(db);
if (!hasTaskRunsColumn(db, "task_kind")) {
db.exec(`ALTER TABLE task_runs ADD COLUMN task_kind TEXT;`);
}
if (!hasTaskRunsColumn(db, "parent_flow_id")) {
db.exec(`ALTER TABLE task_runs ADD COLUMN parent_flow_id TEXT;`);
}
db.exec(`
CREATE TABLE IF NOT EXISTS task_delivery_state (
task_id TEXT PRIMARY KEY,
requester_origin_json TEXT,
last_notified_event_at INTEGER
);
`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_task_runs_run_id ON task_runs(run_id);`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_task_runs_status ON task_runs(status);`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_task_runs_runtime_status ON task_runs(runtime, status);`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_task_runs_cleanup_after ON task_runs(cleanup_after);`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_task_runs_last_event_at ON task_runs(last_event_at);`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_task_runs_owner_key ON task_runs(owner_key);`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_task_runs_parent_flow_id ON task_runs(parent_flow_id);`);
db.exec(
`CREATE INDEX IF NOT EXISTS idx_task_runs_child_session_key ON task_runs(child_session_key);`,
function selectTaskDeliveryStateRows(db: DatabaseSync): TaskDeliveryStateRow[] {
const query = getTaskRegistryKysely(db)
.selectFrom("task_delivery_state")
.select(["task_id", "requester_origin_json", "last_notified_event_at"])
.orderBy("task_id", "asc");
return executeSqliteQuerySync(db, query).rows;
}
function upsertTaskRow(db: DatabaseSync, row: Insertable<TaskRunsTable>): void {
executeSqliteQuerySync(
db,
getTaskRegistryKysely(db)
.insertInto("task_runs")
.values(row)
.onConflict((conflict) =>
conflict.column("task_id").doUpdateSet({
runtime: (eb) => eb.ref("excluded.runtime"),
task_kind: (eb) => eb.ref("excluded.task_kind"),
source_id: (eb) => eb.ref("excluded.source_id"),
requester_session_key: (eb) => eb.ref("excluded.requester_session_key"),
owner_key: (eb) => eb.ref("excluded.owner_key"),
scope_kind: (eb) => eb.ref("excluded.scope_kind"),
child_session_key: (eb) => eb.ref("excluded.child_session_key"),
parent_flow_id: (eb) => eb.ref("excluded.parent_flow_id"),
parent_task_id: (eb) => eb.ref("excluded.parent_task_id"),
agent_id: (eb) => eb.ref("excluded.agent_id"),
run_id: (eb) => eb.ref("excluded.run_id"),
label: (eb) => eb.ref("excluded.label"),
task: (eb) => eb.ref("excluded.task"),
status: (eb) => eb.ref("excluded.status"),
delivery_status: (eb) => eb.ref("excluded.delivery_status"),
notify_policy: (eb) => eb.ref("excluded.notify_policy"),
created_at: (eb) => eb.ref("excluded.created_at"),
started_at: (eb) => eb.ref("excluded.started_at"),
ended_at: (eb) => eb.ref("excluded.ended_at"),
last_event_at: (eb) => eb.ref("excluded.last_event_at"),
cleanup_after: (eb) => eb.ref("excluded.cleanup_after"),
error: (eb) => eb.ref("excluded.error"),
progress_summary: (eb) => eb.ref("excluded.progress_summary"),
terminal_summary: (eb) => eb.ref("excluded.terminal_summary"),
terminal_outcome: (eb) => eb.ref("excluded.terminal_outcome"),
}),
),
);
}
function ensureTaskRegistryPermissions(pathname: string) {
ensureSqliteStorePermissions({
dir: resolveTaskRegistryDir(process.env),
pathname,
dirMode: TASK_REGISTRY_DIR_MODE,
fileMode: TASK_REGISTRY_FILE_MODE,
});
function replaceTaskDeliveryStateRow(
db: DatabaseSync,
row: Insertable<TaskDeliveryStateTable>,
): void {
executeSqliteQuerySync(
db,
getTaskRegistryKysely(db)
.insertInto("task_delivery_state")
.values(row)
.onConflict((conflict) =>
conflict.column("task_id").doUpdateSet({
requester_origin_json: (eb) => eb.ref("excluded.requester_origin_json"),
last_notified_event_at: (eb) => eb.ref("excluded.last_notified_event_at"),
}),
),
);
}
function openTaskRegistryDatabase(): TaskRegistryDatabase {
const pathname = resolveTaskRegistrySqlitePath(process.env);
if (cachedDatabase && cachedDatabase.path === pathname) {
const database = openOpenClawStateDatabase();
const pathname = database.path;
if (cachedDatabase && cachedDatabase.path === pathname && cachedDatabase.db.isOpen) {
return cachedDatabase;
}
if (cachedDatabase) {
cachedDatabase.walMaintenance.close();
cachedDatabase.db.close();
if (cachedDatabase && !cachedDatabase.db.isOpen) {
cachedDatabase = null;
}
ensureTaskRegistryPermissions(pathname);
const { DatabaseSync } = requireNodeSqlite();
const db = new DatabaseSync(pathname);
const walMaintenance = configureSqliteWalMaintenance(db);
db.exec(`PRAGMA synchronous = NORMAL;`);
db.exec(`PRAGMA busy_timeout = 5000;`);
ensureSchema(db);
ensureTaskRegistryPermissions(pathname);
cachedDatabase = {
db,
db: database.db,
path: pathname,
statements: createStatements(db),
walMaintenance,
};
return cachedDatabase;
}
function withWriteTransaction(write: (statements: TaskRegistryStatements) => void) {
const { db, path, statements } = openTaskRegistryDatabase();
db.exec("BEGIN IMMEDIATE");
try {
write(statements);
db.exec("COMMIT");
ensureTaskRegistryPermissions(path);
} catch (error) {
db.exec("ROLLBACK");
throw error;
}
function withWriteTransaction(write: (database: TaskRegistryDatabase) => void) {
const database = openTaskRegistryDatabase();
runOpenClawStateWriteTransaction(() => {
write(database);
});
}
export function loadTaskRegistryStateFromSqlite(): TaskRegistryStoreSnapshot {
const { statements } = openTaskRegistryDatabase();
const taskRows = statements.selectAll.all() as TaskRegistryRow[];
const deliveryRows = statements.selectAllDeliveryStates.all() as TaskDeliveryStateRow[];
const { db } = openTaskRegistryDatabase();
const taskRows = selectTaskRows(db);
const deliveryRows = selectTaskDeliveryStateRows(db);
return {
tasks: new Map(taskRows.map((row) => [row.task_id, rowToTaskRecord(row)])),
deliveryStates: new Map(deliveryRows.map((row) => [row.task_id, rowToTaskDeliveryState(row)])),
@@ -482,71 +309,144 @@ export function listTaskRegistryRecordsByOwnerKeyFromSqlite(ownerKey: string): T
if (!key) {
return [];
}
const { statements } = openTaskRegistryDatabase();
const rows = statements.selectByOwnerKey.all(key) as TaskRegistryRow[];
const { db } = openTaskRegistryDatabase();
const query = getTaskRegistryKysely(db)
.selectFrom("task_runs")
.select([
"task_id",
"runtime",
"task_kind",
"source_id",
"requester_session_key",
"owner_key",
"scope_kind",
"child_session_key",
"parent_flow_id",
"parent_task_id",
"agent_id",
"run_id",
"label",
"task",
"status",
"delivery_status",
"notify_policy",
"created_at",
"started_at",
"ended_at",
"last_event_at",
"cleanup_after",
"error",
"progress_summary",
"terminal_summary",
"terminal_outcome",
])
.where("owner_key", "=", key)
.orderBy("created_at", "asc")
.orderBy("task_id", "asc");
const rows = executeSqliteQuerySync(db, query).rows;
return rows.map(rowToTaskRecord);
}
export function saveTaskRegistryStateToSqlite(snapshot: TaskRegistryStoreSnapshot) {
withWriteTransaction((statements) => {
statements.clearDeliveryStates.run();
statements.clearRows.run();
withWriteTransaction(({ db }) => {
const kysely = getTaskRegistryKysely(db);
const taskIds = [...snapshot.tasks.keys()];
if (taskIds.length === 0) {
executeSqliteQuerySync(db, kysely.deleteFrom("task_delivery_state"));
executeSqliteQuerySync(db, kysely.deleteFrom("task_runs"));
return;
}
pruneRowsNotInSnapshot({
db,
tableName: "task_runs",
columnName: "task_id",
tempTableName: "openclaw_live_task_run_ids",
ids: taskIds,
});
const deliveryTaskIds = [...snapshot.deliveryStates.keys()];
if (deliveryTaskIds.length === 0) {
executeSqliteQuerySync(db, kysely.deleteFrom("task_delivery_state"));
} else {
pruneRowsNotInSnapshot({
db,
tableName: "task_delivery_state",
columnName: "task_id",
tempTableName: "openclaw_live_task_delivery_ids",
ids: deliveryTaskIds,
});
}
for (const task of snapshot.tasks.values()) {
statements.upsertRow.run(bindTaskRecordBase(task));
upsertTaskRow(db, bindTaskRecordBase(task));
}
for (const state of snapshot.deliveryStates.values()) {
statements.replaceDeliveryState.run(bindTaskDeliveryState(state));
replaceTaskDeliveryStateRow(db, bindTaskDeliveryState(state));
}
});
}
export function upsertTaskRegistryRecordToSqlite(task: TaskRecord) {
const store = openTaskRegistryDatabase();
store.statements.upsertRow.run(bindTaskRecordBase(task));
withWriteTransaction(({ db }) => {
upsertTaskRow(db, bindTaskRecordBase(task));
});
}
export function upsertTaskWithDeliveryStateToSqlite(params: {
task: TaskRecord;
deliveryState?: TaskDeliveryState;
}) {
withWriteTransaction((statements) => {
statements.upsertRow.run(bindTaskRecordBase(params.task));
withWriteTransaction(({ db }) => {
upsertTaskRow(db, bindTaskRecordBase(params.task));
if (params.deliveryState) {
statements.replaceDeliveryState.run(bindTaskDeliveryState(params.deliveryState));
replaceTaskDeliveryStateRow(db, bindTaskDeliveryState(params.deliveryState));
} else {
statements.deleteDeliveryState.run(params.task.taskId);
executeSqliteQuerySync(
db,
getTaskRegistryKysely(db)
.deleteFrom("task_delivery_state")
.where("task_id", "=", params.task.taskId),
);
}
});
}
export function deleteTaskRegistryRecordFromSqlite(taskId: string) {
const store = openTaskRegistryDatabase();
store.statements.deleteRow.run(taskId);
store.statements.deleteDeliveryState.run(taskId);
withWriteTransaction(({ db }) => {
const kysely = getTaskRegistryKysely(db);
executeSqliteQuerySync(
db,
kysely.deleteFrom("task_delivery_state").where("task_id", "=", taskId),
);
executeSqliteQuerySync(db, kysely.deleteFrom("task_runs").where("task_id", "=", taskId));
});
}
export function deleteTaskAndDeliveryStateFromSqlite(taskId: string) {
withWriteTransaction((statements) => {
statements.deleteRow.run(taskId);
statements.deleteDeliveryState.run(taskId);
withWriteTransaction(({ db }) => {
const kysely = getTaskRegistryKysely(db);
executeSqliteQuerySync(
db,
kysely.deleteFrom("task_delivery_state").where("task_id", "=", taskId),
);
executeSqliteQuerySync(db, kysely.deleteFrom("task_runs").where("task_id", "=", taskId));
});
}
export function upsertTaskDeliveryStateToSqlite(state: TaskDeliveryState) {
const store = openTaskRegistryDatabase();
store.statements.replaceDeliveryState.run(bindTaskDeliveryState(state));
withWriteTransaction(({ db }) => {
replaceTaskDeliveryStateRow(db, bindTaskDeliveryState(state));
});
}
export function deleteTaskDeliveryStateFromSqlite(taskId: string) {
const store = openTaskRegistryDatabase();
store.statements.deleteDeliveryState.run(taskId);
withWriteTransaction(({ db }) => {
executeSqliteQuerySync(
db,
getTaskRegistryKysely(db).deleteFrom("task_delivery_state").where("task_id", "=", taskId),
);
});
}
export function closeTaskRegistrySqliteStore() {
if (!cachedDatabase) {
return;
}
cachedDatabase.walMaintenance.close();
cachedDatabase.db.close();
export function closeTaskRegistryDatabase() {
cachedDatabase = null;
closeOpenClawStateDatabase();
}

View File

@@ -1,7 +1,13 @@
import { mkdirSync, statSync } from "node:fs";
import { statSync } from "node:fs";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { requireNodeSqlite } from "../infra/node-sqlite.js";
import { executeSqliteQuerySync, getNodeSqliteKysely } from "../infra/kysely-sync.js";
import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js";
import {
closeOpenClawStateDatabase,
openOpenClawStateDatabase,
} from "../state/openclaw-state-db.js";
import { resolveOpenClawStateSqlitePath } from "../state/openclaw-state-db.paths.js";
import { withOpenClawTestState } from "../test-utils/openclaw-test-state.js";
import { createManagedTaskFlow, resetTaskFlowRegistryForTests } from "./task-flow-registry.js";
import {
@@ -14,14 +20,29 @@ import {
maybeDeliverTaskStateChangeUpdate,
resetTaskRegistryForTests,
} from "./task-registry.js";
import { resolveTaskRegistryDir, resolveTaskRegistrySqlitePath } from "./task-registry.paths.js";
import {
configureTaskRegistryRuntime,
type TaskRegistryObserverEvent,
} from "./task-registry.store.js";
import type { TaskRecord } from "./task-registry.types.js";
import {
loadTaskRegistryStateFromSqlite,
saveTaskRegistryStateToSqlite,
} from "./task-registry.store.sqlite.js";
import type { TaskDeliveryState, TaskRecord } from "./task-registry.types.js";
import {
parseOptionalTaskTerminalOutcome,
parseTaskDeliveryStatus,
parseTaskNotifyPolicy,
parseTaskRuntime,
parseTaskScopeKind,
parseTaskStatus,
} from "./task-registry.types.js";
const ORIGINAL_STATE_DIR = process.env.OPENCLAW_STATE_DIR;
type TaskRegistryTestDatabase = Pick<
OpenClawStateKyselyDatabase,
"task_delivery_state" | "task_runs"
>;
function requireFirstUpsertParams(upsertTaskWithDeliveryState: ReturnType<typeof vi.fn>): {
task?: { taskId?: string };
@@ -82,9 +103,10 @@ describe("task-registry store runtime", () => {
},
});
const restored = findTaskByRunId("run-restored");
expect(restored?.taskId).toBe("task-restored");
expect(restored?.task).toBe("Restored task");
expect(findTaskByRunId("run-restored")).toMatchObject({
taskId: "task-restored",
task: "Restored task",
});
expect(loadSnapshot).toHaveBeenCalledTimes(1);
createTaskRecord({
@@ -128,6 +150,90 @@ describe("task-registry store runtime", () => {
expect(loadSnapshot).toHaveBeenCalledTimes(1);
});
it("rejects invalid persisted task enum values", () => {
expect(parseTaskRuntime("cron")).toBe("cron");
expect(parseTaskScopeKind("system")).toBe("system");
expect(parseTaskStatus("running")).toBe("running");
expect(parseTaskDeliveryStatus("pending")).toBe("pending");
expect(parseTaskNotifyPolicy("done_only")).toBe("done_only");
expect(parseOptionalTaskTerminalOutcome("blocked")).toBe("blocked");
expect(parseOptionalTaskTerminalOutcome(null)).toBeUndefined();
expect(() => parseTaskRuntime("timer")).toThrow("Invalid persisted task runtime");
expect(() => parseTaskScopeKind("workspace")).toThrow("Invalid persisted task scope kind");
expect(() => parseTaskStatus("done")).toThrow("Invalid persisted task status");
expect(() => parseTaskDeliveryStatus("ok")).toThrow("Invalid persisted task delivery status");
expect(() => parseTaskNotifyPolicy("verbose")).toThrow("Invalid persisted task notify policy");
expect(() => parseOptionalTaskTerminalOutcome("failed")).toThrow(
"Invalid persisted task terminal outcome",
);
});
it("rejects corrupt persisted task rows during sqlite restore", async () => {
await withOpenClawTestState(
{ layout: "state-only", prefix: "openclaw-task-store-corrupt-" },
async () => {
resetTaskRegistryForTests();
const created = createTaskRecord({
runtime: "cron",
ownerKey: "agent:main:main",
scopeKind: "session",
sourceId: "job-corrupt",
runId: "run-corrupt-task-status",
task: "Corrupt task row",
status: "running",
deliveryStatus: "not_applicable",
notifyPolicy: "silent",
});
const database = openOpenClawStateDatabase();
const db = getNodeSqliteKysely<TaskRegistryTestDatabase>(database.db);
executeSqliteQuerySync(
database.db,
db.updateTable("task_runs").set({ status: "done" }).where("task_id", "=", created.taskId),
);
expect(() => loadTaskRegistryStateFromSqlite()).toThrow("Invalid persisted task status");
},
);
});
it("drops invalid requester origins during sqlite restore", async () => {
await withOpenClawTestState(
{ layout: "state-only", prefix: "openclaw-task-store-invalid-origin-" },
async () => {
resetTaskRegistryForTests();
const created = createTaskRecord({
runtime: "acp",
ownerKey: "agent:main:main",
scopeKind: "session",
childSessionKey: "agent:main:acp:origin",
runId: "run-invalid-origin",
task: "Invalid origin task",
status: "running",
deliveryStatus: "pending",
requesterOrigin: {
channel: "test-channel",
to: "C1234567890",
},
});
const database = openOpenClawStateDatabase();
const db = getNodeSqliteKysely<TaskRegistryTestDatabase>(database.db);
executeSqliteQuerySync(
database.db,
db
.updateTable("task_delivery_state")
.set({ requester_origin_json: '["bad-origin"]' })
.where("task_id", "=", created.taskId),
);
const restored = loadTaskRegistryStateFromSqlite();
expect(restored.deliveryStates.get(created.taskId)?.requesterOrigin).toBeUndefined();
},
);
});
it("emits incremental observer events for restore, mutation, and delete", () => {
const events: TaskRegistryObserverEvent[] = [];
configureTaskRegistryRuntime({
@@ -145,10 +251,11 @@ describe("task-registry store runtime", () => {
},
});
const restored = findTaskByRunId("run-restored");
expect(restored?.runId).toBe("run-restored");
expect(restored?.taskId).toBe("task-restored");
expect(restored?.task).toBe("Restored task");
expect(findTaskByRunId("run-restored")).toMatchObject({
runId: "run-restored",
taskId: "task-restored",
task: "Restored task",
});
const created = createTaskRecord({
runtime: "acp",
ownerKey: "agent:main:main",
@@ -162,26 +269,18 @@ describe("task-registry store runtime", () => {
expect(deleteTaskRecordById(created.taskId)).toBe(true);
expect(events.map((event) => event.kind)).toEqual(["restored", "upserted", "deleted"]);
const restoredEvent = events[0];
expect(restoredEvent?.kind).toBe("restored");
if (restoredEvent?.kind !== "restored") {
throw new Error("Expected restored observer event");
}
expect(restoredEvent.tasks.map((task) => task.taskId)).toEqual(["task-restored"]);
const upsertedEvent = events[1];
expect(upsertedEvent?.kind).toBe("upserted");
if (upsertedEvent?.kind !== "upserted") {
throw new Error("Expected upserted observer event");
}
expect(upsertedEvent.task.taskId).toBe(created.taskId);
const deletedEvent = events[2];
expect(deletedEvent?.kind).toBe("deleted");
if (deletedEvent?.kind !== "deleted") {
throw new Error("Expected deleted observer event");
}
expect(deletedEvent.taskId).toBe(created.taskId);
expect(events[0]).toMatchObject({
kind: "restored",
tasks: [expect.objectContaining({ taskId: "task-restored" })],
});
expect(events[1]).toMatchObject({
kind: "upserted",
task: expect.objectContaining({ taskId: created.taskId }),
});
expect(events[2]).toMatchObject({
kind: "deleted",
taskId: created.taskId,
});
});
it("uses atomic task-plus-delivery store methods when available", async () => {
@@ -219,7 +318,11 @@ describe("task-registry store runtime", () => {
expect(deleteTaskRecordById(created.taskId)).toBe(true);
expect(upsertTaskWithDeliveryState).toHaveBeenCalled();
expect(requireFirstUpsertParams(upsertTaskWithDeliveryState).task?.taskId).toBe(created.taskId);
expect(requireFirstUpsertParams(upsertTaskWithDeliveryState)).toMatchObject({
task: expect.objectContaining({
taskId: created.taskId,
}),
});
expect(
upsertTaskWithDeliveryState.mock.calls.some((call) => {
const params = call[0] as { deliveryState?: { lastNotifiedEventAt?: number } };
@@ -229,6 +332,92 @@ describe("task-registry store runtime", () => {
expect(deleteTaskWithDeliveryState).toHaveBeenCalledWith(created.taskId);
});
it("persists create requester origin with separate task and delivery store methods", () => {
const upsertTask = vi.fn();
const upsertDeliveryState = vi.fn();
configureTaskRegistryRuntime({
store: {
loadSnapshot: () => ({
tasks: new Map(),
deliveryStates: new Map(),
}),
saveSnapshot: vi.fn(),
upsertTask,
upsertDeliveryState,
},
});
const created = createTaskRecord({
runtime: "acp",
ownerKey: "agent:main:main",
scopeKind: "session",
childSessionKey: "agent:codex:acp:new",
runId: "run-separate-store-origin",
task: "Separate store task",
status: "running",
deliveryStatus: "pending",
requesterOrigin: {
channel: "test-channel",
to: "C1234567890",
},
});
expect(upsertTask).toHaveBeenCalledWith(expect.objectContaining({ taskId: created.taskId }));
expect(upsertDeliveryState).toHaveBeenCalledWith({
taskId: created.taskId,
requesterOrigin: {
channel: "test-channel",
to: "C1234567890",
},
});
const taskCallOrder = upsertTask.mock.invocationCallOrder[0];
const deliveryCallOrder = upsertDeliveryState.mock.invocationCallOrder[0];
if (taskCallOrder == null || deliveryCallOrder == null) {
throw new Error("Expected separate store upsert calls");
}
expect(taskCallOrder).toBeLessThan(deliveryCallOrder);
});
it("falls back to full snapshots when custom stores cannot upsert delivery state", () => {
const saveSnapshot = vi.fn();
const upsertTask = vi.fn();
configureTaskRegistryRuntime({
store: {
loadSnapshot: () => ({
tasks: new Map(),
deliveryStates: new Map(),
}),
saveSnapshot,
upsertTask,
},
});
const created = createTaskRecord({
runtime: "acp",
ownerKey: "agent:main:main",
scopeKind: "session",
childSessionKey: "agent:codex:acp:snapshot-fallback",
runId: "run-snapshot-fallback-origin",
task: "Snapshot fallback task",
status: "running",
deliveryStatus: "pending",
requesterOrigin: {
channel: "test-channel",
to: "C1234567890",
},
});
expect(upsertTask).not.toHaveBeenCalled();
expect(saveSnapshot).toHaveBeenCalledOnce();
const snapshot = saveSnapshot.mock.calls[0]?.[0] as {
deliveryStates: ReadonlyMap<string, TaskDeliveryState>;
};
expect(snapshot.deliveryStates.get(created.taskId)?.requesterOrigin).toEqual({
channel: "test-channel",
to: "C1234567890",
});
});
it("restores persisted tasks from the default sqlite store", () => {
const created = createTaskRecord({
runtime: "cron",
@@ -244,10 +433,48 @@ describe("task-registry store runtime", () => {
resetTaskRegistryForTests({ persist: false });
const restored = findTaskByRunId("run-sqlite");
expect(restored?.taskId).toBe(created.taskId);
expect(restored?.sourceId).toBe("job-123");
expect(restored?.task).toBe("Run nightly cron");
expect(findTaskByRunId("run-sqlite")).toMatchObject({
taskId: created.taskId,
sourceId: "job-123",
task: "Run nightly cron",
});
});
it("persists requester origin atomically when creating sqlite tasks", async () => {
await withOpenClawTestState(
{ layout: "state-only", prefix: "openclaw-task-create-origin-" },
async () => {
const created = createTaskRecord({
runtime: "acp",
requesterSessionKey: "agent:main:workspace:channel:C1234567890",
ownerKey: "agent:main:main",
scopeKind: "session",
childSessionKey: "agent:main:workspace:channel:C1234567890",
runId: "run-create-origin",
task: "Reply to channel task",
status: "running",
deliveryStatus: "pending",
notifyPolicy: "done_only",
requesterOrigin: {
channel: "test-channel",
to: "C1234567890",
},
});
resetTaskRegistryForTests({ persist: false });
expect(findTaskByRunId("run-create-origin")).toMatchObject({
taskId: created.taskId,
});
const deliveryState = getTaskRegistrySnapshot().deliveryStates.find(
(state) => state.taskId === created.taskId,
);
expect(deliveryState?.requesterOrigin).toEqual({
channel: "test-channel",
to: "C1234567890",
});
},
);
});
it("persists parentFlowId with task rows", () => {
@@ -270,9 +497,10 @@ describe("task-registry store runtime", () => {
resetTaskRegistryForTests({ persist: false });
const restored = findTaskByRunId("run-flow-linked");
expect(restored?.taskId).toBe(created.taskId);
expect(restored?.parentFlowId).toBe(flow.flowId);
expect(findTaskByRunId("run-flow-linked")).toMatchObject({
taskId: created.taskId,
parentFlowId: flow.flowId,
});
});
it("preserves requesterSessionKey when it differs from ownerKey across sqlite restore", () => {
@@ -291,54 +519,12 @@ describe("task-registry store runtime", () => {
resetTaskRegistryForTests({ persist: false });
const restored = findTaskByRunId("run-requester-session-restore");
expect(restored?.taskId).toBe(created.taskId);
expect(restored?.requesterSessionKey).toBe("agent:main:workspace:channel:C1234567890");
expect(restored?.ownerKey).toBe("agent:main:main");
expect(restored?.childSessionKey).toBe("agent:main:workspace:channel:C1234567890");
});
it("drops malformed requester origin json from sqlite delivery state", async () => {
await withOpenClawTestState(
{ layout: "state-only", prefix: "openclaw-task-store-origin-shape-" },
async () => {
const created = createTaskRecord({
runtime: "acp",
ownerKey: "agent:main:main",
scopeKind: "session",
requesterOrigin: {
channel: "notifychat",
to: "notifychat:123",
},
childSessionKey: "agent:main:acp:origin-shape",
runId: "run-origin-shape",
task: "Restore malformed origin",
status: "running",
deliveryStatus: "pending",
notifyPolicy: "state_changes",
});
const sqlitePath = resolveTaskRegistrySqlitePath(process.env);
const { DatabaseSync } = requireNodeSqlite();
const db = new DatabaseSync(sqlitePath);
db.prepare(
`INSERT OR REPLACE INTO task_delivery_state (
task_id,
requester_origin_json,
last_notified_event_at
) VALUES (?, ?, ?)`,
).run(created.taskId, JSON.stringify(["notifychat", "123"]), 321);
db.close();
resetTaskRegistryForTests({ persist: false });
const deliveryState = getTaskRegistrySnapshot().deliveryStates.find(
(state) => state.taskId === created.taskId,
);
expect(deliveryState?.lastNotifiedEventAt).toBe(321);
expect(deliveryState?.requesterOrigin).toBeUndefined();
},
);
expect(findTaskByRunId("run-requester-session-restore")).toMatchObject({
taskId: created.taskId,
requesterSessionKey: "agent:main:workspace:channel:C1234567890",
ownerKey: "agent:main:main",
childSessionKey: "agent:main:workspace:channel:C1234567890",
});
});
it("preserves taskKind across sqlite restore", () => {
@@ -357,10 +543,112 @@ describe("task-registry store runtime", () => {
resetTaskRegistryForTests({ persist: false });
const restored = findTaskByRunId("run-task-kind-restore");
expect(restored?.taskId).toBe(created.taskId);
expect(restored?.taskKind).toBe("video_generation");
expect(restored?.runId).toBe("run-task-kind-restore");
expect(findTaskByRunId("run-task-kind-restore")).toMatchObject({
taskId: created.taskId,
taskKind: "video_generation",
runId: "run-task-kind-restore",
});
});
it("prunes stale sqlite delivery state while retaining current rows", async () => {
await withOpenClawTestState(
{ layout: "state-only", prefix: "openclaw-task-delivery-prune-" },
async () => {
const taskA = createStoredTask();
const taskB: TaskRecord = {
...createStoredTask(),
taskId: "task-retained-delivery-b",
runId: "run-retained-delivery-b",
};
const deliveryA: TaskDeliveryState = {
taskId: taskA.taskId,
lastNotifiedEventAt: 100,
};
const deliveryB: TaskDeliveryState = {
taskId: taskB.taskId,
lastNotifiedEventAt: 200,
};
saveTaskRegistryStateToSqlite({
tasks: new Map([
[taskA.taskId, taskA],
[taskB.taskId, taskB],
]),
deliveryStates: new Map([
[deliveryA.taskId, deliveryA],
[deliveryB.taskId, deliveryB],
]),
});
saveTaskRegistryStateToSqlite({
tasks: new Map([
[taskA.taskId, taskA],
[taskB.taskId, taskB],
]),
deliveryStates: new Map([[deliveryB.taskId, deliveryB]]),
});
const restored = loadTaskRegistryStateFromSqlite();
expect(restored.deliveryStates.has(taskA.taskId)).toBe(false);
expect(restored.deliveryStates.get(taskB.taskId)).toEqual(deliveryB);
},
);
});
it("prunes large sqlite snapshots without binding every task id at once", async () => {
await withOpenClawTestState(
{ layout: "state-only", prefix: "openclaw-task-large-prune-" },
async () => {
const tasks = new Map<string, TaskRecord>();
const deliveryStates = new Map<string, TaskDeliveryState>();
for (let index = 0; index < 1_200; index++) {
const task: TaskRecord = {
...createStoredTask(),
taskId: `task-large-${index}`,
runId: `run-large-${index}`,
createdAt: index,
lastEventAt: index,
};
tasks.set(task.taskId, task);
deliveryStates.set(task.taskId, {
taskId: task.taskId,
lastNotifiedEventAt: index,
});
}
saveTaskRegistryStateToSqlite({ tasks, deliveryStates });
const retainedTasks = new Map([...tasks].slice(100));
const retainedDeliveryStates = new Map([...deliveryStates].slice(100));
saveTaskRegistryStateToSqlite({
tasks: retainedTasks,
deliveryStates: retainedDeliveryStates,
});
const restored = loadTaskRegistryStateFromSqlite();
expect(restored.tasks.size).toBe(1_100);
expect(restored.deliveryStates.size).toBe(1_100);
expect(restored.tasks.has("task-large-0")).toBe(false);
expect(restored.tasks.has("task-large-1199")).toBe(true);
},
);
});
it("reopens after the shared state database is closed", async () => {
await withOpenClawTestState(
{ layout: "state-only", prefix: "openclaw-task-store-" },
async () => {
const task = createStoredTask();
saveTaskRegistryStateToSqlite({
tasks: new Map([[task.taskId, task]]),
deliveryStates: new Map(),
});
closeOpenClawStateDatabase();
const restored = loadTaskRegistryStateFromSqlite();
expect(restored.tasks.get(task.taskId)).toEqual(task);
},
);
});
it("hardens the sqlite task store directory and file modes", async () => {
@@ -382,181 +670,11 @@ describe("task-registry store runtime", () => {
notifyPolicy: "silent",
});
const registryDir = resolveTaskRegistryDir(process.env);
const sqlitePath = resolveTaskRegistrySqlitePath(process.env);
const databasePath = resolveOpenClawStateSqlitePath(process.env);
const registryDir = path.dirname(databasePath);
expect(databasePath.endsWith(path.join("state", "openclaw.sqlite"))).toBe(true);
expect(statSync(registryDir).mode & 0o777).toBe(0o700);
expect(statSync(sqlitePath).mode & 0o777).toBe(0o600);
},
);
});
it("migrates legacy ownerless cron rows to system scope", async () => {
await withOpenClawTestState(
{ layout: "state-only", prefix: "openclaw-task-store-legacy-" },
async () => {
const sqlitePath = resolveTaskRegistrySqlitePath(process.env);
mkdirSync(path.dirname(sqlitePath), { recursive: true });
const { DatabaseSync } = requireNodeSqlite();
const db = new DatabaseSync(sqlitePath);
db.exec(`
CREATE TABLE task_runs (
task_id TEXT PRIMARY KEY,
runtime TEXT NOT NULL,
source_id TEXT,
requester_session_key TEXT NOT NULL,
child_session_key TEXT,
parent_task_id TEXT,
agent_id TEXT,
run_id TEXT,
label TEXT,
task TEXT NOT NULL,
status TEXT NOT NULL,
delivery_status TEXT NOT NULL,
notify_policy TEXT NOT NULL,
created_at INTEGER NOT NULL,
started_at INTEGER,
ended_at INTEGER,
last_event_at INTEGER,
cleanup_after INTEGER,
error TEXT,
progress_summary TEXT,
terminal_summary TEXT,
terminal_outcome TEXT
);
`);
db.exec(`
CREATE TABLE task_delivery_state (
task_id TEXT PRIMARY KEY,
requester_origin_json TEXT,
last_notified_event_at INTEGER
);
`);
db.prepare(`
INSERT INTO task_runs (
task_id,
runtime,
source_id,
requester_session_key,
child_session_key,
run_id,
task,
status,
delivery_status,
notify_policy,
created_at,
last_event_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
"legacy-cron-task",
"cron",
"nightly-digest",
"",
"agent:main:cron:nightly-digest",
"legacy-cron-run",
"Nightly digest",
"running",
"not_applicable",
"silent",
100,
100,
);
db.close();
resetTaskRegistryForTests({ persist: false });
const restored = findTaskByRunId("legacy-cron-run");
expect(restored?.taskId).toBe("legacy-cron-task");
expect(restored?.ownerKey).toBe("system:cron:nightly-digest");
expect(restored?.scopeKind).toBe("system");
expect(restored?.deliveryStatus).toBe("not_applicable");
expect(restored?.notifyPolicy).toBe("silent");
},
);
});
it("keeps legacy requester_session_key rows writable after restore", async () => {
await withOpenClawTestState(
{ layout: "state-only", prefix: "openclaw-task-store-legacy-write-" },
async () => {
const sqlitePath = resolveTaskRegistrySqlitePath(process.env);
mkdirSync(path.dirname(sqlitePath), { recursive: true });
const { DatabaseSync } = requireNodeSqlite();
const db = new DatabaseSync(sqlitePath);
db.exec(`
CREATE TABLE task_runs (
task_id TEXT PRIMARY KEY,
runtime TEXT NOT NULL,
source_id TEXT,
requester_session_key TEXT NOT NULL,
child_session_key TEXT,
parent_task_id TEXT,
agent_id TEXT,
run_id TEXT,
label TEXT,
task TEXT NOT NULL,
status TEXT NOT NULL,
delivery_status TEXT NOT NULL,
notify_policy TEXT NOT NULL,
created_at INTEGER NOT NULL,
started_at INTEGER,
ended_at INTEGER,
last_event_at INTEGER,
cleanup_after INTEGER,
error TEXT,
progress_summary TEXT,
terminal_summary TEXT,
terminal_outcome TEXT
);
`);
db.exec(`
CREATE TABLE task_delivery_state (
task_id TEXT PRIMARY KEY,
requester_origin_json TEXT,
last_notified_event_at INTEGER
);
`);
db.prepare(`
INSERT INTO task_runs (
task_id,
runtime,
requester_session_key,
run_id,
task,
status,
delivery_status,
notify_policy,
created_at,
last_event_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
"legacy-session-task",
"acp",
"agent:main:main",
"legacy-session-run",
"Legacy session task",
"running",
"pending",
"done_only",
100,
100,
);
db.close();
resetTaskRegistryForTests({ persist: false });
const lost = markTaskLostById({
taskId: "legacy-session-task",
endedAt: 200,
lastEventAt: 200,
error: "session missing",
});
expect(lost?.taskId).toBe("legacy-session-task");
expect(lost?.status).toBe("lost");
expect(lost?.error).toBe("session missing");
const restored = findTaskByRunId("legacy-session-run");
expect(restored?.taskId).toBe("legacy-session-task");
expect(restored?.status).toBe("lost");
expect(restored?.error).toBe("session missing");
expect(statSync(databasePath).mode & 0o777).toBe(0o600);
},
);
});

View File

@@ -1,5 +1,5 @@
import {
closeTaskRegistrySqliteStore,
closeTaskRegistryDatabase,
deleteTaskAndDeliveryStateFromSqlite,
deleteTaskDeliveryStateFromSqlite,
deleteTaskRegistryRecordFromSqlite,
@@ -62,7 +62,7 @@ const defaultTaskRegistryStore: TaskRegistryStore = {
deleteTask: deleteTaskRegistryRecordFromSqlite,
upsertDeliveryState: upsertTaskDeliveryStateToSqlite,
deleteDeliveryState: deleteTaskDeliveryStateFromSqlite,
close: closeTaskRegistrySqliteStore,
close: closeTaskRegistryDatabase,
};
let configuredTaskRegistryStore: TaskRegistryStore = defaultTaskRegistryStore;

View File

@@ -280,7 +280,17 @@ function persistTaskUpsert(task: TaskRecord) {
return;
}
if (store.upsertTask) {
if (deliveryState && !store.upsertDeliveryState) {
store.saveSnapshot({
tasks,
deliveryStates: taskDeliveryStates,
});
return;
}
store.upsertTask(task);
if (deliveryState && store.upsertDeliveryState) {
store.upsertDeliveryState(deliveryState);
}
return;
}
store.saveSnapshot({
@@ -1662,10 +1672,13 @@ export function createTaskRecord(params: {
record.cleanupAfter = resolveTaskCleanupAfter(record);
}
tasks.set(taskId, record);
upsertTaskDeliveryState({
taskId,
requesterOrigin: normalizeDeliveryContext(params.requesterOrigin),
});
const requesterOrigin = normalizeDeliveryContext(params.requesterOrigin);
if (requesterOrigin) {
taskDeliveryStates.set(taskId, {
taskId,
requesterOrigin,
});
}
addRunIdIndex(taskId, record.runId);
addOwnerKeyIndex(taskId, record);
addParentFlowIdIndex(taskId, record);

View File

@@ -27,6 +27,66 @@ export type TaskScopeKind = "session" | "system";
export type TaskStatusCounts = Record<TaskStatus, number>;
export type TaskRuntimeCounts = Record<TaskRuntime, number>;
const TASK_RUNTIMES = new Set<TaskRuntime>(["subagent", "acp", "cli", "cron"]);
const TASK_STATUSES = new Set<TaskStatus>([
"queued",
"running",
"succeeded",
"failed",
"timed_out",
"cancelled",
"lost",
]);
const TASK_DELIVERY_STATUSES = new Set<TaskDeliveryStatus>([
"pending",
"delivered",
"session_queued",
"failed",
"parent_missing",
"not_applicable",
]);
const TASK_NOTIFY_POLICIES = new Set<TaskNotifyPolicy>(["done_only", "state_changes", "silent"]);
const TASK_TERMINAL_OUTCOMES = new Set<TaskTerminalOutcome>(["succeeded", "blocked"]);
const TASK_SCOPE_KINDS = new Set<TaskScopeKind>(["session", "system"]);
function parsePersistedTaskValue<T extends string>(
value: unknown,
values: ReadonlySet<T>,
label: string,
): T {
if (typeof value === "string" && values.has(value as T)) {
return value as T;
}
throw new Error(`Invalid persisted task ${label}: ${JSON.stringify(value)}`);
}
export function parseTaskRuntime(value: unknown): TaskRuntime {
return parsePersistedTaskValue(value, TASK_RUNTIMES, "runtime");
}
export function parseTaskStatus(value: unknown): TaskStatus {
return parsePersistedTaskValue(value, TASK_STATUSES, "status");
}
export function parseTaskDeliveryStatus(value: unknown): TaskDeliveryStatus {
return parsePersistedTaskValue(value, TASK_DELIVERY_STATUSES, "delivery status");
}
export function parseTaskNotifyPolicy(value: unknown): TaskNotifyPolicy {
return parsePersistedTaskValue(value, TASK_NOTIFY_POLICIES, "notify policy");
}
export function parseTaskScopeKind(value: unknown): TaskScopeKind {
return parsePersistedTaskValue(value, TASK_SCOPE_KINDS, "scope kind");
}
export function parseOptionalTaskTerminalOutcome(value: unknown): TaskTerminalOutcome | undefined {
if (value == null || value === "") {
return undefined;
}
return parsePersistedTaskValue(value, TASK_TERMINAL_OUTCOMES, "terminal outcome");
}
export type TaskRegistrySummary = {
total: number;
active: number;