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
2 changes: 1 addition & 1 deletion .github/ISSUE_TEMPLATE/ops_deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ body:
1. Pull latest main
2. Build frontend/backend
3. Restart service
4. Verify /api/health
4. Verify /api/health/ready
validations:
required: true
- type: checkboxes
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ lerna-debug.log*

node_modules
dist
release-manifest.json
backend/data/
data/
.test-openclaw/
Expand Down
13 changes: 8 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,22 +103,25 @@ Production preparation is deliberately separate from ordinary builds:
bun run deploy:prepare
```

This builds both applications and runs the restore-verified SQLite preflight.
Use it before a production restart; plain `build` remains safe for CI and local
verification.
This builds both applications, runs the restore-verified SQLite preflight, and
writes the checksummed release manifest used by production readiness and
activation. Use it before a production restart; plain `build` remains safe for
CI and local verification.

## Runtime notes

- Backend default port: `3100`.
- Frontend dev port: `5173`.
- Health endpoints: `/health` and `/api/health`.
- Health endpoints: public `/api/health/live`, public `/api/health/ready`, and
authenticated `/api/health/diagnostics`.
- Dashboard SQLite uses WAL, numbered checksum-validated migrations,
restrictive storage modes, deploy/maintenance snapshots, and automated
restore checks.
- Frontend builds and the local frontend dev server use Bun's HTML bundler with Babel React Compiler and Bun Tailwind plugins.
- Dev server listens on all addresses so the dashboard can be reached over Tailscale when needed.
- Auth is enforced by the backend request policy for every API route except
`GET|HEAD /api/health`, `GET|HEAD /api/auth/bootstrap`,
`GET|HEAD /api/health/live`, `GET|HEAD /api/health/ready`,
`GET|HEAD /api/auth/bootstrap`,
`POST /api/auth/register-first-user`, `POST /api/auth/login`,
`POST /api/auth/login/totp`, `POST /api/auth/login/recovery`,
`POST /api/auth/login/webauthn/options`,
Expand Down
38 changes: 28 additions & 10 deletions backend/src/databaseMigrationRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
type DatabaseMigration,
databaseMigrations,
} from "./databaseMigrations/index.ts";
import { DASHBOARD_DATABASE_SCHEMA_COMPATIBILITY } from "./databaseSchemaCompatibility.ts";
import {
createVerifiedSqliteBackup,
pruneSqliteBackups,
Expand Down Expand Up @@ -76,20 +77,37 @@ function appliedMigrationRows(database: Database): AppliedMigrationRow[] {
.all() as AppliedMigrationRow[];
}

export function validateDatabaseMigrationHistory(database: Database): number {
export function validateDatabaseMigrationHistory(
database: Database,
maximumCompatibleVersion: number = DASHBOARD_DATABASE_SCHEMA_COMPATIBILITY.maximum
): number {
assertMigrationRegistry();
if (
!Number.isSafeInteger(maximumCompatibleVersion) ||
maximumCompatibleVersion < databaseMigrations.length
) {
throw new TypeError("Invalid maximum compatible SQLite schema version");
}
const appliedRows = appliedMigrationRows(database);
for (const [index, row] of appliedRows.entries()) {
const expected = databaseMigrations[index];
if (!expected) {
const expectedVersion = index + 1;
if (row.version !== expectedVersion) {
throw new Error(
`Database contains unknown SQLite migration version ${row.version}`
`SQLite migration history is not contiguous at version ${expectedVersion}`
);
}
if (row.version !== expected.version) {
throw new Error(
`SQLite migration history is not contiguous at version ${expected.version}`
);
const expected = databaseMigrations[index];
if (!expected) {
if (
row.version > maximumCompatibleVersion ||
!/^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(row.name) ||
!/^[\da-f]{64}$/u.test(row.checksum)
) {
throw new Error(
`Database contains incompatible SQLite migration version ${row.version}`
);
}
continue;
}
if (row.name !== expected.name) {
throw new Error(
Expand Down Expand Up @@ -153,7 +171,7 @@ function applyPendingDatabaseMigrations(
createBackup?: () => SqliteBackupResult | undefined
): DatabaseMigrationResult {
const appliedCountBeforeLock = validateDatabaseMigrationHistory(database);
if (appliedCountBeforeLock === databaseMigrations.length) {
if (appliedCountBeforeLock >= databaseMigrations.length) {
return { applied: [] };
}

Expand All @@ -162,7 +180,7 @@ function applyPendingDatabaseMigrations(
database.run("BEGIN IMMEDIATE");
try {
const appliedCount = validateDatabaseMigrationHistory(database);
if (appliedCount === databaseMigrations.length) {
if (appliedCount >= databaseMigrations.length) {
database.run("COMMIT");
return { applied };
}
Expand Down
41 changes: 41 additions & 0 deletions backend/src/databaseSchemaCompatibility.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { databaseMigrations } from "./databaseMigrations/index.ts";

const CURRENT_DATABASE_SCHEMA_VERSION = databaseMigrations.at(-1)?.version ?? 0;

/**
* Keep this range explicit. An expand migration may widen the maximum before
* the migration ships; a contract migration must narrow it only after the
* previous release has left the rollback window.
*/
export const DASHBOARD_DATABASE_SCHEMA_COMPATIBILITY = Object.freeze({
maximum: 6,
minimum: 6,
target: CURRENT_DATABASE_SCHEMA_VERSION,
});

interface DatabaseSchemaCompatibility {
maximum: number;
minimum: number;
}

if (
DASHBOARD_DATABASE_SCHEMA_COMPATIBILITY.target <
DASHBOARD_DATABASE_SCHEMA_COMPATIBILITY.minimum ||
DASHBOARD_DATABASE_SCHEMA_COMPATIBILITY.target >
DASHBOARD_DATABASE_SCHEMA_COMPATIBILITY.maximum
) {
throw new Error(
"Dashboard database schema target is outside the declared release compatibility range"
);
}

export function isDatabaseSchemaCompatible(
version: number,
compatibility: DatabaseSchemaCompatibility = DASHBOARD_DATABASE_SCHEMA_COMPATIBILITY
): boolean {
return (
Number.isSafeInteger(version) &&
version >= compatibility.minimum &&
version <= compatibility.maximum
);
}
20 changes: 20 additions & 0 deletions backend/src/frontendAssets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import fs from "node:fs";
import path from "node:path";

import { getProcessReleaseRoot } from "./releaseManifest.ts";

export function resolveFrontendPath(): string {
return (
process.env.MIRA_DASHBOARD_FRONTEND_PATH ||
path.join(getProcessReleaseRoot(), "dist")
);
}

export function isFrontendIndexReady(): boolean {
try {
const indexStat = fs.statSync(path.join(resolveFrontendPath(), "index.html"));
return indexStat.isFile();
} catch {
return false;
}
}
146 changes: 146 additions & 0 deletions backend/src/health.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { database } from "./database.ts";
import { validateDatabaseMigrationHistory } from "./databaseMigrationRunner.ts";
import {
DASHBOARD_DATABASE_SCHEMA_COMPATIBILITY,
isDatabaseSchemaCompatible,
} from "./databaseSchemaCompatibility.ts";
import { isFrontendIndexReady } from "./frontendAssets.ts";
import gateway from "./gateway.ts";
import {
getRuntimeReleaseIdentity,
type RuntimeReleaseIdentity,
} from "./releaseManifest.ts";
import { getJobExecutionSummary } from "./services/jobExecutionQueue.ts";

interface DatabaseReadiness {
currentSchemaVersion?: number;
maximumCompatibleSchemaVersion: number;
minimumCompatibleSchemaVersion: number;
ready: boolean;
targetSchemaVersion: number;
}

export interface ReadinessSignals {
database: DatabaseReadiness;
frontendReady: boolean;
gatewayConnected: boolean;
release: RuntimeReleaseIdentity;
sessionCount: number;
workerReady: boolean;
}

export interface DashboardReadinessSnapshot {
checks: {
database: DatabaseReadiness;
frontend: { ready: boolean };
release: {
backendCommit: string;
frontendCommit: string;
issue?: RuntimeReleaseIdentity["issue"];
manifestFormatVersion?: number;
ready: boolean;
source: RuntimeReleaseIdentity["source"];
};
worker: { ready: boolean };
};
dependencies: {
gatewayConnected: boolean;
};
status: "isReady" | "notReady";
}

function databaseReadiness(): DatabaseReadiness {
try {
database.query("SELECT 1").get();
const currentSchemaVersion = validateDatabaseMigrationHistory(database);
return {
currentSchemaVersion,
maximumCompatibleSchemaVersion:
DASHBOARD_DATABASE_SCHEMA_COMPATIBILITY.maximum,
minimumCompatibleSchemaVersion:
DASHBOARD_DATABASE_SCHEMA_COMPATIBILITY.minimum,
ready: isDatabaseSchemaCompatible(currentSchemaVersion),
targetSchemaVersion: DASHBOARD_DATABASE_SCHEMA_COMPATIBILITY.target,
};
} catch {
return {
maximumCompatibleSchemaVersion:
DASHBOARD_DATABASE_SCHEMA_COMPATIBILITY.maximum,
minimumCompatibleSchemaVersion:
DASHBOARD_DATABASE_SCHEMA_COMPATIBILITY.minimum,
ready: false,
targetSchemaVersion: DASHBOARD_DATABASE_SCHEMA_COMPATIBILITY.target,
};
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

function isWorkerReady(): boolean {
try {
return getJobExecutionSummary().workerOnline;
Comment thread
mira-2026 marked this conversation as resolved.
} catch (error) {
console.warn("[Health] Failed to read job worker telemetry:", error);
return false;
}
}

export async function collectReadinessSignals(): Promise<ReadinessSignals> {
return {
database: databaseReadiness(),
frontendReady: isFrontendIndexReady(),
gatewayConnected: gateway.isConnected(),
release: await getRuntimeReleaseIdentity(),
sessionCount: gateway.getSessions().length,
workerReady: isWorkerReady(),
};
}

export function evaluateReadiness(signals: ReadinessSignals): DashboardReadinessSnapshot {
const ready =
signals.database.ready &&
signals.frontendReady &&
signals.release.ready &&
signals.workerReady;
return {
checks: {
database: signals.database,
frontend: { ready: signals.frontendReady },
release: {
backendCommit: signals.release.backendCommit,
frontendCommit: signals.release.frontendCommit,
...(signals.release.issue && { issue: signals.release.issue }),
...(signals.release.manifestFormatVersion !== undefined && {
manifestFormatVersion: signals.release.manifestFormatVersion,
}),
ready: signals.release.ready,
source: signals.release.source,
},
worker: { ready: signals.workerReady },
},
dependencies: {
// Gateway availability is diagnostic. A Gateway outage cannot be
// repaired by rolling Dashboard code back.
gatewayConnected: signals.gatewayConnected,
},
status: ready ? "isReady" : "notReady",
};
}

export function livenessSnapshot() {
return {
status: "isOk" as const,
uptimeSeconds: Math.floor(process.uptime()),
};
}

export async function readinessSnapshot(): Promise<DashboardReadinessSnapshot> {
return evaluateReadiness(await collectReadinessSignals());
}

export async function diagnosticsSnapshot() {
const signals = await collectReadinessSignals();
return {
...evaluateReadiness(signals),
releaseDetails: signals.release,
sessionCount: signals.sessionCount,
};
}
Loading
Loading