Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions backend/src/chat/openClawChatSnapshotStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -544,7 +544,7 @@ export class SqliteOpenClawChatSnapshotStore implements OpenClawChatSnapshotStor
)
.run(this.#gatewayScope, normalizedKey);
});
deleteSnapshot();
deleteSnapshot.immediate();
}

keys(): string[] {
Expand Down Expand Up @@ -688,7 +688,7 @@ export class SqliteOpenClawChatSnapshotStore implements OpenClawChatSnapshotStor
}
this.#pruneSnapshots();
});
persist();
persist.immediate();
}

save(sessionKey: string, snapshot: OpenClawRuntimeSnapshot): void {
Expand All @@ -699,6 +699,6 @@ export class SqliteOpenClawChatSnapshotStore implements OpenClawChatSnapshotStor
this.#persistSnapshot(normalizedKey, snapshot, this.#now());
this.#pruneSnapshots();
});
persist();
persist.immediate();
}
}
1 change: 1 addition & 0 deletions backend/src/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -451,6 +451,7 @@ function initializeDatabase(databasePath: string): DatabaseSync {
const initializedDatabase = new Database(databasePath);
initializedDatabase.run("PRAGMA foreign_keys = ON");
initializedDatabase.run("PRAGMA busy_timeout = 5000");
initializedDatabase.run("PRAGMA journal_mode = WAL");
Comment thread
rajohan marked this conversation as resolved.
Outdated
runSchemaSql(initializedDatabase, SCHEMA_SQL);

return initializedDatabase;
Expand Down
2 changes: 1 addition & 1 deletion backend/src/services/backups.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion backend/src/services/cacheRefresh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2380,7 +2380,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",
Expand Down
4 changes: 2 additions & 2 deletions backend/src/services/dockerUpdater.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion backend/src/services/gitHygiene.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion backend/src/services/logRotation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
57 changes: 57 additions & 0 deletions backend/test/openClawChatSnapshotStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,63 @@ function snapshotFor(sessionKey: string, sequence: number): OpenClawRuntimeSnaps
}

describe("OpenClaw chat snapshot store", () => {
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()}`
Expand Down
15 changes: 7 additions & 8 deletions src/components/features/chat/ChatHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -94,6 +90,9 @@ export function ChatHeader({
</p>
{selectedSession ? (
<>
<Badge className="whitespace-nowrap">
Model: {selectedSession.model || "Unknown"}
</Badge>
<Badge className="whitespace-nowrap">
Thinking: {selectedChatThinkingLabel(selectedSession)}
</Badge>
Expand Down
8 changes: 6 additions & 2 deletions src/components/features/dashboard/ServiceActionsCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<{
Expand Down Expand Up @@ -151,7 +155,7 @@ export function ServiceActionsCard() {

return (
<>
<Card>
<Card className={className}>
<div className="mb-3 flex items-center justify-between gap-2">
<h3 className="text-sm font-semibold tracking-wide text-primary-300 uppercase">
Actions
Expand Down
58 changes: 56 additions & 2 deletions src/components/features/jobs/JobExecutionQueueCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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(", ");
Expand All @@ -41,7 +53,7 @@ export function JobExecutionQueueCard() {
: "None";

return (
<Card variant="bordered" className="space-y-3 p-3 sm:p-4">
<Card variant="bordered" className={cn("space-y-3 p-3 sm:p-4", className)}>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<CardTitle>Execution queue</CardTitle>
Expand Down Expand Up @@ -167,6 +179,48 @@ export function JobExecutionQueueCard() {
) : (
<p className="text-sm text-primary-400">No queued or running jobs.</p>
)}

{recentExecutions.length > 0 ? (
<div className="space-y-2 border-t border-primary-700 pt-3">
<p className="text-xs font-semibold tracking-wide text-primary-400 uppercase">
Recent executions
</p>
<div className="space-y-2" aria-label="Recent job executions">
{recentExecutions.map((execution) => {
const completedAt =
execution.finishedAt ||
execution.startedAt ||
execution.queuedAt;
return (
<div
key={execution.id}
className="flex min-w-0 items-center justify-between gap-3 rounded-lg border border-primary-700 bg-primary-900/30 px-3 py-2"
>
<div className="min-w-0">
<div className="truncate text-sm font-medium text-primary-100">
{execution.displayName}
</div>
<div className="mt-0.5 text-xs text-primary-400">
Finished {formatDate(completedAt)}
</div>
</div>
<div className="flex shrink-0 flex-wrap justify-end gap-1.5">
<Badge variant={statusVariant(execution)}>
{execution.status}
</Badge>
<Badge
variant="default"
className="hidden capitalize sm:inline-flex"
>
{resourceLabel(execution.resourceClass)}
</Badge>
</div>
</div>
);
})}
</div>
</div>
) : undefined}
</Card>
);
}
5 changes: 5 additions & 0 deletions src/hooks/useBackups.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";

import { apiFetchRequired, apiPostRequired } from "./useApi";
import { cacheKeys } from "./useCache";
import { jobExecutionKeys } from "./useJobExecutions";

/** Represents backup job. */
export interface BackupJob {
Expand Down Expand Up @@ -67,6 +68,7 @@ export function useRunKopiaBackup() {
queryKey: cacheKeys.entry("backup.kopia.status"),
}),
queryClient.invalidateQueries({ queryKey: cacheKeys.heartbeat() }),
queryClient.invalidateQueries({ queryKey: jobExecutionKeys.all }),
]);
},
});
Expand All @@ -87,6 +89,7 @@ export function useClearKopiaBackupAttention() {
queryClient.invalidateQueries({
queryKey: cacheKeys.entry("backup.kopia.status"),
}),
queryClient.invalidateQueries({ queryKey: jobExecutionKeys.all }),
]);
},
});
Expand All @@ -106,6 +109,7 @@ export function useRunWalgBackup() {
queryKey: cacheKeys.entry("backup.walg.status"),
}),
queryClient.invalidateQueries({ queryKey: cacheKeys.heartbeat() }),
queryClient.invalidateQueries({ queryKey: jobExecutionKeys.all }),
]);
},
});
Expand All @@ -126,6 +130,7 @@ export function useClearWalgBackupAttention() {
queryClient.invalidateQueries({
queryKey: cacheKeys.entry("backup.walg.status"),
}),
queryClient.invalidateQueries({ queryKey: jobExecutionKeys.all }),
]);
},
});
Expand Down
2 changes: 2 additions & 0 deletions src/hooks/useCache.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";

import { apiFetchRequired, apiPostRequired } from "./useApi";
import { jobExecutionKeys } from "./useJobExecutions";

/** Represents cache envelope. */
export interface CacheEnvelope<T> {
Expand Down Expand Up @@ -148,6 +149,7 @@ export function useRefreshCacheEntry() {
await Promise.all([
queryClient.invalidateQueries({ queryKey: cacheKeys.heartbeat() }),
queryClient.invalidateQueries({ queryKey: cacheKeys.status() }),
queryClient.invalidateQueries({ queryKey: jobExecutionKeys.all }),
...keys.map((key) =>
queryClient.invalidateQueries({ queryKey: cacheKeys.entry(key) })
),
Expand Down
Loading
Loading