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({