diff --git a/backend/src/chat/openClawChatSnapshotStore.ts b/backend/src/chat/openClawChatSnapshotStore.ts
index d864e4816..c368ede21 100644
--- a/backend/src/chat/openClawChatSnapshotStore.ts
+++ b/backend/src/chat/openClawChatSnapshotStore.ts
@@ -531,7 +531,7 @@ export class SqliteOpenClawChatSnapshotStore implements OpenClawChatSnapshotStor
.prepare("DELETE FROM chat_runtime_snapshots WHERE gateway_scope = ?")
.run(this.#gatewayScope);
});
- clearScope();
+ clearScope.immediate();
}
delete(sessionKey: string): void {
@@ -544,7 +544,7 @@ export class SqliteOpenClawChatSnapshotStore implements OpenClawChatSnapshotStor
)
.run(this.#gatewayScope, normalizedKey);
});
- deleteSnapshot();
+ deleteSnapshot.immediate();
}
keys(): string[] {
@@ -688,7 +688,7 @@ export class SqliteOpenClawChatSnapshotStore implements OpenClawChatSnapshotStor
}
this.#pruneSnapshots();
});
- persist();
+ persist.immediate();
}
save(sessionKey: string, snapshot: OpenClawRuntimeSnapshot): void {
@@ -699,6 +699,6 @@ export class SqliteOpenClawChatSnapshotStore implements OpenClawChatSnapshotStor
this.#persistSnapshot(normalizedKey, snapshot, this.#now());
this.#pruneSnapshots();
});
- persist();
+ persist.immediate();
}
}
diff --git a/backend/src/database.ts b/backend/src/database.ts
index c92d4ea5b..868c43e57 100644
--- a/backend/src/database.ts
+++ b/backend/src/database.ts
@@ -442,6 +442,35 @@ function runSchemaSql(databaseConnection: DatabaseSync, schemaSql: string): void
}
}
+export function enableRequiredWalJournalMode(
+ databaseConnection: DatabaseSync,
+ databasePath: string
+): void {
+ let journalModeRow: { journal_mode?: unknown } | null;
+ try {
+ journalModeRow = databaseConnection.query("PRAGMA journal_mode = WAL").get() as {
+ journal_mode?: unknown;
+ } | null;
+ } catch (error) {
+ try {
+ databaseConnection.close();
+ } catch {
+ // Preserve the original SQLite error.
+ }
+ throw error;
+ }
+ const journalMode =
+ typeof journalModeRow?.journal_mode === "string"
+ ? journalModeRow.journal_mode
+ : undefined;
+ if (journalMode?.toLowerCase() !== "wal") {
+ databaseConnection.close();
+ throw new Error(
+ `SQLite WAL journal mode is required for ${databasePath}; got ${journalMode ?? "unknown"}`
+ );
+ }
+}
+
function initializeDatabase(databasePath: string): DatabaseSync {
const { configuredDatabasePath } = resolveDatabasePath();
assertTestDatabasePath(databasePath, configuredDatabasePath);
@@ -451,6 +480,7 @@ function initializeDatabase(databasePath: string): DatabaseSync {
const initializedDatabase = new Database(databasePath);
initializedDatabase.run("PRAGMA foreign_keys = ON");
initializedDatabase.run("PRAGMA busy_timeout = 5000");
+ enableRequiredWalJournalMode(initializedDatabase, databasePath);
runSchemaSql(initializedDatabase, SCHEMA_SQL);
return initializedDatabase;
diff --git a/backend/src/lib/jobResources.ts b/backend/src/lib/jobResources.ts
index 110c333dc..231671760 100644
--- a/backend/src/lib/jobResources.ts
+++ b/backend/src/lib/jobResources.ts
@@ -98,6 +98,31 @@ export function withJobResourceClass(
return resourceContext.run({ resourceClass }, operation);
}
+function isSystemdRunExecutable(executable: string): boolean {
+ return executable === "systemd-run" || executable.endsWith("/systemd-run");
+}
+
+/** Keeps the user-bus variables needed by a scoped launcher without widening child env. */
+export function scopedJobProcessEnvironment(
+ executable: string,
+ environment: Record | undefined,
+ inheritedEnvironment: Record = process.env
+): Record | undefined {
+ if (environment === undefined || !isSystemdRunExecutable(executable)) {
+ return environment;
+ }
+ const busAddress =
+ environment.DBUS_SESSION_BUS_ADDRESS ||
+ inheritedEnvironment.DBUS_SESSION_BUS_ADDRESS;
+ const runtimeDirectory =
+ environment.XDG_RUNTIME_DIR || inheritedEnvironment.XDG_RUNTIME_DIR;
+ return {
+ ...environment,
+ ...(busAddress && { DBUS_SESSION_BUS_ADDRESS: busAddress }),
+ ...(runtimeDirectory && { XDG_RUNTIME_DIR: runtimeDirectory }),
+ };
+}
+
function scopeOwnerProperties(environment: Record): string[] {
const owner = environment.MIRA_DASHBOARD_JOB_SCOPE_OWNER?.trim();
if (!owner || !/^[A-Za-z0-9_.@-]+\.service$/u.test(owner)) return [];
@@ -113,9 +138,8 @@ export function scopedJobProcessCommand(
const context = resourceContext.getStore();
if (
!context ||
- executable === "systemd-run" ||
- environment.MIRA_DASHBOARD_ENABLE_JOB_SCOPES !== "1" ||
- executable.endsWith("/systemd-run")
+ isSystemdRunExecutable(executable) ||
+ environment.MIRA_DASHBOARD_ENABLE_JOB_SCOPES !== "1"
) {
return { arguments: [...arguments_], executable };
}
diff --git a/backend/src/lib/processes.ts b/backend/src/lib/processes.ts
index b28c3b71d..ea1e268cb 100644
--- a/backend/src/lib/processes.ts
+++ b/backend/src/lib/processes.ts
@@ -1,4 +1,4 @@
-import { scopedJobProcessCommand } from "./jobResources.ts";
+import { scopedJobProcessCommand, scopedJobProcessEnvironment } from "./jobResources.ts";
export interface RunProcessOptions {
cwd?: string;
@@ -63,7 +63,7 @@ export function spawnProcess(
cmd: [command.executable, ...command.arguments],
cwd: options.cwd,
detached: options.detached ?? true,
- env: options.env,
+ env: scopedJobProcessEnvironment(command.executable, options.env),
stderr: "pipe",
stdin: "ignore",
stdout: "pipe",
diff --git a/backend/src/routes/cacheRoutes.ts b/backend/src/routes/cacheRoutes.ts
index 50d4bc531..f4d542ac5 100644
--- a/backend/src/routes/cacheRoutes.ts
+++ b/backend/src/routes/cacheRoutes.ts
@@ -8,14 +8,25 @@ import {
} from "../lib/cacheStore.ts";
import { errorMessage, httpStatusCode } from "../lib/errors.ts";
import { stringFallback } from "../lib/values.ts";
-import { cacheRefreshResourceClass } from "../services/cacheRefresh.ts";
+import {
+ cacheRefreshResourceClass,
+ cacheRefreshScheduledJobId,
+} from "../services/cacheRefresh.ts";
+import { getLatestScheduledJobExecution } from "../services/jobExecutionQueue.ts";
import {
enqueueAndWaitForJobExecution,
successfulJobExecutionOutput,
+ waitForJobExecution,
} from "../services/queuedJobExecution.ts";
-import { listScheduledJobs } from "../services/scheduledJobs.ts";
+import {
+ enqueueScheduledJob,
+ getScheduledJob,
+ listScheduledJobs,
+} from "../services/scheduledJobs.ts";
import { getHeartbeatAutomationSnapshot } from "../services/taskAutomation.ts";
+const CACHE_REFRESH_TIMEOUT_MS = 5 * 60 * 1000;
+
function parseJsonFieldOrValue(value: string) {
const parsed = parseJsonField(value);
return parsed ?? value;
@@ -254,6 +265,71 @@ async function refreshedCacheEntry(key: string, result: Record)
return mapCacheRowForResponse(row);
}
+async function enqueueAndWaitForCacheRefresh(
+ key: string,
+ resourceClass: ReturnType,
+ signal: AbortSignal
+) {
+ const enqueueUnscheduledRefresh = async () =>
+ await enqueueAndWaitForJobExecution(
+ {
+ actionKey: "cache.refresh",
+ displayName: `Refresh cache: ${key}`,
+ payload: { key },
+ resourceClass,
+ timeoutMs: CACHE_REFRESH_TIMEOUT_MS,
+ },
+ { signal }
+ );
+ const scheduledJobId = cacheRefreshScheduledJobId(key);
+ if (!scheduledJobId) {
+ return await enqueueUnscheduledRefresh();
+ }
+ let shouldCancelQueuedOnTimeout = true;
+ let executionId: string | undefined;
+ try {
+ executionId = enqueueScheduledJob(scheduledJobId, "manual").executionId;
+ } catch (error) {
+ const statusCode = httpStatusCode(error);
+ if (statusCode === 404) {
+ return await enqueueUnscheduledRefresh();
+ }
+ if (statusCode !== 409) throw error;
+ const existingExecution = getLatestScheduledJobExecution(scheduledJobId);
+ if (!existingExecution) throw error;
+ const scheduledJob = getScheduledJob(scheduledJobId);
+ const canReuseExecution =
+ existingExecution.status === "running" ||
+ (existingExecution.status === "queued" &&
+ scheduledJob !== undefined &&
+ (existingExecution.triggerType === "manual" || scheduledJob.enabled));
+ if (!canReuseExecution) {
+ return await enqueueUnscheduledRefresh();
+ }
+ shouldCancelQueuedOnTimeout = false;
+ executionId = existingExecution.id;
+ }
+ if (!executionId) {
+ throw Object.assign(new Error("Scheduled cache refresh was not queued"), {
+ statusCode: 500,
+ });
+ }
+ const execution = await waitForJobExecution(executionId, {
+ cancelQueuedOnTimeout: shouldCancelQueuedOnTimeout,
+ signal,
+ timeoutMs: CACHE_REFRESH_TIMEOUT_MS,
+ });
+ if (
+ !shouldCancelQueuedOnTimeout &&
+ execution.status === "cancelled" &&
+ (execution.message === "Scheduled job was disabled before execution" ||
+ execution.message === "Scheduled job was removed before execution")
+ ) {
+ return await enqueueUnscheduledRefresh();
+ }
+ return execution;
+}
+
type ParametersRequest = Request & { params: Record };
export const cacheRoutes = {
@@ -312,15 +388,10 @@ export const cacheRoutes = {
if (!key) return json({ error: "Missing cache key" }, { status: 400 });
try {
const resourceClass = cacheRefreshResourceClass(key);
- const execution = await enqueueAndWaitForJobExecution(
- {
- actionKey: "cache.refresh",
- displayName: `Refresh cache: ${key}`,
- payload: { key },
- resourceClass,
- timeoutMs: 5 * 60 * 1000,
- },
- { signal: request.signal }
+ const execution = await enqueueAndWaitForCacheRefresh(
+ key,
+ resourceClass,
+ request.signal
);
const entry = await refreshedCacheEntry(
key,
diff --git a/backend/src/services/backups.ts b/backend/src/services/backups.ts
index f3988fd12..172a070d9 100644
--- a/backend/src/services/backups.ts
+++ b/backend/src/services/backups.ts
@@ -1092,7 +1092,7 @@ export function registerBackupScheduledJobs(): void {
return { backup: await clearBackupAttention(type, backupExecutionId) };
}
);
- database.run("BEGIN");
+ database.run("BEGIN IMMEDIATE");
try {
removeScheduledJobsNotInAction(
"backup.run",
diff --git a/backend/src/services/cacheRefresh.ts b/backend/src/services/cacheRefresh.ts
index b31012c87..f66299c69 100644
--- a/backend/src/services/cacheRefresh.ts
+++ b/backend/src/services/cacheRefresh.ts
@@ -2270,6 +2270,12 @@ const cacheRefreshScheduledJobs = [
},
] as const;
+export function cacheRefreshScheduledJobId(key: string): string | undefined {
+ const scheduledKey = cacheRefreshScopeKey(key);
+ return cacheRefreshScheduledJobs.find((job) => job.actionPayload.key === scheduledKey)
+ ?.id;
+}
+
function getScheduledCacheKey(job: ScheduledJob): string {
const key = job.actionPayload.key;
if (typeof key !== "string" || key.trim() === "") {
@@ -2380,7 +2386,7 @@ export function registerCacheRefreshScheduledJobs(
return { key, ...result };
});
const seedJobs: Array<{ id: string; key: string }> = [];
- database.run("BEGIN");
+ database.run("BEGIN IMMEDIATE");
try {
removeScheduledJobsNotInAction(
"cache.refresh",
diff --git a/backend/src/services/dockerUpdater.ts b/backend/src/services/dockerUpdater.ts
index 6b0f0033d..e8f11e507 100644
--- a/backend/src/services/dockerUpdater.ts
+++ b/backend/src/services/dockerUpdater.ts
@@ -2066,7 +2066,7 @@ export async function registerDockerUpdaterServices(
let isTxnStarted = false;
try {
signal?.throwIfAborted();
- database.run("BEGIN");
+ database.run("BEGIN IMMEDIATE");
isTxnStarted = true;
const discoveredAppSlugs = new Set(
successfulOrPartialDiscoveries.map((item) => item.appSlug)
@@ -2803,7 +2803,7 @@ export function registerDockerUpdaterScheduledJobs(): void {
},
{ timeoutMs: 30 * 60 * 1000 }
);
- database.run("BEGIN");
+ database.run("BEGIN IMMEDIATE");
try {
removeScheduledJobsNotInAction("docker.updater", [job.id]);
const existing = getScheduledJob(job.id);
diff --git a/backend/src/services/gitHygiene.ts b/backend/src/services/gitHygiene.ts
index 264b1c901..23ffe222e 100644
--- a/backend/src/services/gitHygiene.ts
+++ b/backend/src/services/gitHygiene.ts
@@ -507,7 +507,7 @@ export function registerGitHygieneScheduledJobs(): void {
},
{ timeoutMs: GIT_WORKSPACE_SYNC_TIMEOUT_MS }
);
- database.run("BEGIN");
+ database.run("BEGIN IMMEDIATE");
try {
removeScheduledJobsNotInAction("git.openclaw.workspace-sync", [job.id]);
const existing = getScheduledJob(job.id);
diff --git a/backend/src/services/logRotation.ts b/backend/src/services/logRotation.ts
index 5f8fbe7fe..a55ee1e09 100644
--- a/backend/src/services/logRotation.ts
+++ b/backend/src/services/logRotation.ts
@@ -1984,7 +1984,7 @@ export function registerLogRotationScheduledJobs(): void {
}
return { logRotation };
});
- database.run("BEGIN");
+ database.run("BEGIN IMMEDIATE");
try {
removeScheduledJobsNotInAction(LOG_ROTATION_JOB_ID, [LOG_ROTATION_JOB_ID]);
const existing = getScheduledJob(LOG_ROTATION_JOB_ID);
diff --git a/backend/src/services/queuedJobExecution.ts b/backend/src/services/queuedJobExecution.ts
index f8f7f18f6..7c8bb5992 100644
--- a/backend/src/services/queuedJobExecution.ts
+++ b/backend/src/services/queuedJobExecution.ts
@@ -11,6 +11,7 @@ const DEFAULT_POLL_INTERVAL_MS = 100;
const DEFAULT_WAIT_TIMEOUT_MS = 30 * 60 * 1000;
export interface WaitForJobExecutionOptions {
+ cancelQueuedOnTimeout?: boolean;
pollIntervalMs?: number;
signal?: AbortSignal;
timeoutMs?: number;
@@ -55,7 +56,11 @@ export async function waitForJobExecution(
}
if (isTerminalJobExecution(execution)) return execution;
if (Date.now() - startedAt >= timeoutMs) {
- if (execution.status === "queued" && execution.cancellable) {
+ if (
+ options.cancelQueuedOnTimeout !== false &&
+ execution.status === "queued" &&
+ execution.cancellable
+ ) {
try {
cancelJobExecution(id);
} catch (error) {
diff --git a/backend/test/jobExecutionQueue.test.ts b/backend/test/jobExecutionQueue.test.ts
index 5ea5322f8..05d057e24 100644
--- a/backend/test/jobExecutionQueue.test.ts
+++ b/backend/test/jobExecutionQueue.test.ts
@@ -3,6 +3,7 @@ import { afterEach, describe, expect, it } from "bun:test";
import { database } from "../src/database.ts";
import {
scopedJobProcessCommand,
+ scopedJobProcessEnvironment,
withJobResourceClass,
} from "../src/lib/jobResources.ts";
import {
@@ -116,6 +117,30 @@ describe("persistent job execution queue", () => {
});
});
+ it("keeps shared queued work when its observer times out", async () => {
+ const queued = enqueueJobExecution({
+ actionKey: `test.shared-wait-timeout-${Bun.randomUUIDv7()}`,
+ displayName: "Shared timed out wait",
+ resourceClass: "network",
+ timeoutMs: 60_000,
+ });
+ testExecutionIds.add(queued.id);
+
+ await expect(
+ waitForJobExecution(queued.id, {
+ cancelQueuedOnTimeout: false,
+ pollIntervalMs: 10,
+ timeoutMs: 0,
+ })
+ ).rejects.toMatchObject({
+ executionId: queued.id,
+ statusCode: 504,
+ });
+ expect(getJobExecution(queued.id)).toMatchObject({
+ status: "queued",
+ });
+ });
+
it("reports only fresh worker heartbeats as online", () => {
const workerId = `test-worker-${Bun.randomUUIDv7()}`;
registerJobWorker(workerId, 1, "2026-07-22T10:00:00.000Z");
@@ -172,6 +197,46 @@ describe("persistent job execution queue", () => {
);
});
+ it("preserves only user-bus variables for scoped children with restricted env", () => {
+ const restrictedEnvironment = { PATH: "/usr/bin" };
+ const inheritedEnvironment = {
+ DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/1000/bus",
+ INTERNAL_SECRET: "must-not-leak",
+ XDG_RUNTIME_DIR: "/run/user/1000",
+ };
+
+ const expectedEnvironment = {
+ DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/1000/bus",
+ PATH: "/usr/bin",
+ XDG_RUNTIME_DIR: "/run/user/1000",
+ };
+
+ expect(
+ scopedJobProcessEnvironment(
+ "systemd-run",
+ restrictedEnvironment,
+ inheritedEnvironment
+ )
+ ).toEqual(expectedEnvironment);
+ expect(
+ scopedJobProcessEnvironment(
+ "/usr/bin/systemd-run",
+ restrictedEnvironment,
+ inheritedEnvironment
+ )
+ ).toEqual(expectedEnvironment);
+ expect(
+ scopedJobProcessEnvironment(
+ "bash",
+ restrictedEnvironment,
+ inheritedEnvironment
+ )
+ ).toBe(restrictedEnvironment);
+ expect(
+ scopedJobProcessEnvironment("systemd-run", undefined, inheritedEnvironment)
+ ).toBeUndefined();
+ });
+
it("allows queued cancellation but protects a running mutation", () => {
const queued = enqueueJobExecution({
actionKey: `test.protected-${Bun.randomUUIDv7()}`,
diff --git a/backend/test/openClawChatSnapshotStore.test.ts b/backend/test/openClawChatSnapshotStore.test.ts
index 252e5268e..c545dcd42 100644
--- a/backend/test/openClawChatSnapshotStore.test.ts
+++ b/backend/test/openClawChatSnapshotStore.test.ts
@@ -1,3 +1,4 @@
+import { Database } from "bun:sqlite";
import { describe, expect, it } from "bun:test";
import {
@@ -5,7 +6,7 @@ import {
type OpenClawRuntimeSnapshot,
} from "../src/chat/openClawChatBridge.ts";
import { SqliteOpenClawChatSnapshotStore } from "../src/chat/openClawChatSnapshotStore.ts";
-import { database } from "../src/database.ts";
+import { database, enableRequiredWalJournalMode } from "../src/database.ts";
function snapshotFor(sessionKey: string, sequence: number): OpenClawRuntimeSnapshot {
return {
@@ -28,6 +29,90 @@ function snapshotFor(sessionKey: string, sequence: number): OpenClawRuntimeSnaps
}
describe("OpenClaw chat snapshot store", () => {
+ it("preserves a WAL activation error when connection cleanup also fails", () => {
+ const pragmaError = new Error("WAL PRAGMA failed");
+ let closeCalls = 0;
+ const failingDatabase = {
+ close: () => {
+ closeCalls += 1;
+ throw new Error("close failed");
+ },
+ query: () => {
+ throw pragmaError;
+ },
+ } as unknown as Database;
+
+ expect(() => enableRequiredWalJournalMode(failingDatabase, "failing.db")).toThrow(
+ pragmaError
+ );
+ expect(closeCalls).toBe(1);
+ });
+
+ it("fails fast when a database cannot enable WAL", () => {
+ const memoryDatabase = new Database(":memory:");
+
+ expect(() => enableRequiredWalJournalMode(memoryDatabase, ":memory:")).toThrow(
+ "SQLite WAL journal mode is required for :memory:; got memory"
+ );
+ });
+
+ it("uses WAL and waits for a competing writer before updating a snapshot", async () => {
+ const databasePath = process.env.MIRA_DASHBOARD_DB_PATH;
+ if (!databasePath) throw new Error("Test database path is required");
+
+ const journalMode = database.query("PRAGMA journal_mode").get() as {
+ journal_mode: string;
+ };
+ expect(journalMode.journal_mode).toBe("wal");
+
+ const gatewayScope = `gateway-scope-${crypto.randomUUID()}`;
+ const store = new SqliteOpenClawChatSnapshotStore(gatewayScope);
+ const sessionKey = `agent:test:${crypto.randomUUID()}`;
+ store.save(sessionKey, snapshotFor(sessionKey, 1));
+
+ const competingWriter = Bun.spawn(
+ [
+ process.execPath,
+ "--eval",
+ `
+ import { Database } from "bun:sqlite";
+ const database = new Database(process.env.TEST_DATABASE_PATH);
+ database.run("PRAGMA busy_timeout = 5000");
+ database.run("BEGIN IMMEDIATE");
+ console.log("locked");
+ await Bun.sleep(250);
+ database.run("COMMIT");
+ database.close();
+ `,
+ ],
+ {
+ env: { ...process.env, TEST_DATABASE_PATH: databasePath },
+ stderr: "pipe",
+ stdout: "pipe",
+ }
+ );
+
+ try {
+ const outputReader = competingWriter.stdout.getReader();
+ const lockOutput = await outputReader.read();
+ outputReader.releaseLock();
+ expect(new TextDecoder().decode(lockOutput.value)).toContain("locked");
+
+ const updatedSnapshot = snapshotFor(sessionKey, 2);
+ store.save(sessionKey, updatedSnapshot);
+
+ const exitCode = await competingWriter.exited;
+ const stderr = await new Response(competingWriter.stderr).text();
+ expect(stderr).toBe("");
+ expect(exitCode).toBe(0);
+ expect(store.load(sessionKey)).toEqual(updatedSnapshot);
+ } finally {
+ competingWriter.kill();
+ await competingWriter.exited;
+ store.clear();
+ }
+ });
+
it("round-trips and deletes a bounded runtime snapshot", () => {
const store = new SqliteOpenClawChatSnapshotStore(
`gateway-scope-${crypto.randomUUID()}`
diff --git a/backend/test/serviceBehavior.test.ts b/backend/test/serviceBehavior.test.ts
index 685492815..9759f9a9c 100644
--- a/backend/test/serviceBehavior.test.ts
+++ b/backend/test/serviceBehavior.test.ts
@@ -810,6 +810,8 @@ describe("backend service behavior", () => {
});
it("refreshes supported cache keys through the cache route", async () => {
+ rememberEnvironment("MOLTBOOK_API_KEY");
+ process.env.MOLTBOOK_API_KEY = "moltbook-key";
const { waitForLocalCacheSeed } = await import("../src/services/cacheRefresh.ts");
try {
await waitForLocalCacheSeed("weather.spydeberg");
@@ -818,7 +820,14 @@ describe("backend service behavior", () => {
}
cleanupCallbacks.push(() => {
database
- .prepare("DELETE FROM cache_entries WHERE key = 'weather.spydeberg'")
+ .prepare(
+ `DELETE FROM cache_entries
+ WHERE key IN (
+ 'weather.spydeberg',
+ 'log_rotation.state',
+ 'moltbook.home'
+ )`
+ )
.run();
});
const fetchSpy = jest.spyOn(globalThis, "fetch").mockImplementation((async (
@@ -853,13 +862,51 @@ describe("backend service behavior", () => {
],
});
}
+ if (url === "https://www.moltbook.com/api/v1/home") {
+ return Response.json({
+ activity_on_your_posts: [],
+ posts_from_accounts_you_follow: [],
+ what_to_do_next: [],
+ your_direct_messages: {
+ pending_request_count: 0,
+ unread_message_count: 0,
+ },
+ });
+ }
return new Response("not found", { status: 404 });
}) as typeof fetch);
cleanupCallbacks.push(() => fetchSpy.mockRestore());
- const { registerCacheRefreshScheduledJobs } =
+ const { cacheRefreshScheduledJobId, registerCacheRefreshScheduledJobs } =
await import("../src/services/cacheRefresh.ts");
+ const {
+ enqueueScheduledJob,
+ startScheduledJobExecutor,
+ stopScheduledJobExecutor,
+ updateScheduledJob,
+ } = await import("../src/services/scheduledJobs.ts");
+ expect(cacheRefreshScheduledJobId("weather.spydeberg")).toBe("cache.weather");
+ expect(cacheRefreshScheduledJobId("moltbook.home")).toBeUndefined();
+ expect(cacheRefreshScheduledJobId("system.openclaw")).toBe("cache.system");
+ expect(cacheRefreshScheduledJobId("log_rotation.state")).toBeUndefined();
registerCacheRefreshScheduledJobs({ seedStrategy: "none" });
+ cleanupCallbacks.push(() => {
+ database
+ .prepare(
+ `DELETE FROM job_executions
+ WHERE scheduled_job_id = 'cache.weather'
+ OR (action_key = 'cache.refresh'
+ AND json_extract(payload_json, '$.key') IN (
+ 'log_rotation.state',
+ 'moltbook.home',
+ 'weather.spydeberg'
+ ))`
+ )
+ .run();
+ database
+ .prepare("DELETE FROM scheduled_job_runs WHERE job_id = 'cache.weather'")
+ .run();
+ });
await startTestScheduledExecutor();
const { cacheRoutes } = await import("../src/routes/cacheRoutes.ts");
const response = await cacheRoutes["/api/cache/:key/refresh"].POST(
@@ -886,7 +933,220 @@ describe("backend service behavior", () => {
},
isOk: true,
});
- });
+ expect(
+ database
+ .prepare(
+ `SELECT job_id AS jobId, status, trigger_type AS triggerType
+ FROM scheduled_job_runs
+ WHERE job_id = 'cache.weather'
+ ORDER BY id DESC
+ LIMIT 1`
+ )
+ .get()
+ ).toEqual({
+ jobId: "cache.weather",
+ status: "success",
+ triggerType: "manual",
+ });
+
+ const existingRun = enqueueScheduledJob("cache.weather", "startup");
+ const reusedRefresh = await cacheRoutes["/api/cache/:key/refresh"].POST(
+ Object.assign(
+ new Request(
+ "https://dashboard.test/api/cache/weather.spydeberg/refresh",
+ { method: "POST" }
+ ),
+ { params: { key: "weather.spydeberg" } }
+ )
+ );
+ expect(reusedRefresh.status).toBe(200);
+ expect(
+ database
+ .prepare(
+ `SELECT id, trigger_type AS triggerType
+ FROM scheduled_job_runs
+ WHERE job_id = 'cache.weather'
+ ORDER BY id DESC
+ LIMIT 1`
+ )
+ .get()
+ ).toEqual({
+ id: existingRun.id,
+ triggerType: "startup",
+ });
+
+ const moltbookHome = await cacheRoutes["/api/cache/:key/refresh"].POST(
+ Object.assign(
+ new Request("https://dashboard.test/api/cache/moltbook.home/refresh", {
+ method: "POST",
+ }),
+ { params: { key: "moltbook.home" } }
+ )
+ );
+ expect(moltbookHome.status).toBe(200);
+ expect(
+ database
+ .prepare(
+ `SELECT scheduled_job_id IS NULL AS isUnscheduled, status,
+ trigger_type AS triggerType
+ FROM job_executions
+ WHERE action_key = 'cache.refresh'
+ AND json_extract(payload_json, '$.key') = 'moltbook.home'
+ ORDER BY queued_at DESC, id DESC
+ LIMIT 1`
+ )
+ .get()
+ ).toEqual({
+ isUnscheduled: 1,
+ status: "success",
+ triggerType: "manual",
+ });
+
+ await stopScheduledJobExecutor();
+ const disabledRun = enqueueScheduledJob("cache.weather", "startup");
+ expect(updateScheduledJob("cache.weather", { enabled: false })).toMatchObject({
+ enabled: false,
+ });
+ cleanupCallbacks.push(() => {
+ updateScheduledJob("cache.weather", { enabled: true });
+ });
+ const disabledRefreshPromise = cacheRoutes["/api/cache/:key/refresh"].POST(
+ Object.assign(
+ new Request(
+ "https://dashboard.test/api/cache/weather.spydeberg/refresh",
+ { method: "POST" }
+ ),
+ { params: { key: "weather.spydeberg" } }
+ )
+ );
+ await waitFor(
+ () =>
+ database
+ .prepare(
+ `SELECT 1
+ FROM job_executions
+ WHERE scheduled_job_id IS NULL
+ AND action_key = 'cache.refresh'
+ AND json_extract(payload_json, '$.key') = 'weather.spydeberg'
+ AND status = 'queued'`
+ )
+ .get() !== undefined
+ );
+ startScheduledJobExecutor();
+ const disabledRefresh = await disabledRefreshPromise;
+ expect(disabledRefresh.status).toBe(200);
+ expect(
+ database
+ .prepare(
+ `SELECT status
+ FROM scheduled_job_runs
+ WHERE id = ?`
+ )
+ .get(disabledRun.id)
+ ).toEqual({ status: "cancelled" });
+ expect(
+ database
+ .prepare(
+ `SELECT scheduled_job_id IS NULL AS isUnscheduled, status,
+ trigger_type AS triggerType
+ FROM job_executions
+ WHERE scheduled_job_id IS NULL
+ AND action_key = 'cache.refresh'
+ AND json_extract(payload_json, '$.key') = 'weather.spydeberg'
+ ORDER BY queued_at DESC, id DESC
+ LIMIT 1`
+ )
+ .get()
+ ).toEqual({
+ isUnscheduled: 1,
+ status: "success",
+ triggerType: "manual",
+ });
+
+ expect(updateScheduledJob("cache.weather", { enabled: true })).toMatchObject({
+ enabled: true,
+ });
+ await stopScheduledJobExecutor();
+ const disabledAfterReuseRun = enqueueScheduledJob("cache.weather", "startup");
+ const disabledAfterReusePromise = cacheRoutes["/api/cache/:key/refresh"].POST(
+ Object.assign(
+ new Request(
+ "https://dashboard.test/api/cache/weather.spydeberg/refresh",
+ { method: "POST" }
+ ),
+ { params: { key: "weather.spydeberg" } }
+ )
+ );
+ expect(updateScheduledJob("cache.weather", { enabled: false })).toMatchObject({
+ enabled: false,
+ });
+ startScheduledJobExecutor();
+ const disabledAfterReuseRefresh = await disabledAfterReusePromise;
+ expect(disabledAfterReuseRefresh.status).toBe(200);
+ expect(
+ database
+ .prepare(
+ `SELECT status
+ FROM scheduled_job_runs
+ WHERE id = ?`
+ )
+ .get(disabledAfterReuseRun.id)
+ ).toEqual({ status: "cancelled" });
+
+ expect(updateScheduledJob("cache.weather", { enabled: true })).toMatchObject({
+ enabled: true,
+ });
+ cleanupCallbacks.push(() => {
+ registerCacheRefreshScheduledJobs({ seedStrategy: "none" });
+ });
+ const unscheduledWeatherCountBefore = (
+ database
+ .prepare(
+ `SELECT COUNT(*) AS count
+ FROM job_executions
+ WHERE scheduled_job_id IS NULL
+ AND action_key = 'cache.refresh'
+ AND json_extract(payload_json, '$.key') = 'weather.spydeberg'`
+ )
+ .get() as { count: number }
+ ).count;
+ database.prepare("DELETE FROM scheduled_jobs WHERE id = 'cache.weather'").run();
+ const missingScheduleRefresh = await cacheRoutes["/api/cache/:key/refresh"].POST(
+ Object.assign(
+ new Request(
+ "https://dashboard.test/api/cache/weather.spydeberg/refresh",
+ { method: "POST" }
+ ),
+ { params: { key: "weather.spydeberg" } }
+ )
+ );
+ expect(missingScheduleRefresh.status).toBe(200);
+ expect(
+ (
+ database
+ .prepare(
+ `SELECT COUNT(*) AS count
+ FROM job_executions
+ WHERE scheduled_job_id IS NULL
+ AND action_key = 'cache.refresh'
+ AND json_extract(payload_json, '$.key') = 'weather.spydeberg'`
+ )
+ .get() as { count: number }
+ ).count
+ ).toBe(unscheduledWeatherCountBefore + 1);
+ registerCacheRefreshScheduledJobs({ seedStrategy: "none" });
+
+ const logRotationState = await cacheRoutes["/api/cache/:key/refresh"].POST(
+ Object.assign(
+ new Request(
+ "https://dashboard.test/api/cache/log_rotation.state/refresh",
+ { method: "POST" }
+ ),
+ { params: { key: "log_rotation.state" } }
+ )
+ );
+ expect(logRotationState.status).toBe(200);
+ }, 10_000);
it("maps recent deployment jobs in newest-first order", async () => {
const olderId = `test-deploy-older-${Bun.randomUUIDv7()}`;
diff --git a/docs/architecture/database.md b/docs/architecture/database.md
index 4e9205eb4..f040b9ea2 100644
--- a/docs/architecture/database.md
+++ b/docs/architecture/database.md
@@ -22,11 +22,16 @@ EXISTS` schema SQL at startup. It sets:
```sql
PRAGMA foreign_keys = ON;
PRAGMA busy_timeout = 5000;
+PRAGMA journal_mode = WAL;
```
Tests must use temp databases. When `NODE_ENV=test`, the database guard refuses
non-temporary paths and symlinked temp paths.
+WAL mode creates `-wal` and `-shm` sidecars while the database is in use. They
+are runtime files managed by SQLite and must not be removed while Dashboard is
+running.
+
## Tables
| Table | Purpose |
@@ -57,11 +62,31 @@ non-temporary paths and symlinked temp paths.
## Backup Before Manual DB Work
```bash
-cd /home/ubuntu/projects/mira-dashboard/backend
-mkdir -p data/backups
-cp data/mira-dashboard.db "data/backups/mira-dashboard-before-manual-change-$(date +%Y%m%d-%H%M%S).db"
+set -euo pipefail
+backend_dir=/home/ubuntu/projects/mira-dashboard/backend
+configured_db_path="$(
+ cd "$backend_dir"
+ /usr/local/bin/doppler run --config prd --project rajohan -- \
+ sh -c 'printf "%s" "${MIRA_DASHBOARD_DB_PATH-}"'
+)"
+if [[ -z "$configured_db_path" ]]; then
+ db_path="$backend_dir/data/mira-dashboard.db"
+elif [[ "$configured_db_path" = /* ]]; then
+ db_path="$configured_db_path"
+else
+ db_path="$backend_dir/$configured_db_path"
+fi
+mkdir -p "$backend_dir/data/backups"
+backup_path="$backend_dir/data/backups/mira-dashboard-before-manual-change-$(date +%Y%m%d-%H%M%S).db"
+sqlite3 -readonly -cmd ".timeout 5000" "$db_path" ".backup '$backup_path'"
+chmod 0600 "$backup_path"
+test "$(sqlite3 "$backup_path" "PRAGMA quick_check;")" = "ok"
```
+SQLite's online backup API creates a consistent single-file snapshot that
+includes committed WAL contents. Do not copy only the main `.db` file while
+Dashboard is running.
+
## Useful Inspection Commands
```bash
@@ -81,11 +106,27 @@ retry. The application already uses a 5 second busy timeout.
Use this only when Raymond explicitly wants to re-run setup.
```bash
-cd /home/ubuntu/projects/mira-dashboard/backend
-mkdir -p data/backups
-cp data/mira-dashboard.db "data/backups/mira-dashboard-before-bootstrap-reset-$(date +%Y%m%d-%H%M%S).db"
-sqlite3 data/mira-dashboard.db "DELETE FROM auth_sessions; DELETE FROM users; DELETE FROM app_config WHERE key='gateway_token';"
-sqlite3 data/mira-dashboard.db "PRAGMA integrity_check;"
+set -euo pipefail
+backend_dir=/home/ubuntu/projects/mira-dashboard/backend
+configured_db_path="$(
+ cd "$backend_dir"
+ /usr/local/bin/doppler run --config prd --project rajohan -- \
+ sh -c 'printf "%s" "${MIRA_DASHBOARD_DB_PATH-}"'
+)"
+if [[ -z "$configured_db_path" ]]; then
+ db_path="$backend_dir/data/mira-dashboard.db"
+elif [[ "$configured_db_path" = /* ]]; then
+ db_path="$configured_db_path"
+else
+ db_path="$backend_dir/$configured_db_path"
+fi
+mkdir -p "$backend_dir/data/backups"
+backup_path="$backend_dir/data/backups/mira-dashboard-before-bootstrap-reset-$(date +%Y%m%d-%H%M%S).db"
+sqlite3 -readonly -cmd ".timeout 5000" "$db_path" ".backup '$backup_path'"
+chmod 0600 "$backup_path"
+test "$(sqlite3 "$backup_path" "PRAGMA quick_check;")" = "ok"
+sqlite3 -cmd ".timeout 5000" "$db_path" "DELETE FROM auth_sessions; DELETE FROM users; DELETE FROM app_config WHERE key='gateway_token';"
+sqlite3 -cmd ".timeout 5000" "$db_path" "PRAGMA integrity_check;"
curl http://127.0.0.1:3100/api/auth/bootstrap
```
diff --git a/docs/operations/runbooks.md b/docs/operations/runbooks.md
index f0ddef004..de48385df 100644
--- a/docs/operations/runbooks.md
+++ b/docs/operations/runbooks.md
@@ -52,11 +52,27 @@ openclaw status
Use only when Raymond wants to re-run bootstrap.
```bash
-cd /home/ubuntu/projects/mira-dashboard/backend
-mkdir -p data/backups
-cp data/mira-dashboard.db "data/backups/mira-dashboard-before-auth-reset-$(date +%Y%m%d-%H%M%S).db"
-sqlite3 data/mira-dashboard.db "DELETE FROM auth_sessions; DELETE FROM users;"
-sqlite3 data/mira-dashboard.db "PRAGMA integrity_check;"
+set -euo pipefail
+backend_dir=/home/ubuntu/projects/mira-dashboard/backend
+configured_db_path="$(
+ cd "$backend_dir"
+ /usr/local/bin/doppler run --config prd --project rajohan -- \
+ sh -c 'printf "%s" "${MIRA_DASHBOARD_DB_PATH-}"'
+)"
+if [[ -z "$configured_db_path" ]]; then
+ db_path="$backend_dir/data/mira-dashboard.db"
+elif [[ "$configured_db_path" = /* ]]; then
+ db_path="$configured_db_path"
+else
+ db_path="$backend_dir/$configured_db_path"
+fi
+mkdir -p "$backend_dir/data/backups"
+backup_path="$backend_dir/data/backups/mira-dashboard-before-auth-reset-$(date +%Y%m%d-%H%M%S).db"
+sqlite3 -readonly -cmd ".timeout 5000" "$db_path" ".backup '$backup_path'"
+chmod 0600 "$backup_path"
+test "$(sqlite3 "$backup_path" "PRAGMA quick_check;")" = "ok"
+sqlite3 -cmd ".timeout 5000" "$db_path" "DELETE FROM auth_sessions; DELETE FROM users;"
+sqlite3 -cmd ".timeout 5000" "$db_path" "PRAGMA integrity_check;"
curl http://127.0.0.1:3100/api/auth/bootstrap
```
diff --git a/docs/setup/production-deploy.md b/docs/setup/production-deploy.md
index 8f7970dbd..00b607860 100644
--- a/docs/setup/production-deploy.md
+++ b/docs/setup/production-deploy.md
@@ -153,16 +153,34 @@ but the schema uses `CREATE TABLE IF NOT EXISTS`. Existing tables are not
altered automatically. Any change that adds/removes columns, changes
constraints, or backfills data needs an explicit migration/manual rollout plan.
-Before risky auth/database changes, copy the configured live DB first:
+Before risky auth/database changes, create and verify a consistent SQLite
+backup. Use SQLite's online backup API so committed WAL contents are included:
```bash
+set -euo pipefail
backend_dir=/home/ubuntu/projects/mira-dashboard/backend
-db_path="$(cd "$backend_dir" && /usr/local/bin/doppler run --config prd --project rajohan -- printenv MIRA_DASHBOARD_DB_PATH || true)"
-db_path="${db_path:-$backend_dir/data/mira-dashboard.db}"
+configured_db_path="$(
+ cd "$backend_dir"
+ /usr/local/bin/doppler run --config prd --project rajohan -- \
+ sh -c 'printf "%s" "${MIRA_DASHBOARD_DB_PATH-}"'
+)"
+if [[ -z "$configured_db_path" ]]; then
+ db_path="$backend_dir/data/mira-dashboard.db"
+elif [[ "$configured_db_path" = /* ]]; then
+ db_path="$configured_db_path"
+else
+ db_path="$backend_dir/$configured_db_path"
+fi
mkdir -p "$backend_dir/data/backups"
-cp "$db_path" "$backend_dir/data/backups/mira-dashboard-before-change-$(date +%Y%m%d-%H%M%S).db"
+backup_path="$backend_dir/data/backups/mira-dashboard-before-change-$(date +%Y%m%d-%H%M%S).db"
+sqlite3 -readonly -cmd ".timeout 5000" "$db_path" ".backup '$backup_path'"
+chmod 0600 "$backup_path"
+test "$(sqlite3 "$backup_path" "PRAGMA quick_check;")" = "ok"
```
+Do not copy only the main `.db` file while Dashboard is running. WAL mode may
+hold committed writes in the `-wal` sidecar until a checkpoint.
+
## Health Signals
Healthy `/api/health`:
@@ -186,5 +204,4 @@ Important failures:
- `Unauthorized` on API routes: auth/session or cookie issue.
- `database is locked`: another process is holding SQLite; retry after
background jobs settle, then inspect both service logs. Dashboard uses a
- five-second SQLite busy timeout; WAL remains a separate storage-lifecycle
- decision that must be paired with a tested backup/checkpoint plan.
+ five-second SQLite busy timeout and requires WAL mode.
diff --git a/src/components/features/chat/ChatHeader.tsx b/src/components/features/chat/ChatHeader.tsx
index 5efe074f7..7a4f010a2 100644
--- a/src/components/features/chat/ChatHeader.tsx
+++ b/src/components/features/chat/ChatHeader.tsx
@@ -4,7 +4,6 @@ import { useEffect, useState } from "react";
import type { Session } from "../../../types/session";
import { cn } from "../../../utils/cn";
import { formatDuration, formatTokens, getTokenPercent } from "../../../utils/format";
-import { formatSessionType } from "../../../utils/sessionUtilities";
import { Badge } from "../../ui/Badge";
import { Select } from "../../ui/Select";
import { selectedChatSpeedLabel, selectedChatThinkingLabel } from "./chatUtilities";
@@ -51,13 +50,10 @@ function formatHeaderStatus(
? selectedSession.updatedAt
: 0;
- return `${formatSessionType(selectedSession)} · ${selectedSession.model || "Unknown"} · Context: ${contextText} · ${formatDuration(
- selectedSession.updatedAt,
- {
- includeSeconds: true,
- referenceTime: Math.max(referenceTime, updatedAtReference),
- }
- )}`;
+ return `Context: ${contextText} · ${formatDuration(selectedSession.updatedAt, {
+ includeSeconds: true,
+ referenceTime: Math.max(referenceTime, updatedAtReference),
+ })}`;
}
/** Renders the chat header UI. */
@@ -94,6 +90,9 @@ export function ChatHeader({
{selectedSession ? (
<>
+
+ Model: {selectedSession.model || "Unknown"}
+
Thinking: {selectedChatThinkingLabel(selectedSession)}
diff --git a/src/components/features/dashboard/ServiceActionsCard.tsx b/src/components/features/dashboard/ServiceActionsCard.tsx
index 3a7845075..64711ed73 100644
--- a/src/components/features/dashboard/ServiceActionsCard.tsx
+++ b/src/components/features/dashboard/ServiceActionsCard.tsx
@@ -14,8 +14,12 @@ import { Badge } from "../../ui/Badge";
import { Card } from "../../ui/Card";
import { ConfirmModal } from "../../ui/ConfirmModal";
+interface ServiceActionsCardProperties {
+ className?: string;
+}
+
/** Renders the service actions card UI. */
-export function ServiceActionsCard() {
+export function ServiceActionsCard({ className }: ServiceActionsCardProperties = {}) {
const startAction = useStartOpsAction();
const refreshCache = useRefreshCacheEntry();
const { data: systemHost } = useCacheEntry<{
@@ -151,7 +155,7 @@ export function ServiceActionsCard() {
return (
<>
-
+
Actions
diff --git a/src/components/features/jobs/JobExecutionQueueCard.tsx b/src/components/features/jobs/JobExecutionQueueCard.tsx
index db3cfc73c..5a33ee4f6 100644
--- a/src/components/features/jobs/JobExecutionQueueCard.tsx
+++ b/src/components/features/jobs/JobExecutionQueueCard.tsx
@@ -5,6 +5,7 @@ import {
useCancelJobExecution,
useJobExecutions,
} from "../../../hooks";
+import { cn } from "../../../utils/cn";
import { formatDate, formatDuration } from "../../../utils/format";
import { Alert } from "../../ui/Alert";
import { Badge } from "../../ui/Badge";
@@ -22,14 +23,25 @@ function resourceLabel(resourceClass: JobExecution["resourceClass"]): string {
return resourceClass.replace("-", " ");
}
+interface JobExecutionQueueCardProperties {
+ className?: string;
+}
+
/** Shows global queue pressure and cancellation controls for active executions. */
-export function JobExecutionQueueCard() {
+export function JobExecutionQueueCard({
+ className,
+}: JobExecutionQueueCardProperties = {}) {
const queue = useJobExecutions();
const cancelExecution = useCancelJobExecution();
const summary = queue.data?.summary;
const activeExecutions = (queue.data?.executions ?? []).filter(
(execution) => execution.status === "queued" || execution.status === "running"
);
+ const recentExecutions = (queue.data?.executions ?? [])
+ .filter(
+ (execution) => execution.status !== "queued" && execution.status !== "running"
+ )
+ .slice(0, 3);
const activeClasses = summary?.activeResourceClasses
.map((resourceClass) => resourceLabel(resourceClass))
.join(", ");
@@ -41,7 +53,7 @@ export function JobExecutionQueueCard() {
: "None";
return (
-
+
Execution queue
@@ -167,6 +179,48 @@ export function JobExecutionQueueCard() {
) : (
No queued or running jobs.
)}
+
+ {recentExecutions.length > 0 ? (
+
+
+ Recent executions
+
+
+ {recentExecutions.map((execution) => {
+ const completedAt =
+ execution.finishedAt ||
+ execution.startedAt ||
+ execution.queuedAt;
+ return (
+
+
+
+ {execution.displayName}
+
+
+ Finished {formatDate(completedAt)}
+
+
+
+
+ {execution.status}
+
+
+ {resourceLabel(execution.resourceClass)}
+
+
+
+ );
+ })}
+
+
+ ) : undefined}
);
}
diff --git a/src/hooks/useBackups.ts b/src/hooks/useBackups.ts
index 7b8d6d8d7..71bad4665 100644
--- a/src/hooks/useBackups.ts
+++ b/src/hooks/useBackups.ts
@@ -2,6 +2,8 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiFetchRequired, apiPostRequired } from "./useApi";
import { cacheKeys } from "./useCache";
+import { jobExecutionKeys } from "./useJobExecutions";
+import { scheduledJobKeys } from "./useScheduledJobs";
/** Represents backup job. */
export interface BackupJob {
@@ -67,6 +69,11 @@ export function useRunKopiaBackup() {
queryKey: cacheKeys.entry("backup.kopia.status"),
}),
queryClient.invalidateQueries({ queryKey: cacheKeys.heartbeat() }),
+ queryClient.invalidateQueries({ queryKey: jobExecutionKeys.all }),
+ queryClient.invalidateQueries({ queryKey: scheduledJobKeys.list() }),
+ queryClient.invalidateQueries({
+ queryKey: scheduledJobKeys.runs("backup.kopia"),
+ }),
]);
},
});
@@ -87,6 +94,7 @@ export function useClearKopiaBackupAttention() {
queryClient.invalidateQueries({
queryKey: cacheKeys.entry("backup.kopia.status"),
}),
+ queryClient.invalidateQueries({ queryKey: jobExecutionKeys.all }),
]);
},
});
@@ -106,6 +114,11 @@ export function useRunWalgBackup() {
queryKey: cacheKeys.entry("backup.walg.status"),
}),
queryClient.invalidateQueries({ queryKey: cacheKeys.heartbeat() }),
+ queryClient.invalidateQueries({ queryKey: jobExecutionKeys.all }),
+ queryClient.invalidateQueries({ queryKey: scheduledJobKeys.list() }),
+ queryClient.invalidateQueries({
+ queryKey: scheduledJobKeys.runs("backup.walg"),
+ }),
]);
},
});
@@ -126,6 +139,7 @@ export function useClearWalgBackupAttention() {
queryClient.invalidateQueries({
queryKey: cacheKeys.entry("backup.walg.status"),
}),
+ queryClient.invalidateQueries({ queryKey: jobExecutionKeys.all }),
]);
},
});
diff --git a/src/hooks/useCache.ts b/src/hooks/useCache.ts
index 8e3824b05..976a965fb 100644
--- a/src/hooks/useCache.ts
+++ b/src/hooks/useCache.ts
@@ -1,6 +1,8 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiFetchRequired, apiPostRequired } from "./useApi";
+import { jobExecutionKeys } from "./useJobExecutions";
+import { scheduledJobKeys } from "./useScheduledJobs";
/** Represents cache envelope. */
export interface CacheEnvelope
{
@@ -148,6 +150,8 @@ export function useRefreshCacheEntry() {
await Promise.all([
queryClient.invalidateQueries({ queryKey: cacheKeys.heartbeat() }),
queryClient.invalidateQueries({ queryKey: cacheKeys.status() }),
+ queryClient.invalidateQueries({ queryKey: jobExecutionKeys.all }),
+ queryClient.invalidateQueries({ queryKey: scheduledJobKeys.all }),
...keys.map((key) =>
queryClient.invalidateQueries({ queryKey: cacheKeys.entry(key) })
),
diff --git a/src/hooks/useJobExecutions.ts b/src/hooks/useJobExecutions.ts
index b3f8db57b..d71621920 100644
--- a/src/hooks/useJobExecutions.ts
+++ b/src/hooks/useJobExecutions.ts
@@ -1,7 +1,6 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiFetchRequired, apiPostRequired } from "./useApi";
-import { scheduledJobKeys } from "./useScheduledJobs";
export type JobResourceClass =
"interactive" | "light" | "network" | "host-heavy" | "exclusive";
@@ -50,14 +49,15 @@ export const jobExecutionKeys = {
list: () => [...jobExecutionKeys.all, "list"] as const,
};
+const JOB_EXECUTION_REFRESH_MS = 5000;
+
export function useJobExecutions() {
return useQuery({
queryKey: jobExecutionKeys.list(),
queryFn: () => apiFetchRequired("/job-executions"),
- refetchInterval: (query) => {
- const summary = query.state.data?.summary;
- return summary && (summary.queued > 0 || summary.running > 0) ? 2000 : 15_000;
- },
+ refetchInterval: JOB_EXECUTION_REFRESH_MS,
+ refetchIntervalInBackground: false,
+ staleTime: 500,
});
}
@@ -70,10 +70,10 @@ export function useCancelJobExecution() {
),
onSuccess: (result) => {
void queryClient.invalidateQueries({ queryKey: jobExecutionKeys.all });
- void queryClient.invalidateQueries({ queryKey: scheduledJobKeys.list() });
+ void queryClient.invalidateQueries({ queryKey: ["scheduled-jobs"] });
if (result.execution.scheduledJobId) {
void queryClient.invalidateQueries({
- queryKey: scheduledJobKeys.runs(result.execution.scheduledJobId),
+ queryKey: ["scheduled-jobs", "runs", result.execution.scheduledJobId],
});
}
},
diff --git a/src/hooks/useOpsActions.ts b/src/hooks/useOpsActions.ts
index c3f2491e7..2c8ca6bcd 100644
--- a/src/hooks/useOpsActions.ts
+++ b/src/hooks/useOpsActions.ts
@@ -1,6 +1,7 @@
-import { useMutation, useQuery } from "@tanstack/react-query";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiFetchRequired, apiPostRequired } from "./useApi";
+import { jobExecutionKeys } from "./useJobExecutions";
/** Defines ops action id. */
export type OpsActionId =
@@ -105,12 +106,17 @@ export const OPS_ACTIONS: OpsActionDefinition[] = [
/** Provides start ops action. */
export function useStartOpsAction() {
+ const queryClient = useQueryClient();
+
return useMutation({
mutationFn: async (action: OpsActionDefinition) =>
apiPostRequired<{ jobId: string }>("/exec/start", {
command: action.command,
shell: true,
}),
+ onSuccess: () => {
+ void queryClient.invalidateQueries({ queryKey: jobExecutionKeys.all });
+ },
});
}
diff --git a/src/hooks/useScheduledJobs.ts b/src/hooks/useScheduledJobs.ts
index 6c1ef2134..0ba2483ca 100644
--- a/src/hooks/useScheduledJobs.ts
+++ b/src/hooks/useScheduledJobs.ts
@@ -2,7 +2,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { JobDisableIntent } from "../types/job";
import { apiFetchRequired, apiPatchRequired, apiPostRequired } from "./useApi";
-import type { JobResourceClass } from "./useJobExecutions";
+import { jobExecutionKeys, type JobResourceClass } from "./useJobExecutions";
/** Represents a backend-native scheduled job. */
export interface ScheduledJob {
@@ -182,6 +182,7 @@ export function useRunScheduledJobNow() {
void queryClient.invalidateQueries({
queryKey: scheduledJobKeys.runs(variables.id),
});
+ void queryClient.invalidateQueries({ queryKey: jobExecutionKeys.all });
},
});
}
diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx
index d1a8cfc95..2b5a90ae8 100644
--- a/src/pages/Dashboard.tsx
+++ b/src/pages/Dashboard.tsx
@@ -30,6 +30,7 @@ import {
ReportsOverviewCard,
ServiceActionsCard,
} from "../components/features/dashboard";
+import { JobExecutionQueueCard } from "../components/features/jobs/JobExecutionQueueCard";
import { Alert } from "../components/ui/Alert";
import { Card } from "../components/ui/Card";
import { MetricCard } from "../components/ui/MetricCard";
@@ -313,7 +314,10 @@ export function Dashboard() {
);
diff --git a/src/test/chatHeader.test.tsx b/src/test/chatHeader.test.tsx
index 6a888b5a4..d8d306acf 100644
--- a/src/test/chatHeader.test.tsx
+++ b/src/test/chatHeader.test.tsx
@@ -57,8 +57,11 @@ describe("ChatHeader", () => {
);
expect(screen.getByText(/less than 5 seconds ago/u)).toBeInTheDocument();
+ expect(screen.getByText("Model: gpt-5.6-sol")).toBeInTheDocument();
expect(screen.getByText("Thinking: medium")).toBeInTheDocument();
expect(screen.getByText("Speed: Default (Auto)")).toBeInTheDocument();
+ expect(screen.queryByText(/MAIN/u)).not.toBeInTheDocument();
+ expect(screen.queryByText(/gpt-5\.6-sol · Context:/u)).not.toBeInTheDocument();
act(() => {
jest.advanceTimersByTime(10_000);
diff --git a/src/test/componentBehavior.test.tsx b/src/test/componentBehavior.test.tsx
index 8f69f1de6..75e1f5210 100644
--- a/src/test/componentBehavior.test.tsx
+++ b/src/test/componentBehavior.test.tsx
@@ -3261,6 +3261,8 @@ describe("shared component helpers", () => {
expect(
screen.queryByRole("button", { name: "Cancel Host backup" })
).not.toBeInTheDocument();
+ expect(screen.getByText("Recent executions")).toBeInTheDocument();
+ expect(screen.getByText("cancelled")).toBeInTheDocument();
});
queryClient.clear();
diff --git a/src/test/frontendBehavior.test.tsx b/src/test/frontendBehavior.test.tsx
index e51eb6f03..5b9632f13 100644
--- a/src/test/frontendBehavior.test.tsx
+++ b/src/test/frontendBehavior.test.tsx
@@ -129,6 +129,7 @@ import { useDatabaseOverview } from "../hooks/useDatabase";
import { useDockerContainers } from "../hooks/useDocker";
import { useFileContent, useFiles, useSaveFile } from "../hooks/useFiles";
import { useHealth } from "../hooks/useHealth";
+import { jobExecutionKeys } from "../hooks/useJobExecutions";
import { useLogContent, useLogFiles } from "../hooks/useLogs";
import { useMetrics } from "../hooks/useMetrics";
import { useMoltbookData } from "../hooks/useMoltbook";
@@ -151,6 +152,7 @@ import {
} from "../hooks/usePullRequests";
import { hasQuotaStatus, useQuotas } from "../hooks/useQuotas";
import {
+ scheduledJobKeys,
useRunScheduledJobNow,
useScheduledJobRuns,
useScheduledJobs,
@@ -2072,9 +2074,23 @@ describe("Mira Dashboard frontend behavior", () => {
});
const runJob = renderHookWithQueryClient(() => useRunScheduledJobNow());
+ runJob.queryClient.setQueryData(jobExecutionKeys.list(), {
+ executions: [],
+ summary: {
+ activeResourceClasses: [],
+ queued: 0,
+ running: 0,
+ workerCapacity: 1,
+ workerCount: 1,
+ workerOnline: true,
+ },
+ });
await expect(runJob.result.current.mutateAsync({ id: "job-1" })).resolves.toEqual(
expect.objectContaining({ isOk: true })
);
+ expect(
+ runJob.queryClient.getQueryState(jobExecutionKeys.list())?.isInvalidated
+ ).toBe(true);
const kopia = renderHookWithQueryClient(() => useKopiaBackup());
await waitFor(() => expect(kopia.result.current.data?.job?.id).toBe("kopia-1"));
@@ -2083,9 +2099,20 @@ describe("Mira Dashboard frontend behavior", () => {
await waitFor(() => expect(walg.result.current.data?.job).toBeUndefined());
const runKopia = renderHookWithQueryClient(() => useRunKopiaBackup());
+ runKopia.queryClient.setQueryData(scheduledJobKeys.list(), { jobs: [] });
+ runKopia.queryClient.setQueryData(scheduledJobKeys.runs("backup.kopia"), {
+ runs: [],
+ });
await expect(runKopia.result.current.mutateAsync()).resolves.toEqual(
expect.objectContaining({ isOk: true })
);
+ expect(
+ runKopia.queryClient.getQueryState(scheduledJobKeys.list())?.isInvalidated
+ ).toBe(true);
+ expect(
+ runKopia.queryClient.getQueryState(scheduledJobKeys.runs("backup.kopia"))
+ ?.isInvalidated
+ ).toBe(true);
const pullRequests = renderHookWithQueryClient(() => usePullRequests());
await waitFor(() =>
@@ -2449,9 +2476,13 @@ describe("Mira Dashboard frontend behavior", () => {
);
const refreshCache = renderHookWithQueryClient(() => useRefreshCacheEntry());
+ refreshCache.queryClient.setQueryData(scheduledJobKeys.list(), { jobs: [] });
await expect(
refreshCache.result.current.mutateAsync(" weather.spydeberg ,, ")
).resolves.toMatchObject({ keys: ["weather.spydeberg"] });
+ expect(
+ refreshCache.queryClient.getQueryState(scheduledJobKeys.list())?.isInvalidated
+ ).toBe(true);
});
it("attempts every requested cache refresh and retains partial successes", async () => {
@@ -3510,9 +3541,20 @@ describe("Mira Dashboard frontend behavior", () => {
});
const runWalg = renderHookWithQueryClient(() => useRunWalgBackup());
+ runWalg.queryClient.setQueryData(scheduledJobKeys.list(), { jobs: [] });
+ runWalg.queryClient.setQueryData(scheduledJobKeys.runs("backup.walg"), {
+ runs: [],
+ });
await expect(runWalg.result.current.mutateAsync()).resolves.toMatchObject({
job: { id: "walg-1", status: "running" },
});
+ expect(
+ runWalg.queryClient.getQueryState(scheduledJobKeys.list())?.isInvalidated
+ ).toBe(true);
+ expect(
+ runWalg.queryClient.getQueryState(scheduledJobKeys.runs("backup.walg"))
+ ?.isInvalidated
+ ).toBe(true);
const clearKopia = renderHookWithQueryClient(() =>
useClearKopiaBackupAttention()
diff --git a/src/test/pageBehavior.test.tsx b/src/test/pageBehavior.test.tsx
index 7940bf127..0fdc8f122 100644
--- a/src/test/pageBehavior.test.tsx
+++ b/src/test/pageBehavior.test.tsx
@@ -319,6 +319,13 @@ function apiResponse(url: string, method: string, init?: RequestInit) {
return Response.json({ entry: { key: "docker.summary" }, isOk: true });
}
+ if (method === "POST" && url.startsWith("/api/cache/") && url.endsWith("/refresh")) {
+ const key = decodeURIComponent(
+ url.slice("/api/cache/".length, -"/refresh".length)
+ );
+ return Response.json({ entry: { key }, isOk: true });
+ }
+
if (method === "POST" && url === "/api/docker/exec/start") {
expect(parseRequestBody(init)).toMatchObject({
command: "echo hello",
@@ -2222,7 +2229,9 @@ describe("Mira Dashboard pages", () => {
view.queryClient.clear();
});
- it("links git workspace repositories to GitHub remotes", async () => {
+ it("links git workspace repositories and refreshes every Moltbook cache", async () => {
+ const user = userEvent.setup();
+ const fetchMock = fetch as unknown as ReturnType
;
const view = renderPage(createElement(Dashboard), { withSocket: true });
const dashboardRepoLink = await screen.findByRole("link", {
@@ -2233,6 +2242,22 @@ describe("Mira Dashboard pages", () => {
"https://github.com/rajohan/Mira-Dashboard"
);
+ await user.click(screen.getByRole("button", { name: /force update moltbook/i }));
+ await waitFor(() => {
+ for (const key of [
+ "moltbook.home",
+ "moltbook.feed.hot",
+ "moltbook.feed.new",
+ "moltbook.profile",
+ "moltbook.my-content",
+ ]) {
+ expect(fetchMock).toHaveBeenCalledWith(
+ `/api/cache/${key}/refresh`,
+ expect.objectContaining({ method: "POST" })
+ );
+ }
+ });
+
view.unmount();
view.queryClient.clear();
});
@@ -2996,9 +3021,9 @@ describe("Mira Dashboard pages", () => {
});
await waitFor(() => {
- expect(
- screen.getByText(/MAIN · codex · Context: 0.5k \/ 1k \(53%\)/)
- ).toBeInTheDocument();
+ expect(screen.getByText(/Context: 0.5k \/ 1k \(53%\)/)).toBeInTheDocument();
+ expect(screen.getByText("Model: codex")).toBeInTheDocument();
+ expect(screen.queryByText(/MAIN ·/u)).not.toBeInTheDocument();
});
await waitFor(() => {
expect(view.router.state.location.search).toEqual({