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

@@ -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);