From 97dc3599ab5885bed1eaf1c7c4fcd6ec7c69fd51 Mon Sep 17 00:00:00 2001 From: mira-2026 Date: Mon, 27 Jul 2026 19:29:12 +0200 Subject: [PATCH 1/9] fix: clarify deployment verification status --- backend/src/services/pullRequests.ts | 48 +++++++++++++++++++--------- backend/test/serviceBehavior.test.ts | 21 +++++++++--- src/hooks/useDelivery.ts | 2 +- src/pages/Delivery.tsx | 2 +- src/test/pageBehavior.test.tsx | 40 ++++++++++++++++++++++- 5 files changed, 90 insertions(+), 23 deletions(-) diff --git a/backend/src/services/pullRequests.ts b/backend/src/services/pullRequests.ts index 0c1ee6a5f..4411197f2 100644 --- a/backend/src/services/pullRequests.ts +++ b/backend/src/services/pullRequests.ts @@ -348,6 +348,21 @@ function mapDeploymentJob(row: DeploymentJobRow): DeploymentJob { }; } +interface PublicDeploymentJob extends Omit { + status: "building" | "verifying" | "isOk" | "failed"; +} + +/** + * Keeps the persisted cutover protocol compatible with the previous release + * while exposing the work actually in progress to operators. + */ +function publicDeploymentJob(job: DeploymentJob): PublicDeploymentJob { + return { + ...job, + status: job.status === "restart-scheduled" ? "verifying" : job.status, + }; +} + /** Reads one deployment job. */ function readDeploymentJob(jobId: string): DeploymentJob | undefined { const row = database @@ -734,7 +749,7 @@ function refreshDeploymentHeartbeat(job: DeploymentJob): DeploymentJob { } /** Performs read deployment jobs. */ -export function readDeploymentJobs(): DeploymentJob[] { +export function readDeploymentJobs(): PublicDeploymentJob[] { return ( database .prepare( @@ -755,7 +770,7 @@ export function readDeploymentJobs(): DeploymentJob[] { ` ) .all(RECENT_DEPLOYMENTS_LIMIT) as unknown as DeploymentJobRow[] - ).map((row) => mapDeploymentJob(row)); + ).map((row) => publicDeploymentJob(mapDeploymentJob(row))); } interface DeploymentRuntimeResultRow { @@ -1861,7 +1876,10 @@ function shellQuote(value: string): string { function deploymentJobUpdateCommand(job: DeploymentJob): string { const script = ` import { Database } from "bun:sqlite"; -const job = JSON.parse(process.env.MIRA_DEPLOYMENT_JOB || "{}"); +const job = { + ...JSON.parse(process.env.MIRA_DEPLOYMENT_JOB || "{}"), + updatedAt: new Date().toISOString(), +}; const database = new Database(process.env.MIRA_DEPLOYMENT_DB); const sqlNull = JSON.parse("null"); function sqlNullable(value) { @@ -2105,11 +2123,11 @@ async function scheduleReleaseCutover( ...job, status: "isOk", updatedAt: dateToISOString(new Date()), - note: "Atomic release activated. Web, worker, and commit readiness passed", + note: `Atomic release activated. Web, worker, commit, and ${DEPLOYMENT_WORKER_STABILITY_SECONDS}-second worker stability checks passed`, }; const okWithRetentionWarningJob: DeploymentJob = { ...okJob, - note: "Atomic release activated and ready; release retention cleanup failed", + note: `Atomic release activated and verified, including ${DEPLOYMENT_WORKER_STABILITY_SECONDS}-second worker stability; release retention cleanup failed`, }; const rolledBackJob: DeploymentJob = { ...job, @@ -2212,7 +2230,7 @@ async function scheduleReleaseRollback( ...job, status: "isOk", updatedAt: dateToISOString(new Date()), - note: `Atomic rollback activated ${targetShort}. Web, worker, and commit readiness passed`, + note: `Atomic rollback activated ${targetShort}. Web, worker, commit, and ${DEPLOYMENT_WORKER_STABILITY_SECONDS}-second worker stability checks passed`, }; const restoredJob: DeploymentJob = { ...job, @@ -2322,7 +2340,7 @@ function didScheduleOrphanedReleaseCutoverRecovery( ...job, status: "isOk", updatedAt: dateToISOString(new Date()), - note: "Interrupted release cutover recovered; active candidate passed restart and commit-bound readiness", + note: `Interrupted release cutover recovered; active candidate passed restart, commit-bound readiness, and ${DEPLOYMENT_WORKER_STABILITY_SECONDS}-second worker stability`, }; const script = [ "sleep 1", @@ -2527,16 +2545,16 @@ async function runDeploymentJob( ); persistCutover(releaseCutover); - const restartScheduled: DeploymentJob = { + const cutoverJob: DeploymentJob = { ...currentJob, status: "restart-scheduled", updatedAt: dateToISOString(new Date()), commit: candidate.manifest.commitSha, commitTitle: candidate.manifest.commitTitle, - note: "Immutable release published. Detached activation and rollback check scheduled", + note: `Release published. Activating it, restarting services, then verifying web, worker, deployed commit, and ${DEPLOYMENT_WORKER_STABILITY_SECONDS} seconds of worker stability; automatic rollback is armed`, }; - writeDeploymentJob(restartScheduled); - await scheduleReleaseCutover(restartScheduled, releaseCutover, signal); + writeDeploymentJob(cutoverJob); + await scheduleReleaseCutover(cutoverJob, releaseCutover, signal); return releaseCutover; } catch (error) { const failed: DeploymentJob = { @@ -2590,17 +2608,17 @@ async function runRollbackJob( } assertDashboardReleaseHostRuntimeCompatible(state.previous); - const restartScheduled: DeploymentJob = { + const cutoverJob: DeploymentJob = { ...currentJob, status: "restart-scheduled", updatedAt: dateToISOString(new Date()), commit: state.previous.commitSha, commitTitle: state.previous.manifest.commitTitle, - note: "Verified rollback target. Detached atomic rollback and readiness check scheduled", + note: `Rollback target verified. Activating it, restarting services, then verifying web, worker, deployed commit, and ${DEPLOYMENT_WORKER_STABILITY_SECONDS} seconds of worker stability; automatic restoration is armed`, }; - writeDeploymentJob(restartScheduled); + writeDeploymentJob(cutoverJob); await scheduleReleaseRollback( - restartScheduled, + cutoverJob, state.previous.commitSha, state.current.commitSha, signal diff --git a/backend/test/serviceBehavior.test.ts b/backend/test/serviceBehavior.test.ts index 6e9befba8..575cfd54b 100644 --- a/backend/test/serviceBehavior.test.ts +++ b/backend/test/serviceBehavior.test.ts @@ -1571,7 +1571,7 @@ describe("backend service behavior", () => { ) .run( newerId, - "done", + "restart-scheduled", "2026-06-24T11:00:00.000Z", "2026-06-24T11:01:00.000Z", "def456", @@ -1591,7 +1591,7 @@ describe("backend service behavior", () => { ); expect(jobs.find((job) => job.id === newerId)).toMatchObject({ id: newerId, - status: "done", + status: "verifying", commit: "def456", commitTitle: "Newer deploy", note: "newer note", @@ -1971,7 +1971,10 @@ printf 'scheduled\n' .get(rollback.id) as { note: string | null; status: string } | undefined; return ( - row?.status === "restart-scheduled" && existsSync(systemdScriptLog) + row?.status === "restart-scheduled" && + row.note === + "Rollback target verified. Activating it, restarting services, then verifying web, worker, deployed commit, and 31 seconds of worker stability; automatic restoration is armed" && + existsSync(systemdScriptLog) ); }, 5000); await waitFor( @@ -1992,6 +1995,10 @@ printf 'scheduled\n' expect(guardian).toContain( "Original release cccccccc was restored automatically" ); + expect(guardian).toContain( + "Atomic rollback activated dddddddd. Web, worker, commit, and 31-second worker stability checks passed" + ); + expect(guardian).toContain("updatedAt: new Date().toISOString()"); expect(readFileSync(systemdArgumentsLog, "utf8")).toContain( `--unit=mira-dashboard-deploy-${rollback.id}\n` ); @@ -2531,7 +2538,7 @@ printf 'scheduled\n' expect(row).toEqual({ commit_sha: candidateCommit, commit_title: "Deployable dashboard commit", - note: "Immutable release published. Detached activation and rollback check scheduled", + note: "Release published. Activating it, restarting services, then verifying web, worker, deployed commit, and 31 seconds of worker stability; automatic rollback is armed", status: "restart-scheduled", }); await expect(Bun.file(gitLog).text()).resolves.toContain( @@ -2571,6 +2578,10 @@ printf 'scheduled\n' expect(restartCommand).toContain("worker_identity"); expect(restartCommand).toContain("ExecMainStartTimestampMonotonic"); expect(restartCommand).toContain("sleep 31"); + expect(restartCommand).toContain( + "Atomic release activated. Web, worker, commit, and 31-second worker stability checks passed" + ); + expect(restartCommand).toContain("updatedAt: new Date().toISOString()"); expect(restartCommand).toContain(".checks.release.backendCommit"); expect(restartCommand).toContain("releaseLifecycle.js"); expect(restartCommand).toContain( @@ -2666,7 +2677,7 @@ printf 'scheduled\n' 'if restart_services && ready_for_commit "${candidate_commit:0:8}"; then' ); expect(recoveryCommand).toContain( - "Interrupted release cutover recovered; active candidate passed restart and commit-bound readiness" + "Interrupted release cutover recovered; active candidate passed restart, commit-bound readiness, and 31-second worker stability" ); expect( recoveryCommand.indexOf( diff --git a/src/hooks/useDelivery.ts b/src/hooks/useDelivery.ts index b815c6062..e7113d0a1 100644 --- a/src/hooks/useDelivery.ts +++ b/src/hooks/useDelivery.ts @@ -37,7 +37,7 @@ export interface PullRequestSummary { /** Represents deployment job. */ export interface DeploymentJob { id: string; - status: "building" | "restart-scheduled" | "isOk" | "failed"; + status: "building" | "verifying" | "isOk" | "failed"; startedAt: string; updatedAt: string; commit?: string; diff --git a/src/pages/Delivery.tsx b/src/pages/Delivery.tsx index 0218ce00b..00cf69ffd 100644 --- a/src/pages/Delivery.tsx +++ b/src/pages/Delivery.tsx @@ -294,7 +294,7 @@ function normalizedCheckValue(value: unknown): string { function deploymentVariant(status: DeploymentJob["status"]) { if (status === "isOk") return "success" as const; if (status === "failed") return "error" as const; - if (status === "restart-scheduled") return "warning" as const; + if (status === "verifying") return "warning" as const; return "info" as const; } diff --git a/src/test/pageBehavior.test.tsx b/src/test/pageBehavior.test.tsx index afbb982aa..5cf180d8b 100644 --- a/src/test/pageBehavior.test.tsx +++ b/src/test/pageBehavior.test.tsx @@ -1802,7 +1802,7 @@ function apiResponse(url: string, method: string, init?: RequestInit) { deployment: { id: "deploy-2", commit: "def456", - status: "restart-scheduled", + status: "verifying", updatedAt: "2026-06-24T08:15:00.000Z", note: "Deploy scheduled", }, @@ -3299,6 +3299,44 @@ describe("Mira Dashboard pages", () => { view.queryClient.clear(); }); + it("labels post-restart deployment checks as verifying", async () => { + Object.defineProperty(globalThis, "fetch", { + configurable: true, + value: jest.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url === "/api/pull-requests/deployments") { + return Response.json({ + deployments: [ + { + id: "deploy-verifying", + commit: "abc123", + commitTitle: "Verify deployment status", + status: "verifying", + updatedAt: "2026-06-24T08:10:00.000Z", + note: "Restarting services and verifying readiness", + }, + ], + }); + } + + const method = init?.method ?? "GET"; + return apiResponse(url, method, init); + }), + writable: true, + }); + + const view = renderPage(createElement(Delivery)); + + await waitFor(() => { + expect( + screen.getByText("Restarting services and verifying readiness") + ).toBeInTheDocument(); + expect(screen.getByText("verifying")).toBeInTheDocument(); + }); + view.unmount(); + view.queryClient.clear(); + }); + it("summarizes pull request checks from the latest record per check", async () => { Object.defineProperty(globalThis, "fetch", { configurable: true, From c3cfc43e8eb6b5f4cdaaf99d77061e7925d87e19 Mon Sep 17 00:00:00 2001 From: mira-2026 Date: Mon, 27 Jul 2026 20:49:45 +0200 Subject: [PATCH 2/9] fix: consolidate Dashboard runtime paths --- README.md | 14 +- backend/package.json | 2 +- backend/src/database.ts | 13 +- backend/src/development/developmentStack.ts | 125 ++++++++++++- backend/src/gateway.ts | 16 +- backend/src/lib/dashboardPaths.ts | 61 +++++++ backend/src/releaseDeployment.ts | 82 ++++++--- backend/src/releaseManager.ts | 9 +- backend/src/services/cacheRefresh.ts | 6 +- backend/src/services/logRotation.ts | 14 +- .../src/services/pullRequestPreviewHost.ts | 14 +- backend/src/services/pullRequestPreviews.ts | 19 ++ backend/src/services/pullRequests.ts | 34 +--- backend/src/services/scheduledJobs.ts | 6 +- backend/src/services/sqliteMaintenance.ts | 4 +- backend/test/developmentStack.test.ts | 111 ++++++++++-- backend/test/jobExecutionQueue.test.ts | 20 +- backend/test/pullRequestPreview.test.ts | 51 ++++++ backend/test/releaseDeployment.test.ts | 84 ++++++--- backend/test/serviceBehavior.test.ts | 134 ++++++++++++-- backend/test/testDatabaseGuard.test.ts | 4 +- docs/architecture/database.md | 12 +- docs/architecture/gateway-and-chat.md | 2 +- docs/development/local-dev.md | 34 ++-- docs/development/testing-and-prs.md | 2 +- docs/index.md | 2 +- docs/operations/runbooks.md | 45 +++-- docs/operations/scheduler-cache-backups.md | 8 +- docs/operations/troubleshooting.md | 4 +- docs/security/auth-and-trust-boundaries.md | 8 +- docs/setup/new-vps.md | 37 ++-- docs/setup/production-deploy.md | 92 +++++----- docs/setup/secrets-and-env.md | 83 +++++---- .../delivery/PullRequestDevelopmentCard.tsx | 29 ++- src/hooks/useDelivery.ts | 1 + src/pages/Delivery.tsx | 12 +- src/test/openClawAdapterVariants.test.ts | 4 +- src/test/pageBehavior.test.tsx | 171 +++++++++++++----- src/test/pullRequestDevelopmentCard.test.tsx | 18 ++ systemd/mira-dashboard-worker.service | 10 +- systemd/mira-dashboard.service | 10 +- 41 files changed, 1030 insertions(+), 377 deletions(-) create mode 100644 backend/src/lib/dashboardPaths.ts diff --git a/README.md b/README.md index 9b4dc3816..73e846b95 100644 --- a/README.md +++ b/README.md @@ -142,22 +142,22 @@ CI and local verification. ## Production checkout and PR worktrees -`/home/ubuntu/projects/mira-dashboard` is the production checkout. Keep it on `main`; the running service and deploy workflow build from this path only after Raymond approves a merge/deploy. +`/home/ubuntu/projects/mira-dashboard/production/checkout` is the production checkout. Keep it on `main`; the running service and deploy workflow build from this path only after Raymond approves a merge/deploy. -Feature and autopilot work must use separate git worktrees under `/home/ubuntu/projects/mira-dashboard-worktrees`, for example: +Feature and autopilot work must use separate git worktrees under `/home/ubuntu/projects/mira-dashboard/development/worktrees`, for example: ```bash -mkdir -p /home/ubuntu/projects/mira-dashboard-worktrees -git -C /home/ubuntu/projects/mira-dashboard fetch --prune origin -git -C /home/ubuntu/projects/mira-dashboard worktree add \ +mkdir -p /home/ubuntu/projects/mira-dashboard/development/worktrees +git -C /home/ubuntu/projects/mira-dashboard/production/checkout fetch --prune origin +git -C /home/ubuntu/projects/mira-dashboard/production/checkout worktree add \ -b mira/ \ - /home/ubuntu/projects/mira-dashboard-worktrees/ \ + /home/ubuntu/projects/mira-dashboard/development/worktrees/ \ main ``` Run lint/build verification inside the worktree, not the production checkout. This prevents unapproved PR branches from writing live `dist/` or `backend/dist` artifacts. -The Dashboard PR approval/rejection endpoints attempt to remove the matching local worktree after a PR is merged or rejected. Cleanup is best-effort: it only removes paths under `/home/ubuntu/projects/mira-dashboard-worktrees` and skips worktrees with uncommitted changes. +The Dashboard PR approval/rejection endpoints attempt to remove the matching local worktree after a PR is merged or rejected. Cleanup is best-effort: it only removes paths under `/home/ubuntu/projects/mira-dashboard/development/worktrees` and skips worktrees with uncommitted changes. ## Safety notes for agents diff --git a/backend/package.json b/backend/package.json index ffdcc89a4..c764d557b 100644 --- a/backend/package.json +++ b/backend/package.json @@ -9,7 +9,7 @@ "build:backend": "bun node_modules/@typescript/native/bin/tsc --noEmit && bun scripts/build.ts", "db:preflight": "bun dist/databasePreflight.js", "deploy:prepare:backend": "bun run build:backend && bun run db:preflight", - "auth:reset-password": "MIRA_DASHBOARD_DB_PATH=${MIRA_DASHBOARD_DB_PATH:-/home/ubuntu/projects/mira-dashboard-state/mira-dashboard.db} NODE_ENV=production doppler run --config prd --project rajohan --preserve-env=MIRA_DASHBOARD_DB_PATH -- bun dist/resetDashboardPassword.js", + "auth:reset-password": "NODE_ENV=production doppler run --config prd --project rajohan --preserve-env=NODE_ENV,MIRA_DASHBOARD_PROJECT_ROOT -- bun dist/resetDashboardPassword.js", "start:backend": "NODE_ENV=production doppler run --config prd --project rajohan -- bun dist/serverStart.js", "start:worker": "NODE_ENV=production doppler run --config prd --project rajohan -- bun dist/workerStart.js", "lint:backend": "eslint . --cache --cache-strategy content", diff --git a/backend/src/database.ts b/backend/src/database.ts index f763a2572..039d20c2b 100644 --- a/backend/src/database.ts +++ b/backend/src/database.ts @@ -9,6 +9,10 @@ import { prepareDatabaseStorage, secureSqliteFilePermissions, } from "./databaseStorage.ts"; +import { + configuredDashboardProjectPaths, + resolveDashboardProjectPaths, +} from "./lib/dashboardPaths.ts"; type DatabaseSync = Database; @@ -25,7 +29,14 @@ function resolveDatabasePath(): { configuredDatabasePath: string | undefined; databasePath: string; } { - const configuredDatabasePath = process.env.MIRA_DASHBOARD_DB_PATH?.trim(); + const projectPaths = + configuredDashboardProjectPaths() ?? + (process.env.NODE_ENV === "production" + ? resolveDashboardProjectPaths() + : undefined); + const configuredDatabasePath = + process.env.MIRA_DASHBOARD_DB_PATH?.trim() || + projectPaths?.productionDatabasePath; return { configuredDatabasePath, databasePath: configuredDatabasePath diff --git a/backend/src/development/developmentStack.ts b/backend/src/development/developmentStack.ts index f8d97dfa0..b55863fa2 100644 --- a/backend/src/development/developmentStack.ts +++ b/backend/src/development/developmentStack.ts @@ -25,6 +25,7 @@ import path from "node:path"; import { Database } from "bun:sqlite"; +import { dashboardProjectPaths } from "../lib/dashboardPaths.ts"; import { formatOpenClawLogDate } from "../lib/logRoots.ts"; import { type DevelopmentWorkspaceState, @@ -393,10 +394,14 @@ export function resolveDevelopmentStackConfig( if (!hostHome) { throw new Error("Could not resolve the host home for development snapshots"); } + const hostDashboardPaths = dashboardProjectPaths( + environment.MIRA_DASHBOARD_PROJECT_ROOT?.trim() || + path.join(hostHome, "projects", "mira-dashboard") + ); const stateRoot = absoluteNonRootPath( "MIRA_DASHBOARD_DEV_STATE_ROOT", environment.MIRA_DASHBOARD_DEV_STATE_ROOT, - path.join(hostHome, "projects", "mira-dashboard-dev-state", "local") + hostDashboardPaths.developmentLocalStateRoot ); if (!stateRoot) { throw new Error("Development state root could not be resolved"); @@ -428,7 +433,7 @@ export function resolveDevelopmentStackConfig( databaseSource: absoluteNonRootPath( "MIRA_DASHBOARD_DEV_DB_SOURCE", environment.MIRA_DASHBOARD_DEV_DB_SOURCE, - path.join(hostHome, "projects", "mira-dashboard-state", "mira-dashboard.db") + hostDashboardPaths.productionDatabasePath ), frontendHost: configuredHost( "MIRA_DASHBOARD_DEV_FRONTEND_HOST", @@ -457,7 +462,7 @@ export function resolveDevelopmentStackConfig( releaseSource: absoluteNonRootPath( "MIRA_DASHBOARD_DEV_RELEASES_SOURCE", environment.MIRA_DASHBOARD_DEV_RELEASES_SOURCE, - path.join(hostHome, "projects", "mira-dashboard-releases") + hostDashboardPaths.productionReleasesRoot ), repositoryRoot: resolvedRepoRoot, rpId: publicOrigin.hostname.toLowerCase(), @@ -562,6 +567,111 @@ function runIfTableExists( } } +interface CompletedDeploymentHistoryRow { + commit_sha: null | string; + commit_title: null | string; + id: string; + note: null | string; + started_at: string; + status: "failed" | "isOk"; + stderr: null | string; + stdout: null | string; + updated_at: string; +} + +/** + * Restores terminal release history into snapshots created by older Dashboard + * versions without refreshing any mutable development data. + */ +function backfillCompletedDeploymentHistory( + sourcePath: string, + targetPath: string +): void { + if (!isRealRegularFile(sourcePath)) { + throw new Error( + `MIRA_DASHBOARD_DEV_DB_SOURCE must be a real regular file: ${sourcePath}` + ); + } + if (path.resolve(sourcePath) === path.resolve(targetPath)) { + throw new Error("Development database source and target must be distinct"); + } + + const target = new Database(targetPath); + try { + if (!hasTable(target, "deployment_jobs")) return; + target.run("DELETE FROM deployment_jobs WHERE status NOT IN ('isOk', 'failed')"); + const existing = target + .query( + "SELECT 1 FROM deployment_jobs WHERE status IN ('isOk', 'failed') LIMIT 1" + ) + .get(); + if (existing) return; + + const source = new Database(sourcePath, { readonly: true }); + let rows: CompletedDeploymentHistoryRow[]; + try { + if (!hasTable(source, "deployment_jobs")) return; + rows = source + .query( + `SELECT + id, + status, + started_at, + updated_at, + commit_sha, + commit_title, + note, + stdout, + stderr + FROM deployment_jobs + WHERE status IN ('isOk', 'failed') + ORDER BY started_at, id` + ) + .all() as CompletedDeploymentHistoryRow[]; + } finally { + source.close(); + } + if (rows.length === 0) return; + + target.run("BEGIN IMMEDIATE"); + try { + const insert = target.prepare( + `INSERT INTO deployment_jobs ( + id, + status, + started_at, + updated_at, + commit_sha, + commit_title, + note, + stdout, + stderr + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` + ); + for (const row of rows) { + insert.run( + row.id, + row.status, + row.started_at, + row.updated_at, + row.commit_sha, + row.commit_title, + row.note, + row.stdout, + row.stderr + ); + } + target.run("COMMIT"); + } catch (error) { + if (target.inTransaction) target.run("ROLLBACK"); + throw error; + } + } finally { + target.close(); + } +} + function scrubDevelopmentDatabase( databasePath: string, shouldPreserveWebAuthnCredentials: boolean @@ -613,7 +723,11 @@ function scrubDevelopmentDatabase( database.run("DELETE FROM app_config WHERE key = 'gateway_token'"); } runIfTableExists(database, "deployment_lock", "DELETE FROM deployment_lock"); - runIfTableExists(database, "deployment_jobs", "DELETE FROM deployment_jobs"); + runIfTableExists( + database, + "deployment_jobs", + "DELETE FROM deployment_jobs WHERE status NOT IN ('isOk', 'failed')" + ); runIfTableExists(database, "job_executions", "DELETE FROM job_executions"); runIfTableExists( database, @@ -899,6 +1013,9 @@ export function prepareDevelopmentState( } else { database = "created-empty"; } + if (config.databaseSource && isRealRegularFile(config.databasePath)) { + backfillCompletedDeploymentHistory(config.databaseSource, config.databasePath); + } let releases: DevelopmentStateResult["releases"]; const currentRelease = releaseCommitForSlot(config.releaseRoot, "current"); diff --git a/backend/src/gateway.ts b/backend/src/gateway.ts index 6bd9e1682..30165c711 100644 --- a/backend/src/gateway.ts +++ b/backend/src/gateway.ts @@ -5,6 +5,10 @@ import Path from "node:path"; import { OpenClawChatBridge } from "./chat/openClawChatBridge.ts"; import { SqliteOpenClawChatSnapshotStore } from "./chat/openClawChatSnapshotStore.ts"; import type { DashboardSocket } from "./dashboardSocket.ts"; +import { + configuredDashboardProjectPaths, + resolveDashboardProjectPaths, +} from "./lib/dashboardPaths.ts"; import { errorMessage } from "./lib/errors.ts"; import { type DeviceIdentity, @@ -31,11 +35,13 @@ function defaultOpenClawHome(): string { : Path.join(process.cwd(), "data", "openclaw"); } -const DEFAULT_DASHBOARD_OPENCLAW_HOME = Path.join( - process.cwd(), - "data", - "openclaw-client" -); +const DEFAULT_DASHBOARD_OPENCLAW_HOME = + ( + configuredDashboardProjectPaths() ?? + (process.env.NODE_ENV === "production" + ? resolveDashboardProjectPaths() + : undefined) + )?.productionOpenClawHome ?? Path.join(process.cwd(), "data", "openclaw-client"); /** Performs load or create dashboard device IDentity. */ function loadOrCreateDashboardDeviceIdentity( diff --git a/backend/src/lib/dashboardPaths.ts b/backend/src/lib/dashboardPaths.ts new file mode 100644 index 000000000..242c56a62 --- /dev/null +++ b/backend/src/lib/dashboardPaths.ts @@ -0,0 +1,61 @@ +import path from "node:path"; + +import { resolveAbsoluteNonRootPath } from "./safePath.ts"; + +const FALLBACK_DASHBOARD_PROJECT_ROOT = "/home/ubuntu/projects/mira-dashboard"; + +export interface DashboardProjectPaths { + developmentLocalStateRoot: string; + developmentPreviewRoot: string; + developmentPreviewStateRoot: string; + developmentRoot: string; + developmentWorktreeRoot: string; + productionCheckoutRoot: string; + productionDatabasePath: string; + productionLogRotationLockFile: string; + productionOpenClawHome: string; + productionReleasesRoot: string; + productionRoot: string; + productionStateRoot: string; + projectRoot: string; +} + +export function dashboardProjectPaths(projectRoot: string): DashboardProjectPaths { + const root = resolveAbsoluteNonRootPath(projectRoot, "Dashboard project root"); + const productionRoot = path.join(root, "production"); + const productionStateRoot = path.join(productionRoot, "state"); + const developmentRoot = path.join(root, "development"); + return { + developmentLocalStateRoot: path.join(developmentRoot, "state", "local"), + developmentPreviewRoot: path.join(developmentRoot, "preview"), + developmentPreviewStateRoot: path.join(developmentRoot, "state", "preview"), + developmentRoot, + developmentWorktreeRoot: path.join(developmentRoot, "worktrees"), + productionCheckoutRoot: path.join(productionRoot, "checkout"), + productionDatabasePath: path.join(productionStateRoot, "mira-dashboard.db"), + productionLogRotationLockFile: path.join( + productionStateRoot, + "log-rotation.lock" + ), + productionOpenClawHome: path.join(productionStateRoot, "openclaw-client"), + productionReleasesRoot: path.join(productionRoot, "releases"), + productionRoot, + productionStateRoot, + projectRoot: root, + }; +} + +export function configuredDashboardProjectPaths( + environment: NodeJS.ProcessEnv = process.env +): DashboardProjectPaths | undefined { + const configuredRoot = environment.MIRA_DASHBOARD_PROJECT_ROOT?.trim(); + return configuredRoot ? dashboardProjectPaths(configuredRoot) : undefined; +} + +export function resolveDashboardProjectPaths( + environment: NodeJS.ProcessEnv = process.env +): DashboardProjectPaths { + return dashboardProjectPaths( + environment.MIRA_DASHBOARD_PROJECT_ROOT?.trim() || FALLBACK_DASHBOARD_PROJECT_ROOT + ); +} diff --git a/backend/src/releaseDeployment.ts b/backend/src/releaseDeployment.ts index 03a219887..851cd7c77 100644 --- a/backend/src/releaseDeployment.ts +++ b/backend/src/releaseDeployment.ts @@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto"; import fsp from "node:fs/promises"; import path from "node:path"; +import { resolveDashboardProjectPaths } from "./lib/dashboardPaths.ts"; import { runProcess } from "./lib/processes.ts"; import { resolveAbsoluteNonRootPath } from "./lib/safePath.ts"; import { @@ -14,12 +15,6 @@ import { } from "./releaseManager.ts"; const RELEASE_COMMIT_SHA_PATTERN = /^[\da-f]{40}$/u; -const DEFAULT_DASHBOARD_SOURCE_ROOT = "/home/ubuntu/projects/mira-dashboard"; -const DEFAULT_DASHBOARD_WORKTREE_ROOT = "/home/ubuntu/projects/mira-dashboard-worktrees"; -const DEFAULT_DASHBOARD_STATE_ROOT = "/home/ubuntu/projects/mira-dashboard-state"; -const DEFAULT_DASHBOARD_DATABASE_PATH = `${DEFAULT_DASHBOARD_STATE_ROOT}/mira-dashboard.db`; -const DEFAULT_DASHBOARD_OPENCLAW_HOME = `${DEFAULT_DASHBOARD_STATE_ROOT}/openclaw-client`; -const DEFAULT_DASHBOARD_LOG_ROTATION_LOCK_FILE = `${DEFAULT_DASHBOARD_STATE_ROOT}/log-rotation.lock`; const MAX_PROCESS_OUTPUT_BYTES = 20 * 1024 * 1024; export const MANAGED_DASHBOARD_UNITS = { "mira-dashboard-worker.service": "dist/workerStart.js", @@ -30,11 +25,7 @@ export const MANAGED_DASHBOARD_PRESERVED_ENVIRONMENT = [ "MIRA_DASHBOARD_EXECUTION_ROLE", "MIRA_DASHBOARD_ENABLE_JOB_SCOPES", "MIRA_DASHBOARD_JOB_SCOPE_OWNER", - "MIRA_DASHBOARD_DB_PATH", - "MIRA_DASHBOARD_LOG_ROTATION_LOCK_FILE", - "MIRA_DASHBOARD_OPENCLAW_HOME", - "MIRA_DASHBOARD_RELEASE_ROOT", - "MIRA_DASHBOARD_RELEASES_ROOT", + "MIRA_DASHBOARD_PROJECT_ROOT", ] as const; const MANAGED_DASHBOARD_UNIT_POLICY_ENVIRONMENT = { "mira-dashboard-worker.service": [ @@ -83,8 +74,13 @@ export interface ManagedDashboardUnitContract { databasePath: string; logRotationLockFile: string; openClawHome: string; + previewRoot: string; + previewWorktreePath: string; + projectRoot: string; releaseRoot: string; releasesRoot: string; + sourceRoot: string; + worktreeRoot: string; } type ManagedDashboardUnitName = keyof typeof MANAGED_DASHBOARD_UNITS; @@ -98,8 +94,13 @@ function managedReleaseEnvironment( MIRA_DASHBOARD_DB_PATH: contract.databasePath, MIRA_DASHBOARD_LOG_ROTATION_LOCK_FILE: contract.logRotationLockFile, MIRA_DASHBOARD_OPENCLAW_HOME: contract.openClawHome, + MIRA_DASHBOARD_PREVIEW_ROOT: contract.previewRoot, + MIRA_DASHBOARD_PREVIEW_WORKTREE_PATH: contract.previewWorktreePath, + MIRA_DASHBOARD_PROJECT_ROOT: contract.projectRoot, MIRA_DASHBOARD_RELEASE_ROOT: releaseRoot, MIRA_DASHBOARD_RELEASES_ROOT: contract.releasesRoot, + MIRA_DASHBOARD_ROOT: contract.sourceRoot, + MIRA_DASHBOARD_WORKTREE_ROOT: contract.worktreeRoot, NODE_ENV: "production", }; } @@ -216,21 +217,51 @@ async function defaultCommandRunner( export function managedDashboardUnitContract( releasesRoot = resolveDashboardReleasesRoot(), - databasePath = process.env.MIRA_DASHBOARD_DB_PATH ?? DEFAULT_DASHBOARD_DATABASE_PATH, - openClawHome = process.env.MIRA_DASHBOARD_OPENCLAW_HOME ?? - DEFAULT_DASHBOARD_OPENCLAW_HOME + databasePath?: string, + openClawHome?: string ): ManagedDashboardUnitContract { + const projectPaths = resolveDashboardProjectPaths(); const root = resolveAbsoluteNonRootPath(releasesRoot, "Dashboard releases root"); return { - databasePath: resolveAbsoluteNonRootPath(databasePath, "Dashboard database path"), + databasePath: resolveAbsoluteNonRootPath( + databasePath ?? + process.env.MIRA_DASHBOARD_DB_PATH ?? + projectPaths.productionDatabasePath, + "Dashboard database path" + ), logRotationLockFile: resolveAbsoluteNonRootPath( process.env.MIRA_DASHBOARD_LOG_ROTATION_LOCK_FILE ?? - DEFAULT_DASHBOARD_LOG_ROTATION_LOCK_FILE, + projectPaths.productionLogRotationLockFile, "Dashboard log rotation lock file" ), - openClawHome: resolveAbsoluteNonRootPath(openClawHome, "Dashboard OpenClaw home"), + openClawHome: resolveAbsoluteNonRootPath( + openClawHome ?? + process.env.MIRA_DASHBOARD_OPENCLAW_HOME ?? + projectPaths.productionOpenClawHome, + "Dashboard OpenClaw home" + ), + previewRoot: resolveAbsoluteNonRootPath( + process.env.MIRA_DASHBOARD_PREVIEW_ROOT ?? + projectPaths.developmentPreviewStateRoot, + "Dashboard preview state root" + ), + previewWorktreePath: resolveAbsoluteNonRootPath( + process.env.MIRA_DASHBOARD_PREVIEW_WORKTREE_PATH ?? + projectPaths.developmentPreviewRoot, + "Dashboard preview worktree path" + ), + projectRoot: projectPaths.projectRoot, releaseRoot: path.join(root, "current"), releasesRoot: root, + sourceRoot: resolveAbsoluteNonRootPath( + process.env.MIRA_DASHBOARD_ROOT ?? projectPaths.productionCheckoutRoot, + "Dashboard source root" + ), + worktreeRoot: resolveAbsoluteNonRootPath( + process.env.MIRA_DASHBOARD_WORKTREE_ROOT ?? + projectPaths.developmentWorktreeRoot, + "Dashboard worktree root" + ), }; } @@ -241,11 +272,7 @@ export function assertManagedDashboardUnitProperties( ): void { const expectedEnvironment = [ ...MANAGED_DASHBOARD_UNIT_POLICY_ENVIRONMENT[unit], - `MIRA_DASHBOARD_DB_PATH=${contract.databasePath}`, - `MIRA_DASHBOARD_LOG_ROTATION_LOCK_FILE=${contract.logRotationLockFile}`, - `MIRA_DASHBOARD_OPENCLAW_HOME=${contract.openClawHome}`, - `MIRA_DASHBOARD_RELEASE_ROOT=${contract.releaseRoot}`, - `MIRA_DASHBOARD_RELEASES_ROOT=${contract.releasesRoot}`, + `MIRA_DASHBOARD_PROJECT_ROOT=${contract.projectRoot}`, ]; const expectedWorkingDirectory = `${contract.releaseRoot}/backend`; const actual = new Map( @@ -301,11 +328,12 @@ export async function stageDashboardRelease( "Dashboard releases root" ); const commandRunner = options.commandRunner ?? defaultCommandRunner; + const projectPaths = resolveDashboardProjectPaths(); const contract = managedDashboardUnitContract( releasesRoot, options.databasePath ?? process.env.MIRA_DASHBOARD_DB_PATH ?? - DEFAULT_DASHBOARD_DATABASE_PATH, + projectPaths.productionDatabasePath, options.openClawHome ); let existingRelease: ManagedDashboardRelease | undefined; @@ -328,11 +356,15 @@ export async function stageDashboardRelease( } const sourceRoot = resolveAbsoluteNonRootPath( - options.sourceRoot ?? DEFAULT_DASHBOARD_SOURCE_ROOT, + options.sourceRoot ?? + process.env.MIRA_DASHBOARD_ROOT ?? + projectPaths.productionCheckoutRoot, "Dashboard source root" ); const worktreeRoot = resolveAbsoluteNonRootPath( - options.worktreeRoot ?? DEFAULT_DASHBOARD_WORKTREE_ROOT, + options.worktreeRoot ?? + process.env.MIRA_DASHBOARD_WORKTREE_ROOT ?? + projectPaths.developmentWorktreeRoot, "Dashboard worktree root" ); await assertRealDirectory(sourceRoot, "Dashboard source root"); diff --git a/backend/src/releaseManager.ts b/backend/src/releaseManager.ts index d4f0e972f..a1ab1bec7 100644 --- a/backend/src/releaseManager.ts +++ b/backend/src/releaseManager.ts @@ -12,6 +12,10 @@ import { } from "./database.ts"; import { readAppliedDatabaseMigrationHistory } from "./databaseMigrationRunner.ts"; import type { DatabaseMigrationIdentity } from "./databaseMigrations/index.ts"; +import { + configuredDashboardProjectPaths, + resolveDashboardProjectPaths, +} from "./lib/dashboardPaths.ts"; import { guardedPath, writeTextNoFollowGuarded } from "./lib/guardedOps.ts"; import { resolveAbsoluteNonRootPath } from "./lib/safePath.ts"; import { @@ -23,8 +27,6 @@ import { verifyReleaseBuildIdentities, } from "./releaseManifest.ts"; -export const DEFAULT_DASHBOARD_RELEASES_ROOT = - "/home/ubuntu/projects/mira-dashboard-releases"; export const MANAGED_RELEASES_DIRECTORY_NAME = "releases"; const RELEASE_COMMIT_SHA_PATTERN = /^[\da-f]{40}$/u; @@ -368,7 +370,8 @@ async function writeReleaseTransitionJournal( export function resolveDashboardReleasesRoot( configuredRoot = process.env.MIRA_DASHBOARD_RELEASES_ROOT ?? - DEFAULT_DASHBOARD_RELEASES_ROOT + configuredDashboardProjectPaths()?.productionReleasesRoot ?? + resolveDashboardProjectPaths({}).productionReleasesRoot ): string { return resolveAbsoluteNonRootPath(configuredRoot, "Dashboard releases root"); } diff --git a/backend/src/services/cacheRefresh.ts b/backend/src/services/cacheRefresh.ts index f57bc3471..862a30190 100644 --- a/backend/src/services/cacheRefresh.ts +++ b/backend/src/services/cacheRefresh.ts @@ -16,6 +16,7 @@ import { invalidateCacheEntry, parseJsonField, } from "../lib/cacheStore.ts"; +import { resolveDashboardProjectPaths } from "../lib/dashboardPaths.ts"; import type { JobResourceClass } from "../lib/jobResources.ts"; import { runProcess } from "../lib/processes.ts"; import { nonEmptyEnvironmentFallback } from "../lib/values.ts"; @@ -59,6 +60,7 @@ const KOPIA_EXPECTED_SOURCE_PATHS = [ ] as const; const BACKUP_STATUS_STALE_HOURS = 30; const BACKUP_STATUS_MAX_TTL_HOURS = 25; +const dashboardProjectPaths = resolveDashboardProjectPaths(); type JsonRecord = Record; type CacheTtlUnit = "hours" | "minutes"; @@ -82,7 +84,7 @@ const SPYDEBERG = { const CODEX_TRUSTED_DIRS = [ "/home/ubuntu/.openclaw", "/home/ubuntu/projects", - "/home/ubuntu/projects/mira-dashboard", + dashboardProjectPaths.projectRoot, ]; const MOLTBOOK_CACHE_KEY_LIST = [ "moltbook.home", @@ -108,7 +110,7 @@ const gitRepos = [ { key: "mira-dashboard", name: "mira-dashboard", - path: "/home/ubuntu/projects/mira-dashboard", + path: dashboardProjectPaths.productionCheckoutRoot, category: "project", }, { diff --git a/backend/src/services/logRotation.ts b/backend/src/services/logRotation.ts index 090a363ea..ade7f4ac7 100644 --- a/backend/src/services/logRotation.ts +++ b/backend/src/services/logRotation.ts @@ -3,6 +3,10 @@ import fs from "node:fs/promises"; import path from "node:path"; import { database } from "../database.ts"; +import { + configuredDashboardProjectPaths, + resolveDashboardProjectPaths, +} from "../lib/dashboardPaths.ts"; import { runProcess } from "../lib/processes.ts"; import { resolveAbsoluteNonRootPath } from "../lib/safePath.ts"; import { writeCacheSuccess } from "./cacheEntryWriter.ts"; @@ -73,13 +77,21 @@ const ELEVATED_LOG_ROTATION_FORWARDED_ENVIRONMENT = [ "LANG", "NODE_ENV", "TZ", + "MIRA_DASHBOARD_PROJECT_ROOT", "MIRA_DASHBOARD_DB_PATH", "MIRA_DASHBOARD_LOG_ROTATION_LOCK_FILE", ] as const; function resolveLogRotationLockFile(): string { return resolveAbsoluteNonRootPath( - process.env.MIRA_DASHBOARD_LOG_ROTATION_LOCK_FILE?.trim() || DEFAULT_LOCK_FILE, + process.env.MIRA_DASHBOARD_LOG_ROTATION_LOCK_FILE?.trim() || + ( + configuredDashboardProjectPaths() ?? + (process.env.NODE_ENV === "production" + ? resolveDashboardProjectPaths() + : undefined) + )?.productionLogRotationLockFile || + DEFAULT_LOCK_FILE, "MIRA_DASHBOARD_LOG_ROTATION_LOCK_FILE" ); } diff --git a/backend/src/services/pullRequestPreviewHost.ts b/backend/src/services/pullRequestPreviewHost.ts index 360533655..9a5e89a06 100644 --- a/backend/src/services/pullRequestPreviewHost.ts +++ b/backend/src/services/pullRequestPreviewHost.ts @@ -22,6 +22,7 @@ import { prepareDevelopmentState, resolveDevelopmentStackConfig, } from "../development/developmentStack.ts"; +import { resolveDashboardProjectPaths } from "../lib/dashboardPaths.ts"; import { errorMessage } from "../lib/errors.ts"; import { runProcess } from "../lib/processes.ts"; import { @@ -41,7 +42,6 @@ const COMMIT_PATTERN = /^[\da-f]{40}$/u; const UNIT_NAME_PATTERN = /^[A-Za-z0-9_.@-]+\.service$/u; const DEFAULT_GATEWAY_URL = "ws://127.0.0.1:18789"; const DEFAULT_GATEWAY_PROXY_PORT = 18_790; -const DEFAULT_PREVIEW_WORKTREE_PATH = "/home/ubuntu/projects/mira-dashboard-preview"; const MANAGED_STATE_DIRECTORY_PATTERN = /^pr-([1-9]\d*)$/u; const PREVIEW_REFERENCE = "refs/mira-dashboard/previews/active"; const PREVIEW_GATEWAY_PROXY_ENTRYPOINT = "pullRequestPreviewGatewayProxy.js"; @@ -67,6 +67,7 @@ export type PullRequestPreviewLifecycle = export interface PullRequestPreviewStatus { backendPort?: number; commitSha?: string; + controlsAvailable?: boolean; frontendPort?: number; message?: string; number?: number; @@ -283,19 +284,20 @@ function defaultGatewayProxyEntrypoint(): string { export function resolvePullRequestPreviewConfig( environment: Record = process.env ): PullRequestPreviewConfig { + const projectPaths = resolveDashboardProjectPaths(environment); const dashboardRoot = absoluteNonRootPath( "MIRA_DASHBOARD_ROOT", - environment.MIRA_DASHBOARD_ROOT?.trim() || "/home/ubuntu/projects/mira-dashboard" + environment.MIRA_DASHBOARD_ROOT?.trim() || projectPaths.productionCheckoutRoot ); const previewRoot = absoluteNonRootPath( "MIRA_DASHBOARD_PREVIEW_ROOT", environment.MIRA_DASHBOARD_PREVIEW_ROOT?.trim() || - "/home/ubuntu/projects/mira-dashboard-preview-state/managed" + projectPaths.developmentPreviewStateRoot ); const managedWorktreePath = absoluteNonRootPath( "MIRA_DASHBOARD_PREVIEW_WORKTREE_PATH", environment.MIRA_DASHBOARD_PREVIEW_WORKTREE_PATH?.trim() || - DEFAULT_PREVIEW_WORKTREE_PATH + projectPaths.developmentPreviewRoot ); if ( [dashboardRoot, previewRoot].some( @@ -366,7 +368,7 @@ export function resolvePullRequestPreviewConfig( databaseTemplate: optionalAbsoluteNonRootPath( "MIRA_DASHBOARD_PREVIEW_DB_TEMPLATE", environment.MIRA_DASHBOARD_PREVIEW_DB_TEMPLATE?.trim() || - "/home/ubuntu/projects/mira-dashboard-state/mira-dashboard.db" + projectPaths.productionDatabasePath ), frontendPort, gatewayProxyEntrypoint: absoluteNonRootPath( @@ -402,7 +404,7 @@ export function resolvePullRequestPreviewConfig( releaseSource: optionalAbsoluteNonRootPath( "MIRA_DASHBOARD_PREVIEW_RELEASES_SOURCE", environment.MIRA_DASHBOARD_PREVIEW_RELEASES_SOURCE?.trim() || - "/home/ubuntu/projects/mira-dashboard-releases" + projectPaths.productionReleasesRoot ), sessionIdleMinutes: optionalEnvironmentValue( "MIRA_DASHBOARD_SESSION_IDLE_MINUTES", diff --git a/backend/src/services/pullRequestPreviews.ts b/backend/src/services/pullRequestPreviews.ts index 022169b57..c4310f013 100644 --- a/backend/src/services/pullRequestPreviews.ts +++ b/backend/src/services/pullRequestPreviews.ts @@ -39,6 +39,8 @@ const PREVIEW_ACTION_KEYS = new Set([ "dashboard.preview.start", "dashboard.preview.stop", ]); +const PREVIEW_CONTROLS_UNAVAILABLE_MESSAGE = + "PR dev controls are available only from the production Dashboard."; const COMMIT_SHA_PATTERN = /^[\da-f]{40}$/u; const PREVIEW_LIFECYCLES = new Set([ "failed", @@ -92,6 +94,12 @@ export function parsePullRequestPreviewStatus(value: unknown): PullRequestPrevie if (!isRecord(value) || !PREVIEW_LIFECYCLES.has(value.status as never)) { throw new Error("Preview execution returned an invalid status"); } + if ( + value.controlsAvailable !== undefined && + typeof value.controlsAvailable !== "boolean" + ) { + throw new Error("Preview execution returned invalid control availability"); + } const status = value.status as PullRequestPreviewLifecycle; const number = value.number; if (number !== undefined && (!Number.isSafeInteger(number) || Number(number) <= 0)) { @@ -116,6 +124,9 @@ export function parsePullRequestPreviewStatus(value: unknown): PullRequestPrevie ...(typeof value.commitSha === "string" && { commitSha: value.commitSha, }), + ...(typeof value.controlsAvailable === "boolean" && { + controlsAvailable: value.controlsAvailable, + }), ...(typeof value.frontendPort === "number" && { frontendPort: value.frontendPort, }), @@ -140,6 +151,13 @@ function previewFromExecution(execution: JobExecution): PullRequestPreviewStatus /** Reads the current preview state, including queued lifecycle transitions. */ export async function getPullRequestPreviewStatus(): Promise { + if (process.env.MIRA_DASHBOARD_DEV_SAFE_MODE === "1") { + return { + controlsAvailable: false, + message: PREVIEW_CONTROLS_UNAVAILABLE_MESSAGE, + status: "stopped", + }; + } const preview = await readPullRequestPreviewStatus(); const activeExecution = listJobExecutions(200).find( (execution) => @@ -175,6 +193,7 @@ export async function getPullRequestPreviewStatus(): Promise { + if (process.env.MIRA_DASHBOARD_DEV_SAFE_MODE === "1") return; try { if ( listJobExecutions(200).some( diff --git a/backend/src/services/pullRequests.ts b/backend/src/services/pullRequests.ts index 4411197f2..73a492d8e 100644 --- a/backend/src/services/pullRequests.ts +++ b/backend/src/services/pullRequests.ts @@ -1,6 +1,7 @@ import path from "node:path"; import { database, getMiraDatabasePath, sqlNullable } from "../database.ts"; +import { resolveDashboardProjectPaths } from "../lib/dashboardPaths.ts"; import { errorMessage } from "../lib/errors.ts"; import { killProcessGroup, @@ -95,7 +96,7 @@ const DEPLOYMENT_WORKER_STABILITY_SECONDS = Math.ceil(JOB_WORKER_HEARTBEAT_MAX_AGE_MS / 1000) + 1; const PASSING_CHECK_VALUES = new Set(["success", "successful", "neutral", "skipped"]); const OPINIONATED_REVIEW_STATES = new Set(["APPROVED", "CHANGES_REQUESTED", "DISMISSED"]); -const ACTIVE_DEPLOYMENT_STATUSES = new Set(["building", "restart-scheduled"]); +const ACTIVE_DEPLOYMENT_STATUSES = new Set(["building", "verifying"]); const FULL_COMMIT_SHA_PATTERN = /^[\da-f]{40}$/u; const BUN_EXECUTABLE = process.env.BUN_BINARY || "bun"; const publicPullRequestCache: { @@ -132,14 +133,14 @@ export function getResolvedRoots() { function getDashboardRoot(): string { return resolveConfiguredRoot( "MIRA_DASHBOARD_ROOT", - "/home/ubuntu/projects/mira-dashboard" + resolveDashboardProjectPaths().productionCheckoutRoot ); } function getDashboardWorktreeRoot(): string { return resolveConfiguredRoot( "MIRA_DASHBOARD_WORKTREE_ROOT", - "/home/ubuntu/projects/mira-dashboard-worktrees" + resolveDashboardProjectPaths().developmentWorktreeRoot ); } @@ -210,7 +211,7 @@ interface PublicGitHubPullRequest { /** Represents deployment job. */ interface DeploymentJob { id: string; - status: "building" | "restart-scheduled" | "isOk" | "failed"; + status: "building" | "verifying" | "isOk" | "failed"; startedAt: string; updatedAt: string; commit?: string; @@ -348,21 +349,6 @@ function mapDeploymentJob(row: DeploymentJobRow): DeploymentJob { }; } -interface PublicDeploymentJob extends Omit { - status: "building" | "verifying" | "isOk" | "failed"; -} - -/** - * Keeps the persisted cutover protocol compatible with the previous release - * while exposing the work actually in progress to operators. - */ -function publicDeploymentJob(job: DeploymentJob): PublicDeploymentJob { - return { - ...job, - status: job.status === "restart-scheduled" ? "verifying" : job.status, - }; -} - /** Reads one deployment job. */ function readDeploymentJob(jobId: string): DeploymentJob | undefined { const row = database @@ -749,7 +735,7 @@ function refreshDeploymentHeartbeat(job: DeploymentJob): DeploymentJob { } /** Performs read deployment jobs. */ -export function readDeploymentJobs(): PublicDeploymentJob[] { +export function readDeploymentJobs(): DeploymentJob[] { return ( database .prepare( @@ -770,7 +756,7 @@ export function readDeploymentJobs(): PublicDeploymentJob[] { ` ) .all(RECENT_DEPLOYMENTS_LIMIT) as unknown as DeploymentJobRow[] - ).map((row) => publicDeploymentJob(mapDeploymentJob(row))); + ).map((row) => mapDeploymentJob(row)); } interface DeploymentRuntimeResultRow { @@ -2289,7 +2275,7 @@ function didScheduleOrphanedReleaseCutoverRecovery( cutover: OrphanedDeploymentCutover ): boolean { const job = readDeploymentJob(cutover.id); - if (!job || job.status !== "restart-scheduled") { + if (!job || job.status !== "verifying") { return false; } const candidateCommit = cutover.candidateCommit ?? job.commit; @@ -2547,7 +2533,7 @@ async function runDeploymentJob( const cutoverJob: DeploymentJob = { ...currentJob, - status: "restart-scheduled", + status: "verifying", updatedAt: dateToISOString(new Date()), commit: candidate.manifest.commitSha, commitTitle: candidate.manifest.commitTitle, @@ -2610,7 +2596,7 @@ async function runRollbackJob( const cutoverJob: DeploymentJob = { ...currentJob, - status: "restart-scheduled", + status: "verifying", updatedAt: dateToISOString(new Date()), commit: state.previous.commitSha, commitTitle: state.previous.manifest.commitTitle, diff --git a/backend/src/services/scheduledJobs.ts b/backend/src/services/scheduledJobs.ts index e5d7eb913..c3986ae4c 100644 --- a/backend/src/services/scheduledJobs.ts +++ b/backend/src/services/scheduledJobs.ts @@ -1380,7 +1380,7 @@ function didTerminalizeUnrecoverableDeploymentCutover( updated_at = ?, note = ? WHERE id = ? - AND status = 'restart-scheduled'` + AND status = 'verifying'` ) .run( timestamp, @@ -1423,7 +1423,7 @@ export function reconcileOrphanedDeploymentCutovers( .query( `SELECT id, commit_sha AS candidateCommit, updated_at AS updatedAt FROM deployment_jobs - WHERE status = 'restart-scheduled'` + WHERE status = 'verifying'` ) .all() as Array<{ candidateCommit: string | null; @@ -1520,7 +1520,7 @@ function hasPendingDeploymentCutover(): boolean { .query( `SELECT 1 FROM deployment_jobs - WHERE status = 'restart-scheduled' + WHERE status = 'verifying' LIMIT 1` ) .get() diff --git a/backend/src/services/sqliteMaintenance.ts b/backend/src/services/sqliteMaintenance.ts index 0ede373bc..da499c13c 100644 --- a/backend/src/services/sqliteMaintenance.ts +++ b/backend/src/services/sqliteMaintenance.ts @@ -237,14 +237,14 @@ export function pruneDatabaseHistory( changes.deploymentJobs = databaseConnection .prepare( `DELETE FROM deployment_jobs - WHERE status NOT IN ('building', 'restart-scheduled') + WHERE status NOT IN ('building', 'verifying') ${protectedJobClause} AND ( started_at < ? OR id IN ( SELECT id FROM deployment_jobs - WHERE status NOT IN ('building', 'restart-scheduled') + WHERE status NOT IN ('building', 'verifying') ORDER BY started_at DESC, id DESC LIMIT -1 OFFSET 500 ) diff --git a/backend/test/developmentStack.test.ts b/backend/test/developmentStack.test.ts index 80cf2f967..52f05935d 100644 --- a/backend/test/developmentStack.test.ts +++ b/backend/test/developmentStack.test.ts @@ -83,7 +83,17 @@ function createSnapshotSource(databasePath: string): void { CREATE TABLE auth_pending_logins (id TEXT PRIMARY KEY); CREATE TABLE app_config (key TEXT PRIMARY KEY, value TEXT NOT NULL); CREATE TABLE deployment_lock (id INTEGER PRIMARY KEY); - CREATE TABLE deployment_jobs (id TEXT PRIMARY KEY); + CREATE TABLE deployment_jobs ( + id TEXT PRIMARY KEY, + status TEXT NOT NULL, + started_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + commit_sha TEXT, + commit_title TEXT, + note TEXT, + stdout TEXT, + stderr TEXT + ); CREATE TABLE scheduled_jobs ( id TEXT PRIMARY KEY, enabled INTEGER NOT NULL, @@ -110,7 +120,6 @@ function createSnapshotSource(databasePath: string): void { ["auth_webauthn_challenges", "challenge"], ["auth_sessions", "session"], ["auth_pending_logins", "pending"], - ["deployment_jobs", "deployment"], ["job_executions", "execution"], ["job_workers", "worker"], ["chat_runtime_snapshots", "scope"], @@ -121,6 +130,42 @@ function createSnapshotSource(databasePath: string): void { database.run( "INSERT INTO app_config (key, value) VALUES ('gateway_token', 'encrypted'), ('theme', 'dark')" ); + database.run(` + INSERT INTO deployment_jobs ( + id, + status, + started_at, + updated_at, + commit_sha, + commit_title, + note, + stdout, + stderr + ) + VALUES + ( + 'deployment', + 'isOk', + '2026-01-01T00:00:00.000Z', + '2026-01-01T00:01:00.000Z', + 'abc123', + 'Historical release', + 'Ready', + NULL, + NULL + ), + ( + 'deployment-active', + 'building', + '2026-01-02T00:00:00.000Z', + '2026-01-02T00:00:30.000Z', + 'def456', + 'Active release', + 'Building', + NULL, + NULL + ) + `); database.run("INSERT INTO deployment_lock (id) VALUES (1)"); database.run(` INSERT INTO scheduled_jobs (id, enabled, action_key, next_run_at) @@ -162,7 +207,9 @@ describe("development stack", () => { databaseSource: path.join( root, "projects", - "mira-dashboard-state", + "mira-dashboard", + "production", + "state", "mira-dashboard.db" ), frontendHost: "127.0.0.1", @@ -170,12 +217,20 @@ describe("development stack", () => { gatewayUrl: "ws://127.0.0.1:18789", hotReload: true, publicOrigin: "http://localhost:5173", - releaseSource: path.join(root, "projects", "mira-dashboard-releases"), + releaseSource: path.join( + root, + "projects", + "mira-dashboard", + "production", + "releases" + ), rpId: "localhost", stateRoot: path.join( root, "projects", - "mira-dashboard-dev-state", + "mira-dashboard", + "development", + "state", "local" ), }); @@ -322,7 +377,6 @@ describe("development stack", () => { "user_totp_factors", "user_recovery_codes", "deployment_lock", - "deployment_jobs", "job_executions", "scheduled_job_runs", "job_workers", @@ -333,6 +387,21 @@ describe("development stack", () => { snapshot.query(`SELECT COUNT(*) AS count FROM ${tableName}`).get() ).toEqual({ count: 0 }); } + expect( + snapshot + .query( + `SELECT id, status, commit_title + FROM deployment_jobs + ORDER BY id` + ) + .all() + ).toEqual([ + { + commit_title: "Historical release", + id: "deployment", + status: "isOk", + }, + ]); expect( snapshot.query("SELECT key, value FROM app_config ORDER BY key").all() ).toEqual([{ key: "theme", value: "dark" }]); @@ -432,11 +501,23 @@ describe("development stack", () => { expect(environment).not.toHaveProperty( "MIRA_DASHBOARD_AUTOMATION_CREDENTIALS" ); + const legacySnapshot = new Database(config.databasePath); + legacySnapshot.run("DELETE FROM deployment_jobs"); + legacySnapshot.close(); expect(prepareDevelopmentState(config)).toEqual({ database: "reused", releases: "reused", workspace: "reused", }); + const backfilledSnapshot = new Database(config.databasePath, { + readonly: true, + }); + expect( + backfilledSnapshot + .query("SELECT id, status FROM deployment_jobs ORDER BY id") + .all() + ).toEqual([{ id: "deployment", status: "isOk" }]); + backfilledSnapshot.close(); resetDevelopmentState(config); expect(existsSync(stateRoot)).toBe(false); @@ -456,9 +537,10 @@ describe("development stack", () => { const stateRoot = path.join(root, "state"); createSnapshotSource(sourceDatabase); mkdirSync(path.join(root, ".openclaw", "workspace"), { recursive: true }); - mkdirSync(path.join(root, "projects", "mira-dashboard-releases"), { - recursive: true, - }); + mkdirSync( + path.join(root, "projects", "mira-dashboard", "production", "releases"), + { recursive: true } + ); writeFileSync(path.join(root, ".openclaw", "openclaw.json"), "{}"); const remoteConfig = resolveDevelopmentStackConfig( { @@ -636,14 +718,17 @@ describe("development stack", () => { const databaseSource = path.join( root, "projects", - "mira-dashboard-state", + "mira-dashboard", + "production", + "state", "mira-dashboard.db" ); mkdirSync(path.dirname(databaseSource), { recursive: true }); new Database(databaseSource).close(); - mkdirSync(path.join(root, "projects", "mira-dashboard-releases"), { - recursive: true, - }); + mkdirSync( + path.join(root, "projects", "mira-dashboard", "production", "releases"), + { recursive: true } + ); const config = resolveDevelopmentStackConfig( { HOME: root, diff --git a/backend/test/jobExecutionQueue.test.ts b/backend/test/jobExecutionQueue.test.ts index b5a2e75ba..90c219199 100644 --- a/backend/test/jobExecutionQueue.test.ts +++ b/backend/test/jobExecutionQueue.test.ts @@ -81,7 +81,7 @@ function createScheduledTestJob( return id; } -function createRestartScheduledDeployment( +function createVerifyingDeployment( updatedAt: string, candidateCommit = "c".repeat(40) ): string { @@ -91,7 +91,7 @@ function createRestartScheduledDeployment( .prepare( `INSERT INTO deployment_jobs ( id, status, started_at, updated_at, commit_sha, note, stdout, stderr - ) VALUES (?, 'restart-scheduled', ?, ?, ?, ?, '', '')` + ) VALUES (?, 'verifying', ?, ?, ?, ?, '', '')` ) .run(deploymentId, updatedAt, updatedAt, candidateCommit, "Waiting for guardian"); database @@ -104,7 +104,7 @@ describe("persistent job execution queue", () => { it("schedules rollback recovery for an inactive detached cutover", () => { const startedAt = "2026-07-26T03:00:00.000Z"; const recoveredAt = "2026-07-26T03:01:00.000Z"; - const deploymentId = createRestartScheduledDeployment(startedAt); + const deploymentId = createVerifyingDeployment(startedAt); const recovery = jest.fn(() => true); expect( @@ -129,7 +129,7 @@ describe("persistent job execution queue", () => { .get(deploymentId) ).toEqual({ note: "Waiting for guardian", - status: "restart-scheduled", + status: "verifying", updatedAt: startedAt, }); expect( @@ -141,7 +141,7 @@ describe("persistent job execution queue", () => { it("warns once when an orphaned cutover has no recovery handler", () => { const startedAt = "2026-07-26T03:00:00.000Z"; - const deploymentId = createRestartScheduledDeployment(startedAt); + const deploymentId = createVerifyingDeployment(startedAt); const warning = jest.spyOn(console, "warn").mockImplementation(() => {}); try { expect( @@ -170,7 +170,7 @@ describe("persistent job execution queue", () => { it("terminalizes an inactive legacy cutover without a persisted full SHA", () => { const startedAt = "2026-07-26T03:00:00.000Z"; - const deploymentId = createRestartScheduledDeployment(startedAt, "c0ffee12"); + const deploymentId = createVerifyingDeployment(startedAt, "c0ffee12"); const warning = jest.spyOn(console, "warn").mockImplementation(() => {}); const recovery = jest.fn(() => true); try { @@ -216,7 +216,7 @@ describe("persistent job execution queue", () => { it("bounds an explicit unknown guardian state by scheduling recovery", () => { const startedAt = "2026-07-26T03:00:00.000Z"; - const deploymentId = createRestartScheduledDeployment(startedAt); + const deploymentId = createVerifyingDeployment(startedAt); const recovery = jest.fn(() => true); expect( @@ -251,7 +251,7 @@ describe("persistent job execution queue", () => { it("bounds guardian inspection failures by scheduling rollback recovery", () => { const startedAt = "2026-07-26T03:00:00.000Z"; - const deploymentId = createRestartScheduledDeployment(startedAt); + const deploymentId = createVerifyingDeployment(startedAt); const recovery = jest.fn(() => true); expect( @@ -295,7 +295,7 @@ describe("persistent job execution queue", () => { .get(deploymentId) ).toEqual({ note: "Waiting for guardian", - status: "restart-scheduled", + status: "verifying", updatedAt: startedAt, }); expect( @@ -307,7 +307,7 @@ describe("persistent job execution queue", () => { it("accepts a loaded active unit when systemctl emits benign diagnostics", () => { const startedAt = "2026-07-26T03:00:00.000Z"; - createRestartScheduledDeployment(startedAt); + createVerifyingDeployment(startedAt); const originalPath = process.env.PATH; let fakeBin: string | undefined; const warning = jest.spyOn(console, "warn").mockImplementation(() => {}); diff --git a/backend/test/pullRequestPreview.test.ts b/backend/test/pullRequestPreview.test.ts index fdbc623e3..c1c55a4a6 100644 --- a/backend/test/pullRequestPreview.test.ts +++ b/backend/test/pullRequestPreview.test.ts @@ -32,6 +32,7 @@ import { stopPullRequestPreview, } from "../src/services/pullRequestPreviewHost.ts"; import { + getPullRequestPreviewStatus as getManagedPullRequestPreviewStatus, prepareAndStartPullRequestPreview, prepareAndStopPullRequestPreview, reconcileClosedPullRequestPreview, @@ -150,6 +151,39 @@ function previewScheduledJob(number: unknown, commitSha: unknown = COMMIT): Sche } describe("managed pull request preview", () => { + it("keeps host preview controls out of isolated Dashboard dev", async () => { + const previousSafeMode = process.env.MIRA_DASHBOARD_DEV_SAFE_MODE; + const statusSpy = jest.spyOn(previewHost, "getPullRequestPreviewStatus"); + const executionsSpy = jest.spyOn(jobExecutionQueue, "listJobExecutions"); + const stateNumbersSpy = jest.spyOn( + previewHost, + "listManagedPullRequestPreviewStateNumbers" + ); + process.env.MIRA_DASHBOARD_DEV_SAFE_MODE = "1"; + + try { + await expect(getManagedPullRequestPreviewStatus()).resolves.toEqual({ + controlsAvailable: false, + message: + "PR dev controls are available only from the production Dashboard.", + status: "stopped", + }); + await reconcileClosedPullRequestPreview([]); + expect(statusSpy).not.toHaveBeenCalled(); + expect(executionsSpy).not.toHaveBeenCalled(); + expect(stateNumbersSpy).not.toHaveBeenCalled(); + } finally { + if (previousSafeMode === undefined) { + delete process.env.MIRA_DASHBOARD_DEV_SAFE_MODE; + } else { + process.env.MIRA_DASHBOARD_DEV_SAFE_MODE = previousSafeMode; + } + statusSpy.mockRestore(); + executionsSpy.mockRestore(); + stateNumbersSpy.mockRestore(); + } + }); + it("uses the running Bun executable when the service PATH does not expose Bun", () => { const root = mkdtempSync(path.join(tmpdir(), "mira-preview-bun-path-")); try { @@ -182,6 +216,23 @@ describe("managed pull request preview", () => { it("resolves a single-slot host contract without accepting ambiguous config", () => { const root = mkdtempSync(path.join(tmpdir(), "mira-preview-config-")); try { + const rootOnlyConfig = resolvePullRequestPreviewConfig({ + BUN_BINARY: "/home/ubuntu/.bun/bin/bun", + MIRA_DASHBOARD_PROJECT_ROOT: root, + }); + expect(rootOnlyConfig).toMatchObject({ + dashboardRoot: path.join(root, "production", "checkout"), + databaseTemplate: path.join( + root, + "production", + "state", + "mira-dashboard.db" + ), + managedWorktreePath: path.join(root, "development", "preview"), + previewRoot: path.join(root, "development", "state", "preview"), + releaseSource: path.join(root, "production", "releases"), + }); + const config = resolvePullRequestPreviewConfig({ BUN_BINARY: "/home/ubuntu/.bun/bin/bun", MIRA_DASHBOARD_PREVIEW_BACKEND_PORT: "4101", diff --git a/backend/test/releaseDeployment.test.ts b/backend/test/releaseDeployment.test.ts index 9d7661148..bc20dd4cd 100644 --- a/backend/test/releaseDeployment.test.ts +++ b/backend/test/releaseDeployment.test.ts @@ -11,6 +11,7 @@ import path from "node:path"; import { afterEach, describe, expect, it } from "bun:test"; +import { resolveDashboardProjectPaths } from "../src/lib/dashboardPaths.ts"; import { assertManagedDashboardUnitProperties, type DashboardReleaseCommandRunner, @@ -25,6 +26,7 @@ import { createReleaseFixture } from "./support/releaseFixture.ts"; const COMMIT_SHA = "a".repeat(40); const OTHER_COMMIT_SHA = "b".repeat(40); +const PRODUCTION_PATHS = resolveDashboardProjectPaths({}); const temporaryRoots: string[] = []; function managedUnitProperties(unitContents: string): string { @@ -33,7 +35,9 @@ function managedUnitProperties(unitContents: string): string { .filter((line) => line.startsWith("Environment=")) .map((line) => line.slice("Environment=".length)); const execStart = lines.find((line) => line.startsWith("ExecStart=")); - const workingDirectory = lines.find((line) => line.startsWith("WorkingDirectory=")); + const workingDirectory = lines + .find((line) => line.startsWith("WorkingDirectory=")) + ?.replaceAll("%h", "/home/ubuntu"); if (!execStart || !workingDirectory) { throw new Error("Managed unit fixture is missing required service properties"); } @@ -80,15 +84,40 @@ afterEach(() => { }); describe("immutable release deployment", () => { + it("derives the complete host layout from one project-root environment value", () => { + const paths = resolveDashboardProjectPaths({ + MIRA_DASHBOARD_PROJECT_ROOT: "/srv/dashboard", + }); + expect(paths).toMatchObject({ + developmentLocalStateRoot: "/srv/dashboard/development/state/local", + developmentPreviewRoot: "/srv/dashboard/development/preview", + developmentPreviewStateRoot: "/srv/dashboard/development/state/preview", + developmentWorktreeRoot: "/srv/dashboard/development/worktrees", + productionCheckoutRoot: "/srv/dashboard/production/checkout", + productionDatabasePath: "/srv/dashboard/production/state/mira-dashboard.db", + productionReleasesRoot: "/srv/dashboard/production/releases", + projectRoot: "/srv/dashboard", + }); + expect(() => + resolveDashboardProjectPaths({ + MIRA_DASHBOARD_PROJECT_ROOT: "/", + }) + ).toThrow("Dashboard project root must be an absolute non-root path"); + }); + it("keeps shipped managed units aligned with the production contract", () => { - const releasesRoot = "/home/ubuntu/projects/mira-dashboard-releases"; + const releasesRoot = PRODUCTION_PATHS.productionReleasesRoot; const contract = { - databasePath: "/home/ubuntu/projects/mira-dashboard-state/mira-dashboard.db", - logRotationLockFile: - "/home/ubuntu/projects/mira-dashboard-state/log-rotation.lock", - openClawHome: "/home/ubuntu/projects/mira-dashboard-state/openclaw-client", + databasePath: PRODUCTION_PATHS.productionDatabasePath, + logRotationLockFile: PRODUCTION_PATHS.productionLogRotationLockFile, + openClawHome: PRODUCTION_PATHS.productionOpenClawHome, + previewRoot: PRODUCTION_PATHS.developmentPreviewStateRoot, + previewWorktreePath: PRODUCTION_PATHS.developmentPreviewRoot, + projectRoot: PRODUCTION_PATHS.projectRoot, releaseRoot: `${releasesRoot}/current`, releasesRoot, + sourceRoot: PRODUCTION_PATHS.productionCheckoutRoot, + worktreeRoot: PRODUCTION_PATHS.developmentWorktreeRoot, }; for (const unitName of Object.keys(MANAGED_DASHBOARD_UNITS) as Array< keyof typeof MANAGED_DASHBOARD_UNITS @@ -105,20 +134,24 @@ describe("immutable release deployment", () => { ) ).not.toThrow(); expect(unit).toContain( - "WorkingDirectory=/home/ubuntu/projects/mira-dashboard-releases/current/backend" + "WorkingDirectory=%h/projects/mira-dashboard/production/releases/current/backend" ); expect(unit).toContain( - "MIRA_DASHBOARD_DB_PATH=/home/ubuntu/projects/mira-dashboard-state/mira-dashboard.db" - ); - expect(unit).toContain( - "MIRA_DASHBOARD_OPENCLAW_HOME=/home/ubuntu/projects/mira-dashboard-state/openclaw-client" - ); - expect(unit).toContain( - "MIRA_DASHBOARD_LOG_ROTATION_LOCK_FILE=/home/ubuntu/projects/mira-dashboard-state/log-rotation.lock" - ); - expect(unit).not.toContain( - "/home/ubuntu/projects/mira-dashboard/backend/data" + `Environment=MIRA_DASHBOARD_PROJECT_ROOT=${PRODUCTION_PATHS.projectRoot}` ); + for (const obsoleteEnvironment of [ + "MIRA_DASHBOARD_DB_PATH", + "MIRA_DASHBOARD_LOG_ROTATION_LOCK_FILE", + "MIRA_DASHBOARD_OPENCLAW_HOME", + "MIRA_DASHBOARD_PREVIEW_ROOT", + "MIRA_DASHBOARD_PREVIEW_WORKTREE_PATH", + "MIRA_DASHBOARD_RELEASE_ROOT", + "MIRA_DASHBOARD_RELEASES_ROOT", + "MIRA_DASHBOARD_ROOT", + "MIRA_DASHBOARD_WORKTREE_ROOT", + ]) { + expect(unit).not.toContain(`Environment=${obsoleteEnvironment}=`); + } } }); @@ -128,11 +161,12 @@ describe("immutable release deployment", () => { ) as { scripts?: Record }; const resetCommand = backendPackage.scripts?.["auth:reset-password"]; expect(resetCommand).toContain( - "MIRA_DASHBOARD_DB_PATH=${MIRA_DASHBOARD_DB_PATH:-/home/ubuntu/projects/mira-dashboard-state/mira-dashboard.db}" + "NODE_ENV=production doppler run --config prd --project rajohan" ); expect(resetCommand).toContain( - "--preserve-env=MIRA_DASHBOARD_DB_PATH -- bun dist/resetDashboardPassword.js" + "--preserve-env=NODE_ENV,MIRA_DASHBOARD_PROJECT_ROOT -- bun dist/resetDashboardPassword.js" ); + expect(resetCommand).not.toContain("/home/ubuntu/projects"); }); it("builds in an isolated worktree and atomically publishes only artifacts", async () => { @@ -494,7 +528,7 @@ describe("immutable release deployment", () => { ); const properties = [ `WorkingDirectory=${contract.releaseRoot}/backend`, - `Environment=NODE_ENV=production MIRA_DASHBOARD_EXECUTION_ROLE=web MIRA_DASHBOARD_ENABLE_JOB_SCOPES=1 MIRA_DASHBOARD_JOB_SCOPE_OWNER=mira-dashboard.service MIRA_DASHBOARD_DB_PATH=${contract.databasePath} MIRA_DASHBOARD_LOG_ROTATION_LOCK_FILE=${contract.logRotationLockFile} MIRA_DASHBOARD_OPENCLAW_HOME=${contract.openClawHome} MIRA_DASHBOARD_RELEASE_ROOT=${contract.releaseRoot} MIRA_DASHBOARD_RELEASES_ROOT=${contract.releasesRoot}`, + `Environment=NODE_ENV=production MIRA_DASHBOARD_EXECUTION_ROLE=web MIRA_DASHBOARD_ENABLE_JOB_SCOPES=1 MIRA_DASHBOARD_JOB_SCOPE_OWNER=mira-dashboard.service MIRA_DASHBOARD_PROJECT_ROOT=${contract.projectRoot}`, `ExecStart={ path=/usr/local/bin/doppler ; argv[]=/usr/local/bin/doppler run --preserve-env=${MANAGED_DASHBOARD_PRESERVED_ENVIRONMENT.join(",")} -- bun dist/serverStart.js ; }`, ].join("\n"); expect(() => @@ -555,22 +589,22 @@ describe("immutable release deployment", () => { assertManagedDashboardUnitProperties( "mira-dashboard.service", properties.replace( - ` MIRA_DASHBOARD_OPENCLAW_HOME=${contract.openClawHome}`, + ` MIRA_DASHBOARD_PROJECT_ROOT=${contract.projectRoot}`, "" ), contract ) - ).toThrow("MIRA_DASHBOARD_OPENCLAW_HOME"); + ).toThrow("MIRA_DASHBOARD_PROJECT_ROOT"); expect(() => assertManagedDashboardUnitProperties( "mira-dashboard.service", properties.replace( - ` MIRA_DASHBOARD_OPENCLAW_HOME=${contract.openClawHome}`, - () => ` NOT_MIRA_DASHBOARD_OPENCLAW_HOME=${contract.openClawHome}` + ` MIRA_DASHBOARD_PROJECT_ROOT=${contract.projectRoot}`, + () => ` NOT_MIRA_DASHBOARD_PROJECT_ROOT=${contract.projectRoot}` ), contract ) - ).toThrow("MIRA_DASHBOARD_OPENCLAW_HOME"); + ).toThrow("MIRA_DASHBOARD_PROJECT_ROOT"); await expect( stageDashboardRelease("short", { ...options, diff --git a/backend/test/serviceBehavior.test.ts b/backend/test/serviceBehavior.test.ts index 575cfd54b..b9ed10f71 100644 --- a/backend/test/serviceBehavior.test.ts +++ b/backend/test/serviceBehavior.test.ts @@ -18,7 +18,7 @@ import path from "node:path"; import { afterEach, describe, expect, it, jest } from "bun:test"; import type { DashboardSocket } from "../src/dashboardSocket.ts"; -import { database, getMiraDatabasePath, sqlNullable } from "../src/database.ts"; +import { database, sqlNullable } from "../src/database.ts"; import * as processModule from "../src/lib/processes.ts"; import { ensureDashboardReleaseLayout, @@ -47,6 +47,35 @@ function createTemporaryRoot(prefix: string): string { return root; } +async function executeSuccessfulGuardianPath(script: string): Promise { + const firstLifecycleBranch = script.indexOf("\nif MIRA_DASHBOARD_RELEASES_ROOT="); + if (firstLifecycleBranch === -1) { + throw new Error("Guardian fixture is missing its lifecycle branch"); + } + const executableScript = [ + "restart_services() { return 0; }", + "ready_for_commit() { return 0; }", + script.slice(firstLifecycleBranch + 1), + ].join("\n"); + const child = Bun.spawn(["/bin/bash", "-lc", executableScript], { + env: process.env, + stderr: "pipe", + stdout: "pipe", + }); + const [exitCode, stderr, stdout] = await Promise.all([ + child.exited, + new Response(child.stderr).text(), + new Response(child.stdout).text(), + ]); + if (exitCode !== 0) { + throw new Error( + `Guardian fixture failed with exit code ${exitCode}: ${ + stderr.trim() || stdout.trim() + }` + ); + } +} + function readableUtf8Stream(value: string): ReadableStream { return new ReadableStream({ start(controller) { @@ -1571,7 +1600,7 @@ describe("backend service behavior", () => { ) .run( newerId, - "restart-scheduled", + "verifying", "2026-06-24T11:00:00.000Z", "2026-06-24T11:01:00.000Z", "def456", @@ -1869,15 +1898,22 @@ describe("backend service behavior", () => { it("hands manual rollback to a detached readiness-bound guardian", async () => { rememberEnvironment("PATH"); + rememberEnvironment("MIRA_DASHBOARD_PROJECT_ROOT"); rememberEnvironment("MIRA_DASHBOARD_ROOT"); rememberEnvironment("MIRA_DASHBOARD_RELEASES_ROOT"); rememberEnvironment("MIRA_DASHBOARD_OPENCLAW_HOME"); rememberEnvironment("MIRA_DASHBOARD_LOG_ROTATION_LOCK_FILE"); + rememberEnvironment("MIRA_DASHBOARD_PREVIEW_ROOT"); + rememberEnvironment("MIRA_DASHBOARD_PREVIEW_WORKTREE_PATH"); + rememberEnvironment("MIRA_DASHBOARD_WORKTREE_ROOT"); const fakeRoot = createTemporaryRoot("mira-release-rollback-root-"); const fakeBin = createTemporaryRoot("mira-release-rollback-bin-"); const releasesRoot = path.join(fakeRoot, "managed-releases"); const openClawHome = path.join(fakeRoot, "state", "openclaw-client"); const logRotationLockFile = path.join(fakeRoot, "state", "log-rotation.lock"); + const previewRoot = path.join(fakeRoot, "preview-state"); + const previewWorktreePath = path.join(fakeRoot, "preview"); + const worktreeRoot = path.join(fakeRoot, "worktrees"); const systemdScriptLog = path.join(fakeRoot, "rollback-guardian.sh"); const systemdArgumentsLog = path.join(fakeRoot, "rollback-systemd-run.args"); const currentCommit = "c".repeat(40); @@ -1923,8 +1959,8 @@ else scope_owner="mira-dashboard.service" fi printf '%s\n' \ - "Environment=NODE_ENV=production MIRA_DASHBOARD_EXECUTION_ROLE=$execution_role MIRA_DASHBOARD_ENABLE_JOB_SCOPES=1 MIRA_DASHBOARD_JOB_SCOPE_OWNER=$scope_owner MIRA_DASHBOARD_DB_PATH=${getMiraDatabasePath()} MIRA_DASHBOARD_LOG_ROTATION_LOCK_FILE=${logRotationLockFile} MIRA_DASHBOARD_OPENCLAW_HOME=${openClawHome} MIRA_DASHBOARD_RELEASE_ROOT=${releasesRoot}/current MIRA_DASHBOARD_RELEASES_ROOT=${releasesRoot}" \ - "ExecStart={ path=/usr/local/bin/doppler ; argv[]=/usr/local/bin/doppler run --preserve-env=NODE_ENV,MIRA_DASHBOARD_EXECUTION_ROLE,MIRA_DASHBOARD_ENABLE_JOB_SCOPES,MIRA_DASHBOARD_JOB_SCOPE_OWNER,MIRA_DASHBOARD_DB_PATH,MIRA_DASHBOARD_LOG_ROTATION_LOCK_FILE,MIRA_DASHBOARD_OPENCLAW_HOME,MIRA_DASHBOARD_RELEASE_ROOT,MIRA_DASHBOARD_RELEASES_ROOT -- bun $entrypoint ; }" \ + "Environment=NODE_ENV=production MIRA_DASHBOARD_EXECUTION_ROLE=$execution_role MIRA_DASHBOARD_ENABLE_JOB_SCOPES=1 MIRA_DASHBOARD_JOB_SCOPE_OWNER=$scope_owner MIRA_DASHBOARD_PROJECT_ROOT=${fakeRoot}" \ + "ExecStart={ path=/usr/local/bin/doppler ; argv[]=/usr/local/bin/doppler run --preserve-env=NODE_ENV,MIRA_DASHBOARD_EXECUTION_ROLE,MIRA_DASHBOARD_ENABLE_JOB_SCOPES,MIRA_DASHBOARD_JOB_SCOPE_OWNER,MIRA_DASHBOARD_PROJECT_ROOT -- bun $entrypoint ; }" \ "WorkingDirectory=${releasesRoot}/current/backend" ` ); @@ -1942,10 +1978,14 @@ printf 'scheduled\n' chmodSync(path.join(fakeBin, "systemctl"), 0o755); chmodSync(path.join(fakeBin, "systemd-run"), 0o755); process.env.PATH = `${fakeBin}${path.delimiter}${process.env.PATH ?? ""}`; + process.env.MIRA_DASHBOARD_PROJECT_ROOT = fakeRoot; process.env.MIRA_DASHBOARD_ROOT = fakeRoot; process.env.MIRA_DASHBOARD_RELEASES_ROOT = releasesRoot; process.env.MIRA_DASHBOARD_OPENCLAW_HOME = openClawHome; process.env.MIRA_DASHBOARD_LOG_ROTATION_LOCK_FILE = logRotationLockFile; + process.env.MIRA_DASHBOARD_PREVIEW_ROOT = previewRoot; + process.env.MIRA_DASHBOARD_PREVIEW_WORKTREE_PATH = previewWorktreePath; + process.env.MIRA_DASHBOARD_WORKTREE_ROOT = worktreeRoot; const { prepareAndStartRollback, registerPullRequestExecutionActions } = await import("../src/services/pullRequests.ts"); @@ -1971,7 +2011,7 @@ printf 'scheduled\n' .get(rollback.id) as { note: string | null; status: string } | undefined; return ( - row?.status === "restart-scheduled" && + row?.status === "verifying" && row.note === "Rollback target verified. Activating it, restarting services, then verifying web, worker, deployed commit, and 31 seconds of worker stability; automatic restoration is armed" && existsSync(systemdScriptLog) @@ -1983,6 +2023,13 @@ printf 'scheduled\n' ); const guardian = readFileSync(systemdScriptLog, "utf8"); + const scheduledUpdatedAt = ( + database + .prepare( + "SELECT updated_at AS updatedAt FROM deployment_jobs WHERE id = ?" + ) + .get(rollback.id) as { updatedAt: string } + ).updatedAt; expect(guardian).toContain( `${releasesRoot}/releases/${currentCommit}/backend/dist/releaseLifecycle.js` ); @@ -2022,6 +2069,28 @@ printf 'scheduled\n' expect(readFileSync(systemdArgumentsLog, "utf8")).toContain( "--expand-environment=no\n" ); + await executeSuccessfulGuardianPath(guardian); + const completedRollback = database + .prepare( + `SELECT status, note, updated_at AS updatedAt + FROM deployment_jobs + WHERE id = ?` + ) + .get(rollback.id) as { + note: string; + status: string; + updatedAt: string; + }; + expect(completedRollback).toMatchObject({ + note: "Atomic rollback activated dddddddd. Web, worker, commit, and 31-second worker stability checks passed", + status: "isOk", + }); + expect(new Date(completedRollback.updatedAt).toISOString()).toBe( + completedRollback.updatedAt + ); + expect(Date.parse(completedRollback.updatedAt)).toBeGreaterThan( + Date.parse(scheduledUpdatedAt) + ); } finally { database .prepare("UPDATE deployment_jobs SET status = 'failed' WHERE id = ?") @@ -2330,11 +2399,14 @@ printf 'scheduled\n' it("publishes an immutable release and hands activation to detached cutover", async () => { rememberEnvironment("PATH"); + rememberEnvironment("MIRA_DASHBOARD_PROJECT_ROOT"); rememberEnvironment("MIRA_DASHBOARD_ROOT"); rememberEnvironment("MIRA_DASHBOARD_WORKTREE_ROOT"); rememberEnvironment("MIRA_DASHBOARD_RELEASES_ROOT"); rememberEnvironment("MIRA_DASHBOARD_OPENCLAW_HOME"); rememberEnvironment("MIRA_DASHBOARD_LOG_ROTATION_LOCK_FILE"); + rememberEnvironment("MIRA_DASHBOARD_PREVIEW_ROOT"); + rememberEnvironment("MIRA_DASHBOARD_PREVIEW_WORKTREE_PATH"); rememberEnvironment("PORT"); const fakeRoot = createTemporaryRoot("mira-pr-deploy-root-"); const fakeBin = createTemporaryRoot("mira-pr-deploy-bin-"); @@ -2343,6 +2415,8 @@ printf 'scheduled\n' const candidateTemplate = path.join(fakeRoot, "candidate-template"); const openClawHome = path.join(fakeRoot, "state", "openclaw-client"); const logRotationLockFile = path.join(fakeRoot, "state", "log-rotation.lock"); + const previewRoot = path.join(fakeRoot, "preview-state"); + const previewWorktreePath = path.join(fakeRoot, "preview"); const priorPreviousCommit = "b".repeat(40); const oldCommit = "c".repeat(40); const candidateCommit = "d".repeat(40); @@ -2413,6 +2487,8 @@ set -euo pipefail printf '%s|%s\n' "$PWD" "$*" >> ${JSON.stringify(bunLog)} if [[ "$*" == "install --frozen-lockfile" || "$*" == "run deploy:prepare" || "$*" == "dist/databasePreflight.js" ]]; then printf 'ok\n' +elif [[ "$1" == "-e" || "$1" == */releaseLifecycle.js ]]; then + exec ${JSON.stringify(process.execPath)} "$@" else echo "unexpected bun args: $*" >&2 exit 2 @@ -2442,8 +2518,8 @@ else scope_owner="mira-dashboard.service" fi printf '%s\n' \ - "Environment=NODE_ENV=production MIRA_DASHBOARD_EXECUTION_ROLE=$execution_role MIRA_DASHBOARD_ENABLE_JOB_SCOPES=1 MIRA_DASHBOARD_JOB_SCOPE_OWNER=$scope_owner MIRA_DASHBOARD_DB_PATH=${getMiraDatabasePath()} MIRA_DASHBOARD_LOG_ROTATION_LOCK_FILE=${logRotationLockFile} MIRA_DASHBOARD_OPENCLAW_HOME=${openClawHome} MIRA_DASHBOARD_RELEASE_ROOT=${releasesRoot}/current MIRA_DASHBOARD_RELEASES_ROOT=${releasesRoot}" \ - "ExecStart={ path=/usr/local/bin/doppler ; argv[]=/usr/local/bin/doppler run --preserve-env=NODE_ENV,MIRA_DASHBOARD_EXECUTION_ROLE,MIRA_DASHBOARD_ENABLE_JOB_SCOPES,MIRA_DASHBOARD_JOB_SCOPE_OWNER,MIRA_DASHBOARD_DB_PATH,MIRA_DASHBOARD_LOG_ROTATION_LOCK_FILE,MIRA_DASHBOARD_OPENCLAW_HOME,MIRA_DASHBOARD_RELEASE_ROOT,MIRA_DASHBOARD_RELEASES_ROOT -- bun $entrypoint ; }" \ + "Environment=NODE_ENV=production MIRA_DASHBOARD_EXECUTION_ROLE=$execution_role MIRA_DASHBOARD_ENABLE_JOB_SCOPES=1 MIRA_DASHBOARD_JOB_SCOPE_OWNER=$scope_owner MIRA_DASHBOARD_PROJECT_ROOT=${fakeRoot}" \ + "ExecStart={ path=/usr/local/bin/doppler ; argv[]=/usr/local/bin/doppler run --preserve-env=NODE_ENV,MIRA_DASHBOARD_EXECUTION_ROLE,MIRA_DASHBOARD_ENABLE_JOB_SCOPES,MIRA_DASHBOARD_JOB_SCOPE_OWNER,MIRA_DASHBOARD_PROJECT_ROOT -- bun $entrypoint ; }" \ 'WorkingDirectory=${releasesRoot}/current/backend' ` ); @@ -2462,11 +2538,14 @@ printf 'scheduled\n' chmodSync(path.join(fakeBin, "systemctl"), 0o755); chmodSync(path.join(fakeBin, "systemd-run"), 0o755); process.env.PATH = `${fakeBin}${path.delimiter}${process.env.PATH ?? ""}`; + process.env.MIRA_DASHBOARD_PROJECT_ROOT = fakeRoot; process.env.MIRA_DASHBOARD_ROOT = fakeRoot; process.env.MIRA_DASHBOARD_WORKTREE_ROOT = worktreeRoot; process.env.MIRA_DASHBOARD_RELEASES_ROOT = releasesRoot; process.env.MIRA_DASHBOARD_OPENCLAW_HOME = openClawHome; process.env.MIRA_DASHBOARD_LOG_ROTATION_LOCK_FILE = logRotationLockFile; + process.env.MIRA_DASHBOARD_PREVIEW_ROOT = previewRoot; + process.env.MIRA_DASHBOARD_PREVIEW_WORKTREE_PATH = previewWorktreePath; process.env.PORT = "4310"; const { registerPullRequestExecutionActions, startDeployLatest } = @@ -2515,7 +2594,7 @@ printf 'scheduled\n' status: string; } | undefined; - return row?.status === "restart-scheduled" && existsSync(systemdLog); + return row?.status === "verifying" && existsSync(systemdLog); }, 5000); await waitFor( () => getJobExecution(deploymentExecution.id)?.status === "success", @@ -2539,8 +2618,15 @@ printf 'scheduled\n' commit_sha: candidateCommit, commit_title: "Deployable dashboard commit", note: "Release published. Activating it, restarting services, then verifying web, worker, deployed commit, and 31 seconds of worker stability; automatic rollback is armed", - status: "restart-scheduled", + status: "verifying", }); + const scheduledUpdatedAt = ( + database + .prepare( + "SELECT updated_at AS updatedAt FROM deployment_jobs WHERE id = ?" + ) + .get(job.id) as { updatedAt: string } + ).updatedAt; await expect(Bun.file(gitLog).text()).resolves.toContain( "pull --ff-only origin main" ); @@ -2707,7 +2793,7 @@ printf 'scheduled\n' .get(job.id) ).toEqual({ note: "Detached release guardian ended without a terminal result; automatic rollback recovery scheduled", - status: "restart-scheduled", + status: "verifying", }); expect( database @@ -2748,7 +2834,7 @@ printf 'scheduled\n' const row = database .prepare("SELECT status FROM deployment_jobs WHERE id = ?") .get(redeploy.id) as { status: string } | undefined; - return row?.status === "restart-scheduled" && existsSync(systemdLog); + return row?.status === "verifying" && existsSync(systemdLog); }, 5000); await waitFor( () => getJobExecution(redeployExecution.id)?.status === "success", @@ -2827,6 +2913,28 @@ printf 'scheduled\n' note: "Automatic redeploy fallback is not eligible: Previous release failed its latest runtime readiness check", status: "failed", }); + await executeSuccessfulGuardianPath(restartCommand); + const completedDeployment = database + .prepare( + `SELECT status, note, updated_at AS updatedAt + FROM deployment_jobs + WHERE id = ?` + ) + .get(job.id) as { + note: string; + status: string; + updatedAt: string; + }; + expect(completedDeployment).toMatchObject({ + note: "Atomic release activated. Web, worker, commit, and 31-second worker stability checks passed", + status: "isOk", + }); + expect(new Date(completedDeployment.updatedAt).toISOString()).toBe( + completedDeployment.updatedAt + ); + expect(Date.parse(completedDeployment.updatedAt)).toBeGreaterThan( + Date.parse(scheduledUpdatedAt) + ); } finally { database .prepare("DELETE FROM job_executions WHERE id = ?") @@ -3602,7 +3710,7 @@ fi branch: "main", dirty: true, exists: true, - remote: "https://example.com/mira-dashboard.git", + remote: "https://example.com/checkout.git", statusSummary: { conflicted: 1, deleted: 1, @@ -5840,7 +5948,7 @@ fi runProcessSpy.mock.calls[0] ?? []; expect(sudoCommand).toBe("sudo"); expect(sudoArguments).toContain( - "--preserve-env=LANG,NODE_ENV,TZ,MIRA_DASHBOARD_DB_PATH,MIRA_DASHBOARD_LOG_ROTATION_LOCK_FILE" + "--preserve-env=LANG,NODE_ENV,TZ,MIRA_DASHBOARD_PROJECT_ROOT,MIRA_DASHBOARD_DB_PATH,MIRA_DASHBOARD_LOG_ROTATION_LOCK_FILE" ); expect(sudoOptions?.env).toMatchObject({ MIRA_DASHBOARD_DB_PATH: configuredDatabasePath, diff --git a/backend/test/testDatabaseGuard.test.ts b/backend/test/testDatabaseGuard.test.ts index 4a0379cca..41ae7e029 100644 --- a/backend/test/testDatabaseGuard.test.ts +++ b/backend/test/testDatabaseGuard.test.ts @@ -6,6 +6,8 @@ import { pathToFileURL } from "node:url"; import { describe, expect, it } from "bun:test"; +import { resolveDashboardProjectPaths } from "../src/lib/dashboardPaths.ts"; + const realTemporaryRoot = realpathSync(path.resolve(tmpdir())); const realHomeRoot = realpathSync(path.resolve(homedir())); const nonTemporaryRelativePath = path.relative(realTemporaryRoot, realHomeRoot); @@ -146,7 +148,7 @@ describe("database test safety guard", () => { it("refuses the production state database path while running tests", async () => { const { exitCode, stderr } = await importDatabaseInChild( - "/home/ubuntu/projects/mira-dashboard-state/mira-dashboard.db" + resolveDashboardProjectPaths({}).productionDatabasePath ); expect(exitCode).not.toBe(0); diff --git a/docs/architecture/database.md b/docs/architecture/database.md index c8adde2eb..3e3846553 100644 --- a/docs/architecture/database.md +++ b/docs/architecture/database.md @@ -11,7 +11,7 @@ backend/data/mira-dashboard.db That default is for development. Production units set: ```text -/home/ubuntu/projects/mira-dashboard-state/mira-dashboard.db +/home/ubuntu/projects/mira-dashboard/production/state/mira-dashboard.db ``` The `-wal`/`-shm` sidecars and `backups/` directory stay below the same @@ -122,7 +122,7 @@ legacy reusable session ids do not survive the upgrade. The deploy flow uses one combined build/preflight command before restart: ```bash -cd /home/ubuntu/projects/mira-dashboard +cd /home/ubuntu/projects/mira-dashboard/production/checkout /usr/local/bin/doppler run --config prd --project rajohan -- \ bun run deploy:prepare ``` @@ -153,7 +153,7 @@ history. Snapshots live below `dirname(MIRA_DASHBOARD_DB_PATH)/backups/`. This is `backend/data/backups/` in development and -`/home/ubuntu/projects/mira-dashboard-state/backups/` in production: +`/home/ubuntu/projects/mira-dashboard/production/state/backups/` in production: | Kind | Maximum age | Maximum count | | --------------- | ----------- | ------------- | @@ -171,7 +171,7 @@ Use the same explicit path as the managed production units: ```bash set -euo pipefail -db_path=/home/ubuntu/projects/mira-dashboard-state/mira-dashboard.db +db_path=/home/ubuntu/projects/mira-dashboard/production/state/mira-dashboard.db sqlite3 -readonly "$db_path" ".tables" sqlite3 -readonly "$db_path" "PRAGMA integrity_check;" sqlite3 -readonly "$db_path" \ @@ -197,8 +197,8 @@ Use this only when Raymond explicitly wants to re-run setup. ```bash set -euo pipefail -backend_dir=/home/ubuntu/projects/mira-dashboard-releases/current/backend -db_path=/home/ubuntu/projects/mira-dashboard-state/mira-dashboard.db +backend_dir=/home/ubuntu/projects/mira-dashboard/production/releases/current/backend +db_path=/home/ubuntu/projects/mira-dashboard/production/state/mira-dashboard.db cd "$backend_dir" /usr/local/bin/doppler run --config prd --project rajohan -- \ env MIRA_DASHBOARD_DB_PATH="$db_path" bun run db:preflight diff --git a/docs/architecture/gateway-and-chat.md b/docs/architecture/gateway-and-chat.md index 4c9f09167..ec5395d7a 100644 --- a/docs/architecture/gateway-and-chat.md +++ b/docs/architecture/gateway-and-chat.md @@ -617,6 +617,6 @@ openclaw status Do not print Gateway token values while debugging. Inspect length/metadata only: ```bash -sqlite3 /home/ubuntu/projects/mira-dashboard-state/mira-dashboard.db \ +sqlite3 /home/ubuntu/projects/mira-dashboard/production/state/mira-dashboard.db \ "SELECT key, length(value), updated_at FROM app_config WHERE key='gateway_token';" ``` diff --git a/docs/development/local-dev.md b/docs/development/local-dev.md index c1412bf26..8e39eda9b 100644 --- a/docs/development/local-dev.md +++ b/docs/development/local-dev.md @@ -55,7 +55,7 @@ which they were started. There is no permanent development systemd unit. Local state defaults to: ```text -/home/ubuntu/projects/mira-dashboard-dev-state/local/ +/home/ubuntu/projects/mira-dashboard/development/state/local/ ``` It contains: @@ -72,15 +72,16 @@ database, OpenClaw workspace, and managed releases. Later starts reuse them so changes made while testing remain available. The database snapshot removes active sessions and pending logins, WebAuthn -challenges, TOTP/recovery secrets, the persisted Gateway token, deployment/job -runtime state, and chat replay snapshots. Existing Dashboard users and password -hashes remain available. WebAuthn public credentials remain available only when -the source and development relying-party IDs match. For a different RP, such as -the default `localhost`, the snapshot removes incompatible credentials and -disables MFA so password login and local factor enrollment remain possible. -Cache refresh and SQLite maintenance jobs retain their enabled state and -schedule. Backup, Docker, deploy, workspace-sync, and log-rotation jobs are -forced disabled. +challenges, TOTP/recovery secrets, the persisted Gateway token, active +deployment locks/nonterminal deployment jobs, job-execution runtime, and chat +replay snapshots. Terminal release-job history remains available as read-only +dev context. Existing Dashboard users and password hashes remain available. +WebAuthn public credentials remain available only when the source and +development relying-party IDs match. For a different RP, such as the default +`localhost`, the snapshot removes incompatible credentials and disables MFA so +password login and local factor enrollment remain possible. Cache refresh and +SQLite maintenance jobs retain their enabled state and schedule. Backup, +Docker, deploy, workspace-sync, and log-rotation jobs are forced disabled. The workspace copy rejects symlinks and excludes Git metadata, credential/secret directories, private-key names, `.env` files, and token/secret files. Safe @@ -138,14 +139,14 @@ The Delivery page exposes one shared **PR dev** slot: - Only PRs targeting `main` from the configured trusted-author allowlist can start. - The exact PR commit is checked out at - `/home/ubuntu/projects/mira-dashboard-preview`. + `/home/ubuntu/projects/mira-dashboard/development/preview`. - Dependencies install with frozen lockfiles and lifecycle scripts disabled. - Source and Git metadata are read-only inside a Bubblewrap sandbox. - Source watchers and frontend HMR are disabled because the managed checkout is fixed and read-only. Ordinary `bun run dev` and `bun run dev:remote` still provide frontend and backend hot reload from their current worktree. - State is stored under - `/home/ubuntu/projects/mira-dashboard-preview-state/managed/states/pr-/`. + `/home/ubuntu/projects/mira-dashboard/development/state/preview/states/pr-/`. - Tailscale publishes HTTPS only after the managed frontend/backend pair is locally ready. - Separate transient user units run the sandbox and a host-owned Gateway @@ -163,10 +164,10 @@ The Delivery page exposes one shared **PR dev** slot: - Status reconciliation performs the same unit, route, and credential cleanup if a transient unit exits, is collected, or reaches its four-hour limit. -`managed/bun-cache` is a shared dependency cache retained between PRs to speed up -frozen installs. `managed/installer-home` is an isolated, normally empty home -directory for those installs; neither directory contains per-PR application -state. +`bun-cache` is a shared dependency cache retained between PRs to speed up frozen +installs. `installer-home` is an isolated, normally empty home directory for +those installs; both live directly under `development/state/preview`, and +neither contains per-PR application state. The production backend decrypts its persisted Gateway token only when starting trusted PR dev. It atomically writes that token to an owner-only `0600` file @@ -188,6 +189,7 @@ The stack validates all configured ports, origins, URLs, and paths. Useful overrides include: ```text +MIRA_DASHBOARD_PROJECT_ROOT MIRA_DASHBOARD_DEV_FRONTEND_PORT MIRA_DASHBOARD_DEV_BACKEND_PORT MIRA_DASHBOARD_DEV_HOT_RELOAD diff --git a/docs/development/testing-and-prs.md b/docs/development/testing-and-prs.md index db5eda66a..3ad0a208b 100644 --- a/docs/development/testing-and-prs.md +++ b/docs/development/testing-and-prs.md @@ -119,7 +119,7 @@ worktree, then finish with a read-only check that production is still clean `main`: ```bash -cd /home/ubuntu/projects/mira-dashboard +cd /home/ubuntu/projects/mira-dashboard/production/checkout git status --short --branch ``` diff --git a/docs/index.md b/docs/index.md index 473cb28bd..979c32a5f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -51,7 +51,7 @@ It is a Bun-native application with: Production uses `mira-dashboard.service` plus `mira-dashboard-worker.service`. Both run through Doppler `rajohan/prd` from the immutable release selected by -`/home/ubuntu/projects/mira-dashboard-releases/current`; the Git checkout is +`/home/ubuntu/projects/mira-dashboard/production/releases/current`; the Git checkout is only the deployment control plane. ## Documentation Rules diff --git a/docs/operations/runbooks.md b/docs/operations/runbooks.md index 59b86658e..a2a58d429 100644 --- a/docs/operations/runbooks.md +++ b/docs/operations/runbooks.md @@ -60,11 +60,12 @@ Use only when Raymond wants to re-run bootstrap. ```bash set -euo pipefail -backend_dir=/home/ubuntu/projects/mira-dashboard-releases/current/backend -db_path=/home/ubuntu/projects/mira-dashboard-state/mira-dashboard.db +export MIRA_DASHBOARD_PROJECT_ROOT=/home/ubuntu/projects/mira-dashboard +backend_dir="$MIRA_DASHBOARD_PROJECT_ROOT/production/releases/current/backend" +db_path="$MIRA_DASHBOARD_PROJECT_ROOT/production/state/mira-dashboard.db" cd "$backend_dir" /usr/local/bin/doppler run --config prd --project rajohan -- \ - env MIRA_DASHBOARD_DB_PATH="$db_path" bun run db:preflight + env NODE_ENV=production bun run db:preflight 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 @@ -73,7 +74,7 @@ curl http://127.0.0.1:3100/api/auth/bootstrap To force Gateway token entry during bootstrap too: ```bash -db_path=/home/ubuntu/projects/mira-dashboard-state/mira-dashboard.db +db_path="$MIRA_DASHBOARD_PROJECT_ROOT/production/state/mira-dashboard.db" sqlite3 "$db_path" "DELETE FROM app_config WHERE key='gateway_token';" ``` @@ -84,15 +85,15 @@ watched by the web service, or unauthenticated reset endpoint. Use the host-local interactive command from an SSH/console TTY: ```bash -cd /home/ubuntu/projects/mira-dashboard-releases/current/backend -MIRA_DASHBOARD_DB_PATH=/home/ubuntu/projects/mira-dashboard-state/mira-dashboard.db \ - bun run auth:reset-password -- --username +export MIRA_DASHBOARD_PROJECT_ROOT=/home/ubuntu/projects/mira-dashboard +cd "$MIRA_DASHBOARD_PROJECT_ROOT/production/releases/current/backend" +bun run auth:reset-password -- --username ``` The single standalone `--` ends Bun script options; `--username` is passed to -the reset program. The package script preserves the explicit stable database -path through Doppler and defaults to the same production path when it is not -already set. The program reads the new password twice with terminal echo +the reset program. The package script preserves the project root through +Doppler, and the backend derives the stable database path. The program reads +the new password twice with terminal echo disabled, preserves MFA, revokes every session and pending ceremony, clears authentication cooldowns, and appends an audit event. It never accepts password material through command arguments or environment variables. @@ -101,8 +102,7 @@ Only when all registered second factors are also lost, run the deliberate break-glass variant: ```bash -MIRA_DASHBOARD_DB_PATH=/home/ubuntu/projects/mira-dashboard-state/mira-dashboard.db \ - bun run auth:reset-password -- --username --reset-mfa +bun run auth:reset-password -- --username --reset-mfa ``` `--reset-mfa` deletes registered WebAuthn credentials, encrypted TOTP factors, @@ -113,7 +113,7 @@ recovery codes offline. ## Inspect Gateway Token Metadata Without Printing It ```bash -db_path=/home/ubuntu/projects/mira-dashboard-state/mira-dashboard.db +db_path=/home/ubuntu/projects/mira-dashboard/production/state/mira-dashboard.db sqlite3 "$db_path" \ "SELECT key, length(value), updated_at FROM app_config WHERE key='gateway_token';" ``` @@ -134,7 +134,7 @@ Use the tracked workspace provisioner; it writes the full token directly to the file and prints only the Dashboard-side hash/scopes object: ```bash -cd /home/ubuntu/projects/mira-dashboard +cd /home/ubuntu/projects/mira-dashboard/production/checkout bun scripts/provisionDashboardAutomationCredential.ts ``` @@ -200,7 +200,7 @@ is compatible with its schema. Never overwrite the live database or remove ```bash set -euo pipefail backup_path=/absolute/path/to/selected/mira-dashboard-....db -db_path=/home/ubuntu/projects/mira-dashboard-state/mira-dashboard.db +db_path=/home/ubuntu/projects/mira-dashboard/production/state/mira-dashboard.db test -f "$backup_path" test "$(sqlite3 -readonly "$backup_path" "PRAGMA quick_check;")" = "ok" has_migration_history="$( @@ -279,11 +279,10 @@ Inspect the `database.maintenance` job on Jobs and the Database page's attention list. A manual deploy preflight can create and restore-verify a fresh snapshot: ```bash -cd /home/ubuntu/projects/mira-dashboard-releases/current/backend +cd /home/ubuntu/projects/mira-dashboard/production/releases/current/backend /usr/local/bin/doppler run --config prd --project rajohan -- \ - env \ - MIRA_DASHBOARD_DB_PATH=/home/ubuntu/projects/mira-dashboard-state/mira-dashboard.db \ - bun run db:preflight + env MIRA_DASHBOARD_PROJECT_ROOT=/home/ubuntu/projects/mira-dashboard \ + NODE_ENV=production bun run db:preflight ``` “Reusable space” is SQLite freelist capacity that can be reused by future @@ -341,20 +340,20 @@ Delete the smoke report from the UI or with `DELETE /api/reports/:id`. Dashboard PR worktrees live under: ```text -/home/ubuntu/projects/mira-dashboard-worktrees +/home/ubuntu/projects/mira-dashboard/development/worktrees ``` List: ```bash -git -C /home/ubuntu/projects/mira-dashboard worktree list +git -C /home/ubuntu/projects/mira-dashboard/production/checkout worktree list ``` Remove only clean, known worktrees: ```bash -git -C /home/ubuntu/projects/mira-dashboard worktree remove /home/ubuntu/projects/mira-dashboard-worktrees/ -git -C /home/ubuntu/projects/mira-dashboard worktree prune +git -C /home/ubuntu/projects/mira-dashboard/production/checkout worktree remove /home/ubuntu/projects/mira-dashboard/development/worktrees/ +git -C /home/ubuntu/projects/mira-dashboard/production/checkout worktree prune ``` ## Docker Compose Validation diff --git a/docs/operations/scheduler-cache-backups.md b/docs/operations/scheduler-cache-backups.md index 79e4e519f..65ba7a6a0 100644 --- a/docs/operations/scheduler-cache-backups.md +++ b/docs/operations/scheduler-cache-backups.md @@ -234,14 +234,14 @@ run automatic `VACUUM`. List scheduled job tables: ```bash -sqlite3 /home/ubuntu/projects/mira-dashboard-state/mira-dashboard.db \ +sqlite3 /home/ubuntu/projects/mira-dashboard/production/state/mira-dashboard.db \ "SELECT id, name, enabled, schedule_type, next_run_at, updated_at FROM scheduled_jobs ORDER BY id;" ``` Inspect recent runs: ```bash -sqlite3 /home/ubuntu/projects/mira-dashboard-state/mira-dashboard.db \ +sqlite3 /home/ubuntu/projects/mira-dashboard/production/state/mira-dashboard.db \ "SELECT job_id, status, started_at, finished_at FROM scheduled_job_runs ORDER BY id DESC LIMIT 20;" ``` @@ -249,7 +249,7 @@ Inspect SQLite lifecycle state: ```bash set -euo pipefail -db_path=/home/ubuntu/projects/mira-dashboard-state/mira-dashboard.db +db_path=/home/ubuntu/projects/mira-dashboard/production/state/mira-dashboard.db sqlite3 -readonly "$db_path" \ "SELECT version, name, applied_at FROM schema_migrations ORDER BY version;" sqlite3 -readonly "$db_path" \ @@ -259,6 +259,6 @@ sqlite3 -readonly "$db_path" \ Inspect cache freshness: ```bash -sqlite3 /home/ubuntu/projects/mira-dashboard-state/mira-dashboard.db \ +sqlite3 /home/ubuntu/projects/mira-dashboard/production/state/mira-dashboard.db \ "SELECT key, status, updated_at FROM cache_entries ORDER BY updated_at DESC LIMIT 30;" ``` diff --git a/docs/operations/troubleshooting.md b/docs/operations/troubleshooting.md index c90e0d688..6e4721747 100644 --- a/docs/operations/troubleshooting.md +++ b/docs/operations/troubleshooting.md @@ -9,7 +9,7 @@ curl --fail http://127.0.0.1:3100/api/health/live curl --fail http://127.0.0.1:3100/api/health/ready systemctl --user status mira-dashboard.service --no-pager journalctl --user -u mira-dashboard.service -n 160 --no-pager -git -C /home/ubuntu/projects/mira-dashboard status --short --branch +git -C /home/ubuntu/projects/mira-dashboard/production/checkout status --short --branch ``` Expected production checkout state: @@ -102,7 +102,7 @@ layout unless a deliberate migration requires otherwise. ## Frontend Not Built Or Serving Old Assets ```bash -cd /home/ubuntu/projects/mira-dashboard +cd /home/ubuntu/projects/mira-dashboard/production/checkout /usr/local/bin/doppler run --config prd --project rajohan -- \ bun run deploy:prepare systemctl --user restart mira-dashboard.service diff --git a/docs/security/auth-and-trust-boundaries.md b/docs/security/auth-and-trust-boundaries.md index 7c1963198..863279e38 100644 --- a/docs/security/auth-and-trust-boundaries.md +++ b/docs/security/auth-and-trust-boundaries.md @@ -110,7 +110,7 @@ when enabled, rotates the current session, and revokes every other session. Forgotten-password recovery is intentionally host-local: ```bash -cd /home/ubuntu/projects/mira-dashboard-releases/current/backend +cd /home/ubuntu/projects/mira-dashboard/production/releases/current/backend bun run auth:reset-password -- --username ``` @@ -187,13 +187,13 @@ profile per OpenClaw caller. On the current host the runtime layout is: The directory must be owned by the OpenClaw user with mode `0700`; every token file must be a regular, non-symlink file owned by that user with mode `0600`. The tracked Dashboard helper -`/home/ubuntu/projects/mira-dashboard/scripts/provisionDashboardAutomationCredential.ts` +`/home/ubuntu/projects/mira-dashboard/production/checkout/scripts/provisionDashboardAutomationCredential.ts` writes the full token directly to the correct file and prints only the hash-only Dashboard configuration entry. It refuses to overwrite an existing file. Run it once for each profile from an untracked host shell: ```bash -cd /home/ubuntu/projects/mira-dashboard +cd /home/ubuntu/projects/mira-dashboard/production/checkout bun scripts/provisionDashboardAutomationCredential.ts heartbeat bun scripts/provisionDashboardAutomationCredential.ts daily-summary bun scripts/provisionDashboardAutomationCredential.ts daily-brief @@ -204,7 +204,7 @@ Combine the four printed objects into the JSON array stored as `MIRA_DASHBOARD_AUTOMATION_CREDENTIALS` in Doppler. The full tokens remain only in the client files; they are not stored in Doppler, SQLite, prompts, cron payloads, unit files, or command arguments. The caller wrapper -`/home/ubuntu/projects/mira-dashboard/scripts/miraDashboardApi.ts` reads the +`/home/ubuntu/projects/mira-dashboard/production/checkout/scripts/miraDashboardApi.ts` reads the selected file only after checking type, ownership, exact mode, size, and token format. Request bodies come from standard input and the token is attached only as an `Authorization` header. The wrapper resolves its loopback origin from the diff --git a/docs/setup/new-vps.md b/docs/setup/new-vps.md index 33bc03b91..656021cdf 100644 --- a/docs/setup/new-vps.md +++ b/docs/setup/new-vps.md @@ -29,10 +29,12 @@ systemctl --user status openclaw-gateway.service Production checkout path: ```bash -mkdir -p /home/ubuntu/projects -cd /home/ubuntu/projects -git clone https://github.com/rajohan/Mira-Dashboard.git mira-dashboard -cd mira-dashboard +install -d -m 0755 \ + /home/ubuntu/projects/mira-dashboard/production \ + /home/ubuntu/projects/mira-dashboard/development +git clone https://github.com/rajohan/Mira-Dashboard.git \ + /home/ubuntu/projects/mira-dashboard/production/checkout +cd /home/ubuntu/projects/mira-dashboard/production/checkout ``` Install dependencies: @@ -47,9 +49,10 @@ Create the managed runtime roots: ```bash install -d -m 0755 \ - /home/ubuntu/projects/mira-dashboard-worktrees \ - /home/ubuntu/projects/mira-dashboard-releases -install -d -m 0700 /home/ubuntu/projects/mira-dashboard-state + /home/ubuntu/projects/mira-dashboard/development/state \ + /home/ubuntu/projects/mira-dashboard/development/worktrees \ + /home/ubuntu/projects/mira-dashboard/production/releases +install -d -m 0700 /home/ubuntu/projects/mira-dashboard/production/state ``` ## Publish The Initial Managed Release @@ -58,11 +61,8 @@ Build, preflight, checksum, and publish the checked-out commit from an isolated worktree: ```bash -cd /home/ubuntu/projects/mira-dashboard -RELEASES_ROOT=/home/ubuntu/projects/mira-dashboard-releases -DATABASE_PATH=/home/ubuntu/projects/mira-dashboard-state/mira-dashboard.db -OPENCLAW_CLIENT_HOME=/home/ubuntu/projects/mira-dashboard-state/openclaw-client -LOG_ROTATION_LOCK=/home/ubuntu/projects/mira-dashboard-state/log-rotation.lock +export MIRA_DASHBOARD_PROJECT_ROOT=/home/ubuntu/projects/mira-dashboard +cd "$MIRA_DASHBOARD_PROJECT_ROOT/production/checkout" CANDIDATE_SHA="$(git rev-parse HEAD)" # A new host has no live database to preflight yet. Initialize and migrate the @@ -70,7 +70,6 @@ CANDIDATE_SHA="$(git rev-parse HEAD)" ( cd backend env \ - MIRA_DASHBOARD_DB_PATH="$DATABASE_PATH" \ NODE_ENV=production \ bun -e ' const { database } = await import("./src/database.ts"); @@ -86,10 +85,6 @@ CANDIDATE_SHA="$(git rev-parse HEAD)" ) env \ - MIRA_DASHBOARD_DB_PATH="$DATABASE_PATH" \ - MIRA_DASHBOARD_OPENCLAW_HOME="$OPENCLAW_CLIENT_HOME" \ - MIRA_DASHBOARD_LOG_ROTATION_LOCK_FILE="$LOG_ROTATION_LOCK" \ - MIRA_DASHBOARD_RELEASES_ROOT="$RELEASES_ROOT" \ NODE_ENV=production \ bun backend/src/releaseDeployment.ts stage "$CANDIDATE_SHA" ``` @@ -97,12 +92,8 @@ env \ Activate it before installing/starting the managed systemd units: ```bash -RELEASES_ROOT=/home/ubuntu/projects/mira-dashboard-releases -DATABASE_PATH=/home/ubuntu/projects/mira-dashboard-state/mira-dashboard.db CANDIDATE_SHA="$(git rev-parse HEAD)" env \ - MIRA_DASHBOARD_DB_PATH="$DATABASE_PATH" \ - MIRA_DASHBOARD_RELEASES_ROOT="$RELEASES_ROOT" \ NODE_ENV=production \ bun backend/src/releaseLifecycle.ts activate "$CANDIDATE_SHA" ``` @@ -142,7 +133,7 @@ The Dashboard does not trust localhost as an identity. From the Dashboard checkout, provision four independent caller credentials: ```bash -cd /home/ubuntu/projects/mira-dashboard +cd /home/ubuntu/projects/mira-dashboard/production/checkout install -d -m 0700 /home/ubuntu/.config/mira-dashboard/automation bun scripts/provisionDashboardAutomationCredential.ts heartbeat bun scripts/provisionDashboardAutomationCredential.ts daily-summary @@ -172,7 +163,7 @@ must target `ubuntu`'s user manager. Install the tracked web and worker units: ```bash -cd /home/ubuntu/projects/mira-dashboard +cd /home/ubuntu/projects/mira-dashboard/production/checkout install -d -m 0755 /home/ubuntu/.config/systemd/user install -m 0644 systemd/mira-dashboard.service \ /home/ubuntu/.config/systemd/user/mira-dashboard.service diff --git a/docs/setup/production-deploy.md b/docs/setup/production-deploy.md index f3509493e..6032069cd 100644 --- a/docs/setup/production-deploy.md +++ b/docs/setup/production-deploy.md @@ -2,38 +2,53 @@ Production separates source control, immutable code, and persistent state: -| Purpose | Path | -| ------------------------------------------------- | ------------------------------------------------------------ | -| Control checkout and deployment scripts | `/home/ubuntu/projects/mira-dashboard` | -| Temporary detached build worktrees | `/home/ubuntu/projects/mira-dashboard-worktrees/` | -| Shared managed PR-dev checkout | `/home/ubuntu/projects/mira-dashboard-preview` | -| Managed PR-dev state and dependency cache | `/home/ubuntu/projects/mira-dashboard-preview-state/managed` | -| Immutable releases and `current`/`previous` links | `/home/ubuntu/projects/mira-dashboard-releases` | -| Persistent production state | `/home/ubuntu/projects/mira-dashboard-state` | +```text +/home/ubuntu/projects/mira-dashboard/ +├── production/ +│ ├── checkout/ +│ ├── releases/ +│ └── state/ +└── development/ + ├── preview/ + ├── state/ + │ ├── local/ + │ └── preview/ + └── worktrees/ +``` + +| Purpose | Path | +| ------------------------------------------------- | ---------------------------------------------------------------- | +| Control checkout and deployment scripts | `/home/ubuntu/projects/mira-dashboard/production/checkout` | +| Temporary detached build worktrees | `/home/ubuntu/projects/mira-dashboard/development/worktrees/` | +| Shared managed PR-dev checkout | `/home/ubuntu/projects/mira-dashboard/development/preview` | +| Managed PR-dev state and dependency cache | `/home/ubuntu/projects/mira-dashboard/development/state/preview` | +| Immutable releases and `current`/`previous` links | `/home/ubuntu/projects/mira-dashboard/production/releases` | +| Persistent production state | `/home/ubuntu/projects/mira-dashboard/production/state` | Web and worker execute from: ```text -/home/ubuntu/projects/mira-dashboard-releases/current/backend +/home/ubuntu/projects/mira-dashboard/production/releases/current/backend ``` `current` and `previous` are atomic relative symlinks to full-SHA directories -below `mira-dashboard-releases/releases/`. Production never builds in, writes -to, or executes backend code from the control checkout. +below `production/releases/releases/`. Production never builds in, writes to, +or executes backend code from the control checkout. ## Persistent State Mutable state is deliberately outside both Git and every release: -| State | Stable path | -| ---------------------------------- | -------------------------------------------------------------- | -| SQLite database | `/home/ubuntu/projects/mira-dashboard-state/mira-dashboard.db` | -| SQLite WAL and shared-memory files | next to the database | -| Restore-verified SQLite backups | `/home/ubuntu/projects/mira-dashboard-state/backups/` | -| Dashboard Gateway device identity | `/home/ubuntu/projects/mira-dashboard-state/openclaw-client/` | -| Log-rotation lock | `/home/ubuntu/projects/mira-dashboard-state/log-rotation.lock` | +| State | Stable path | +| ---------------------------------- | ------------------------------------------------------------------------- | +| SQLite database | `/home/ubuntu/projects/mira-dashboard/production/state/mira-dashboard.db` | +| SQLite WAL and shared-memory files | next to the database | +| Restore-verified SQLite backups | `/home/ubuntu/projects/mira-dashboard/production/state/backups/` | +| Dashboard Gateway device identity | `/home/ubuntu/projects/mira-dashboard/production/state/openclaw-client/` | +| Log-rotation lock | `/home/ubuntu/projects/mira-dashboard/production/state/log-rotation.lock` | -The backup directory is derived from `dirname(MIRA_DASHBOARD_DB_PATH)`, so +The backup directory is derived from the production state root (or from +`dirname(MIRA_DASHBOARD_DB_PATH)` when that advanced override is used), so pre-deploy and pre-migration snapshots automatically stay under the state root. Kopia mounts `/home/ubuntu/projects` as its projects source; the separate state directory remains in that backup scope. @@ -42,20 +57,18 @@ directory remains in that backup scope. application configuration, is listed in every release manifest, and is copied and checksum-verified with the other immutable release artifacts. -Both managed units set the stable paths explicitly: +Both managed units set one stable project root: ```text -MIRA_DASHBOARD_DB_PATH=/home/ubuntu/projects/mira-dashboard-state/mira-dashboard.db -MIRA_DASHBOARD_OPENCLAW_HOME=/home/ubuntu/projects/mira-dashboard-state/openclaw-client -MIRA_DASHBOARD_LOG_ROTATION_LOCK_FILE=/home/ubuntu/projects/mira-dashboard-state/log-rotation.lock -MIRA_DASHBOARD_RELEASE_ROOT=/home/ubuntu/projects/mira-dashboard-releases/current -MIRA_DASHBOARD_RELEASES_ROOT=/home/ubuntu/projects/mira-dashboard-releases +MIRA_DASHBOARD_PROJECT_ROOT=/home/ubuntu/projects/mira-dashboard ``` -Their Doppler command selectively preserves these five values plus `NODE_ENV`, +The backend derives every production and development path in the layout above +from that root. Their Doppler command preserves it plus `NODE_ENV`, `MIRA_DASHBOARD_EXECUTION_ROLE`, `MIRA_DASHBOARD_ENABLE_JOB_SCOPES`, and `MIRA_DASHBOARD_JOB_SCOPE_OWNER`, so production secrets cannot replace -unit-owned state, release paths, or orchestration policy. +unit-owned paths or orchestration policy. Fine-grained path variables remain +available only as explicit development, test, and recovery overrides. The OpenClaw home preserves the signed Gateway device identity across releases. Secrets remain in Doppler `rajohan/prd`; tracked unit files contain no secret @@ -67,7 +80,7 @@ The Dashboard worker owns the deployment: 1. Require a clean control checkout and fast-forward `main`. 2. Create a detached build worktree below - `/home/ubuntu/projects/mira-dashboard-worktrees/`. + `/home/ubuntu/projects/mira-dashboard/development/worktrees/`. 3. Install frozen frontend and backend dependencies. 4. Run `deploy:prepare` against the stable production database. 5. Verify the release manifest, component identities, schema contract, and @@ -84,9 +97,9 @@ The Dashboard worker owns the deployment: 11. On success, retain `current`, `previous`, and one additional newest verified release. -The executor fails closed unless both units already run from managed -`current/backend` with the exact stable state paths. A deployment never modifies -the running release. +The executor fails closed unless both units use the expected project root and +run from managed `current/backend`. A deployment never modifies the running +release. ## Restart And Smoke Test @@ -119,8 +132,9 @@ First confirm no deployment or rollback action is running: ```bash set -euo pipefail -RELEASES_ROOT=/home/ubuntu/projects/mira-dashboard-releases -DATABASE_PATH=/home/ubuntu/projects/mira-dashboard-state/mira-dashboard.db +export MIRA_DASHBOARD_PROJECT_ROOT=/home/ubuntu/projects/mira-dashboard +RELEASES_ROOT="$MIRA_DASHBOARD_PROJECT_ROOT/production/releases" +DATABASE_PATH="$MIRA_DASHBOARD_PROJECT_ROOT/production/state/mira-dashboard.db" assert_no_active_release_action() { local active_action @@ -157,9 +171,7 @@ CURRENT_LIFECYCLE="$CURRENT_RELEASE/backend/dist/releaseLifecycle.js" test -f "$CURRENT_LIFECYCLE" test ! -L "$CURRENT_LIFECYCLE" STATUS="$( - env MIRA_DASHBOARD_DB_PATH="$DATABASE_PATH" \ - MIRA_DASHBOARD_RELEASES_ROOT="$RELEASES_ROOT" \ - NODE_ENV=production \ + env NODE_ENV=production \ bun "$CURRENT_LIFECYCLE" status )" TARGET_SHA="$(jq --raw-output '.previous.commitSha // empty' <<<"$STATUS")" @@ -189,16 +201,12 @@ ready_for_commit() { } assert_no_active_release_action -env MIRA_DASHBOARD_DB_PATH="$DATABASE_PATH" \ - MIRA_DASHBOARD_RELEASES_ROOT="$RELEASES_ROOT" \ - NODE_ENV=production \ +env NODE_ENV=production \ bun "$CURRENT_LIFECYCLE" rollback "$CURRENT_SHA" "$TARGET_SHA" systemctl --user restart mira-dashboard-worker.service mira-dashboard.service if ! ready_for_commit "$TARGET_SHA"; then echo "Rollback target failed readiness. Restoring $CURRENT_SHA" >&2 - env MIRA_DASHBOARD_DB_PATH="$DATABASE_PATH" \ - MIRA_DASHBOARD_RELEASES_ROOT="$RELEASES_ROOT" \ - NODE_ENV=production \ + env NODE_ENV=production \ bun "$CURRENT_LIFECYCLE" rollback "$TARGET_SHA" "$CURRENT_SHA" systemctl --user restart mira-dashboard-worker.service mira-dashboard.service ready_for_commit "$CURRENT_SHA" diff --git a/docs/setup/secrets-and-env.md b/docs/setup/secrets-and-env.md index 18926bf2c..e2fd3f475 100644 --- a/docs/setup/secrets-and-env.md +++ b/docs/setup/secrets-and-env.md @@ -23,28 +23,35 @@ Environment token precedence is: ## Dashboard Storage And Paths -| Variable | Required | Default | Purpose | -| --------------------------------------- | ------------------------------ | ------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | -| `MIRA_DASHBOARD_DB_PATH` | Explicit in production units | `backend/data/mira-dashboard.db` from backend cwd | SQLite database path. Production uses `/home/ubuntu/projects/mira-dashboard-state/mira-dashboard.db`. | -| `MIRA_DASHBOARD_LOG_ROTATION_LOCK_FILE` | Explicit in production units | `backend/data/log-rotation.lock` from backend cwd | Stable cross-release lock for elevated log rotation. | -| `MIRA_DASHBOARD_FRONTEND_PATH` | Optional | repo `dist/` | Static frontend build served by the backend. | -| `OPENCLAW_HOME` | Optional | `~/.openclaw` | Primary OpenClaw home for file/config/media/agent lookups when set. | -| `MIRA_DASHBOARD_OPENCLAW_HOME` | Explicit in production units | `backend/data/openclaw-client` from backend cwd | Dashboard Gateway-client identity home. Production uses the persistent state root so its signed device identity survives releases. | -| `MIRA_DASHBOARD_RELEASE_ROOT` | Explicit in production units | inferred runtime root | Active immutable release root; production uses `/home/ubuntu/projects/mira-dashboard-releases/current`. | -| `MIRA_DASHBOARD_RELEASES_ROOT` | Explicit in production tooling | `/home/ubuntu/projects/mira-dashboard-releases` | Managed release layout containing `releases/`, `current`, `previous`, locks, and transition journal. | -| `WORKSPACE_ROOT` | Optional | OpenClaw workspace | Root exposed by `/api/files`. Must be absolute and normalized if set. | -| `MIRA_DASHBOARD_LOGS_ROOT` | Optional | system log root default | Root used by log stream services. | +| Variable | Required | Default | Purpose | +| --------------------------------------- | --------------------------- | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | +| `MIRA_DASHBOARD_PROJECT_ROOT` | Explicit in production unit | `/home/ubuntu/projects/mira-dashboard` | Single host layout root. Production/state/release and development paths are derived from it. | +| `MIRA_DASHBOARD_DB_PATH` | Optional advanced override | `/production/state/mira-dashboard.db` | SQLite database path. Without a configured project root, local source runs use `backend/data/mira-dashboard.db`. | +| `MIRA_DASHBOARD_LOG_ROTATION_LOCK_FILE` | Optional advanced override | `/production/state/log-rotation.lock` | Stable cross-release lock for elevated log rotation. | +| `MIRA_DASHBOARD_FRONTEND_PATH` | Optional | repo `dist/` | Static frontend build served by the backend. | +| `OPENCLAW_HOME` | Optional | `~/.openclaw` | Primary OpenClaw home for file/config/media/agent lookups when set. | +| `MIRA_DASHBOARD_OPENCLAW_HOME` | Optional advanced override | `/production/state/openclaw-client` | Dashboard Gateway-client identity home. Production uses persistent state so its signed device identity survives releases. | +| `MIRA_DASHBOARD_RELEASE_ROOT` | Internal/advanced override | inferred runtime root | Exact release root used by isolated builds and development stacks. Production infers the active immutable release from its cwd. | +| `MIRA_DASHBOARD_RELEASES_ROOT` | Optional advanced override | `/production/releases` | Managed release layout containing `releases/`, `current`, `previous`, locks, and transition journal. | +| `MIRA_DASHBOARD_ROOT` | Optional advanced override | `/production/checkout` | Clean `main` control checkout used for approved release builds and PR worktree ownership. | +| `MIRA_DASHBOARD_WORKTREE_ROOT` | Optional advanced override | `/development/worktrees` | Feature and detached release-build worktrees owned by the production checkout. | +| `WORKSPACE_ROOT` | Optional | OpenClaw workspace | Root exposed by `/api/files`. Must be absolute and normalized if set. | +| `MIRA_DASHBOARD_LOGS_ROOT` | Optional | system log root default | Root used by log stream services. | `MIRA_DASHBOARD_FRONTEND_PATH` is a development/test escape hatch. Production serves the active release's checksummed `dist/`; any configured value that does not resolve exactly to that directory is rejected. +Production units configure only `MIRA_DASHBOARD_PROJECT_ROOT`; the backend +derives the normal layout and does not need one environment variable per path. +The advanced path variables are retained because isolated development stacks, +tests, and recovery commands sometimes need deliberately nonstandard roots. + Production mutable state lives in -`/home/ubuntu/projects/mira-dashboard-state`, outside both the control checkout -and immutable releases. SQLite backups are derived as -`dirname(MIRA_DASHBOARD_DB_PATH)/backups`, so the production backup directory is -`/home/ubuntu/projects/mira-dashboard-state/backups/`. Versioned -`backend/config/` files are release artifacts, not external state. +`/home/ubuntu/projects/mira-dashboard/production/state`, outside both the +control checkout and immutable releases. SQLite backups live below the derived +state root (or `dirname(MIRA_DASHBOARD_DB_PATH)/backups` when overridden). +Versioned `backend/config/` files are release artifacts, not external state. `OPENCLAW_HOME` remains the primary OpenClaw installation/configuration root; `MIRA_DASHBOARD_OPENCLAW_HOME` is the separate Dashboard client identity root and is never used as a fallback for primary OpenClaw files, agents, config, @@ -156,28 +163,28 @@ child environment forwards the two auth timing values unchanged, uses the production RP ID only to decide whether copied WebAuthn public credentials are compatible, and does not inherit other provider or host credentials. -| Variable | Default | Purpose | -| --------------------------------------------- | ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------ | -| `MIRA_DASHBOARD_DEV_FRONTEND_PORT` | `5173` | Frontend hot-reload port. | -| `MIRA_DASHBOARD_DEV_BACKEND_PORT` | `3101` | Backend restart-on-change port. | -| `MIRA_DASHBOARD_DEV_HOT_RELOAD` | `1` when unset or empty | Accepts only `0` or `1`; enables frontend HMR and backend/frontend source watchers, while managed PR dev sets `0`. | -| `MIRA_DASHBOARD_DEV_PUBLIC_ORIGIN` | `http://localhost:5173` | Cookie/WebAuthn origin; remote dev derives the Tailscale HTTPS origin. | -| `MIRA_DASHBOARD_DEV_SOURCE_WEBAUTHN_RP_ID` | production `MIRA_DASHBOARD_WEBAUTHN_RP_ID` | Source snapshot RP used to retain or remove copied WebAuthn public credentials. | -| `MIRA_DASHBOARD_DEV_STATE_ROOT` | `~/projects/mira-dashboard-dev-state/local` | Owner-only isolated development state. | -| `MIRA_DASHBOARD_DEV_DB_SOURCE` | `~/projects/mira-dashboard-state/mira-dashboard.db` | Production database used only to create a scrubbed WAL-consistent snapshot. | -| `MIRA_DASHBOARD_DEV_RELEASES_SOURCE` | `~/projects/mira-dashboard-releases` | Managed releases copied into isolated state. | -| `MIRA_DASHBOARD_DEV_WORKSPACE_SOURCE` | `~/.openclaw/workspace` | Workspace copied with secret and symlink filtering. | -| `MIRA_DASHBOARD_DEV_OPENCLAW_CONFIG_SOURCE` | `~/.openclaw/openclaw.json` | Source for sanitized agent-only development config. | -| `MIRA_DASHBOARD_DEV_GATEWAY_URL` | `ws://127.0.0.1:18789` | Live production Gateway used by trusted dev. | -| `MIRA_DASHBOARD_DEV_GATEWAY_TOKEN_FILE` | none | Optional owner-only token file; local commands normally use Doppler environment. | -| `MIRA_DASHBOARD_PREVIEW_ROOT` | `/home/ubuntu/projects/mira-dashboard-preview-state/managed` | Host-owned records, shared install cache, and isolated per-PR state. | -| `MIRA_DASHBOARD_PREVIEW_WORKTREE_PATH` | `/home/ubuntu/projects/mira-dashboard-preview` | Single shared checkout reused by the managed PR-dev slot. | -| `MIRA_DASHBOARD_PREVIEW_GATEWAY_URL` | `ws://127.0.0.1:18789` | Production Gateway used only by the host-owned PR-dev capability proxy. | -| `MIRA_DASHBOARD_PREVIEW_GATEWAY_TOKEN_FILE` | `/gateway.token` | Disposable `0600` proxy credential mounted read-only into trusted PR dev. | -| `MIRA_DASHBOARD_PREVIEW_GATEWAY_PROXY_PORT` | `18790` | Loopback-only host proxy port; must differ from frontend/backend ports. | -| `MIRA_DASHBOARD_PREVIEW_GATEWAY_PROXY_UNIT` | `mira-dashboard-pr-preview-gateway.service` | Transient proxy unit name; no permanent systemd unit file is installed. | -| `MIRA_DASHBOARD_PREVIEW_OPENCLAW_SOURCE_ROOT` | `/home/ubuntu/.openclaw` | Source root for managed PR workspace/config snapshots. | -| `HOST` / `PORT` / `DASHBOARD_API_TARGET` | `127.0.0.1` / `5173` / `http://127.0.0.1:3101` | Child frontend bind and exact backend proxy target. | +| Variable | Default | Purpose | +| --------------------------------------------- | -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | +| `MIRA_DASHBOARD_DEV_FRONTEND_PORT` | `5173` | Frontend hot-reload port. | +| `MIRA_DASHBOARD_DEV_BACKEND_PORT` | `3101` | Backend restart-on-change port. | +| `MIRA_DASHBOARD_DEV_HOT_RELOAD` | `1` when unset or empty | Accepts only `0` or `1`; enables frontend HMR and backend/frontend source watchers, while managed PR dev sets `0`. | +| `MIRA_DASHBOARD_DEV_PUBLIC_ORIGIN` | `http://localhost:5173` | Cookie/WebAuthn origin; remote dev derives the Tailscale HTTPS origin. | +| `MIRA_DASHBOARD_DEV_SOURCE_WEBAUTHN_RP_ID` | production `MIRA_DASHBOARD_WEBAUTHN_RP_ID` | Source snapshot RP used to retain or remove copied WebAuthn public credentials. | +| `MIRA_DASHBOARD_DEV_STATE_ROOT` | `~/projects/mira-dashboard/development/state/local` | Owner-only isolated development state. | +| `MIRA_DASHBOARD_DEV_DB_SOURCE` | `~/projects/mira-dashboard/production/state/mira-dashboard.db` | Production database used only to create a scrubbed WAL-consistent snapshot. | +| `MIRA_DASHBOARD_DEV_RELEASES_SOURCE` | `~/projects/mira-dashboard/production/releases` | Managed releases copied into isolated state. | +| `MIRA_DASHBOARD_DEV_WORKSPACE_SOURCE` | `~/.openclaw/workspace` | Workspace copied with secret and symlink filtering. | +| `MIRA_DASHBOARD_DEV_OPENCLAW_CONFIG_SOURCE` | `~/.openclaw/openclaw.json` | Source for sanitized agent-only development config. | +| `MIRA_DASHBOARD_DEV_GATEWAY_URL` | `ws://127.0.0.1:18789` | Live production Gateway used by trusted dev. | +| `MIRA_DASHBOARD_DEV_GATEWAY_TOKEN_FILE` | none | Optional owner-only token file; local commands normally use Doppler environment. | +| `MIRA_DASHBOARD_PREVIEW_ROOT` | `/development/state/preview` | Optional nonstandard host state override; shared install cache and isolated per-PR state live here. | +| `MIRA_DASHBOARD_PREVIEW_WORKTREE_PATH` | `/development/preview` | Optional nonstandard checkout override for the single managed PR-dev slot. | +| `MIRA_DASHBOARD_PREVIEW_GATEWAY_URL` | `ws://127.0.0.1:18789` | Production Gateway used only by the host-owned PR-dev capability proxy. | +| `MIRA_DASHBOARD_PREVIEW_GATEWAY_TOKEN_FILE` | `/gateway.token` | Disposable `0600` proxy credential mounted read-only into trusted PR dev. | +| `MIRA_DASHBOARD_PREVIEW_GATEWAY_PROXY_PORT` | `18790` | Loopback-only host proxy port; must differ from frontend/backend ports. | +| `MIRA_DASHBOARD_PREVIEW_GATEWAY_PROXY_UNIT` | `mira-dashboard-pr-preview-gateway.service` | Transient proxy unit name; no permanent systemd unit file is installed. | +| `MIRA_DASHBOARD_PREVIEW_OPENCLAW_SOURCE_ROOT` | `/home/ubuntu/.openclaw` | Source root for managed PR workspace/config snapshots. | +| `HOST` / `PORT` / `DASHBOARD_API_TARGET` | `127.0.0.1` / `5173` / `http://127.0.0.1:3101` | Child frontend bind and exact backend proxy target. | See [Local development](../development/local-dev.md) for snapshot contents, blocked production mutations, cookie isolation, and the managed trusted-PR diff --git a/src/components/features/delivery/PullRequestDevelopmentCard.tsx b/src/components/features/delivery/PullRequestDevelopmentCard.tsx index 76078e7bb..4915206f6 100644 --- a/src/components/features/delivery/PullRequestDevelopmentCard.tsx +++ b/src/components/features/delivery/PullRequestDevelopmentCard.tsx @@ -58,6 +58,7 @@ export function PullRequestDevelopmentCard({ }) { const status = preview?.status ?? "stopped"; const hasPreview = preview?.number !== undefined; + const areControlsAvailable = preview?.controlsAvailable !== false; return ( @@ -73,10 +74,25 @@ export function PullRequestDevelopmentCard({

- - {error ? "Status unavailable" : previewLabel(status)} + + {error + ? "Status unavailable" + : areControlsAvailable + ? previewLabel(status) + : "View only"} - {hasPreview && status !== "stopped" && onStop ? ( + {hasPreview && + areControlsAvailable && + status !== "stopped" && + onStop ? (
- ) : ( + ) : areControlsAvailable ? (

Run an eligible trusted PR in dev from its card below.

@@ -131,6 +147,11 @@ export function PullRequestDevelopmentCard({ backup actions stay blocked.

+ ) : ( +

+ {preview?.message ?? + "PR dev controls are available only from the production Dashboard."} +

)}
); diff --git a/src/hooks/useDelivery.ts b/src/hooks/useDelivery.ts index e7113d0a1..5d0a35030 100644 --- a/src/hooks/useDelivery.ts +++ b/src/hooks/useDelivery.ts @@ -94,6 +94,7 @@ export type PullRequestPreviewLifecycle = export interface PullRequestPreviewStatus { backendPort?: number; commitSha?: string; + controlsAvailable?: boolean; frontendPort?: number; message?: string; number?: number; diff --git a/src/pages/Delivery.tsx b/src/pages/Delivery.tsx index 00cf69ffd..3e98646ee 100644 --- a/src/pages/Delivery.tsx +++ b/src/pages/Delivery.tsx @@ -831,10 +831,12 @@ export function Delivery() { const isRebuildDevelopment = isPreviewSlotActive && hasPullRequestPreviewSlot && !isPreviewCommitCurrent; const canStartDevelopment = !hasCurrentDevelopment; + const arePreviewControlsAvailable = previewStatus?.controlsAvailable !== false; const isPreviewActionDisabled = isActionPending || isPreviewStatusLoading || Boolean(previewStatusError) || + !arePreviewControlsAvailable || isPreviewSlotBusy || isPreviewTransitionInProgress; let blockedMessage: string | undefined; @@ -842,6 +844,10 @@ export function Delivery() { blockedMessage = "Loading PR dev status."; } else if (previewStatusError) { blockedMessage = `PR dev status is unavailable: ${previewStatusError.message}`; + } else if (!arePreviewControlsAvailable) { + blockedMessage = + previewStatus?.message ?? + "PR dev controls are available only from the production Dashboard."; } else if (isPreviewSlotBusy) { blockedMessage = `PR #${previewStatus?.number} currently owns the dev slot. Stop it before starting another PR.`; } else if (isPreviewTransitionInProgress) { @@ -899,7 +905,11 @@ export function Delivery() { type: "preview-stop", }) } - disabled={isActionPending || isPreviewTransitionInProgress} + disabled={ + isActionPending || + !arePreviewControlsAvailable || + isPreviewTransitionInProgress + } > Stop dev diff --git a/src/test/openClawAdapterVariants.test.ts b/src/test/openClawAdapterVariants.test.ts index 0c0e24633..8d5609e2d 100644 --- a/src/test/openClawAdapterVariants.test.ts +++ b/src/test/openClawAdapterVariants.test.ts @@ -730,7 +730,7 @@ describe("OpenClaw adapter variants", () => { envelope( "session.message", { - content: "/home/ubuntu/projects/mira-dashboard", + content: "/workspace/mira-dashboard", role: "toolResult", toolCallId: "call-1", toolName: "exec", @@ -756,7 +756,7 @@ describe("OpenClaw adapter variants", () => { }); expect(diagnostics).toHaveLength(1); expect(diagnostics?.[0]?.message.toolCalls?.[0]?.toolResult).toMatchObject({ - content: "/home/ubuntu/projects/mira-dashboard", + content: "/workspace/mira-dashboard", id: "call-1", name: "exec", }); diff --git a/src/test/pageBehavior.test.tsx b/src/test/pageBehavior.test.tsx index 5cf180d8b..5906a80b8 100644 --- a/src/test/pageBehavior.test.tsx +++ b/src/test/pageBehavior.test.tsx @@ -1726,9 +1726,9 @@ function apiResponse(url: string, method: string, init?: RequestInit) { if (url === "/api/pull-requests/production-checkout") { return Response.json({ checkout: { - root: "/home/ubuntu/projects/mira-dashboard", - expectedRoot: "/home/ubuntu/projects/mira-dashboard", - worktreeRoot: "/home/ubuntu/projects/mira-dashboard", + root: "/srv/mira-dashboard/production/checkout", + expectedRoot: "/srv/mira-dashboard/production/checkout", + worktreeRoot: "/srv/mira-dashboard/production/checkout", branch: "main", expectedBranch: "main", head: "abc123", @@ -2939,6 +2939,71 @@ describe("Mira Dashboard pages", () => { view.queryClient.clear(); }); + it("shows production-only PR dev controls without a host-path error", async () => { + const originalFetch = fetch; + let view: ReturnType | undefined; + try { + Object.defineProperty(globalThis, "fetch", { + configurable: true, + value: jest.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url === "/api/pull-requests") { + return Response.json({ + pullRequests: [ + { + author: { login: "mira-2026" }, + baseRefName: "main", + createdAt: "2026-06-24T08:00:00.000Z", + headRefName: "mira/dev-safe-preview", + headRefOid: "a".repeat(40), + isDraft: false, + number: 342, + previewEligible: true, + title: "Dev-safe preview controls", + updatedAt: "2026-06-24T08:05:00.000Z", + url: "https://github.com/rajohan/Mira-Dashboard/pull/342", + }, + ], + }); + } + if (url === "/api/pull-requests/preview") { + return Response.json({ + preview: { + controlsAvailable: false, + message: + "PR dev controls are available only from the production Dashboard.", + status: "stopped", + }, + }); + } + const method = init?.method ?? "GET"; + return apiResponse(url, method, init); + }), + writable: true, + }); + + view = renderPage(createElement(Delivery)); + + expect(await screen.findByText("View only")).toBeInTheDocument(); + expect( + screen.getAllByText( + "PR dev controls are available only from the production Dashboard." + ) + ).toHaveLength(2); + expect(screen.getByRole("button", { name: "Run in dev" })).toBeDisabled(); + expect(screen.queryByText(/must not overlap Dashboard source/u)).toBeNull(); + expect(screen.queryByText(/PR dev status is unavailable/u)).toBeNull(); + } finally { + view?.unmount(); + view?.queryClient.clear(); + Object.defineProperty(globalThis, "fetch", { + configurable: true, + value: originalFetch, + writable: true, + }); + } + }); + it("starts and stops an eligible trusted PR development environment", async () => { const user = userEvent.setup(); let preview: Record = { status: "stopped" }; @@ -3248,55 +3313,63 @@ describe("Mira Dashboard pages", () => { }); it("explains when deploy actions are blocked by the production checkout", async () => { - Object.defineProperty(globalThis, "fetch", { - configurable: true, - value: jest.fn(async (input: RequestInfo | URL, init?: RequestInit) => { - const url = String(input); - if (url === "/api/pull-requests/production-checkout") { - return Response.json({ - checkout: { - root: "/home/ubuntu/projects/mira-dashboard", - expectedRoot: "/home/ubuntu/projects/mira-dashboard", - worktreeRoot: - "/home/ubuntu/projects/mira-dashboard-worktrees", - branch: "main", - expectedBranch: "main", - head: "abc123", - isClean: false, - isProductionRoot: true, - isSafeForDeploy: false, - statusShort: " M src/App.tsx", - }, - }); - } - - const method = init?.method ?? "GET"; - return apiResponse(url, method, init); - }), - writable: true, - }); + const originalFetch = fetch; + let view: ReturnType | undefined; + try { + Object.defineProperty(globalThis, "fetch", { + configurable: true, + value: jest.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url === "/api/pull-requests/production-checkout") { + return Response.json({ + checkout: { + root: "/srv/mira-dashboard/production/checkout", + expectedRoot: "/srv/mira-dashboard/production/checkout", + worktreeRoot: "/srv/mira-dashboard/development/worktrees", + branch: "main", + expectedBranch: "main", + head: "abc123", + isClean: false, + isProductionRoot: true, + isSafeForDeploy: false, + statusShort: " M src/App.tsx", + }, + }); + } - const view = renderPage(createElement(Delivery)); + const method = init?.method ?? "GET"; + return apiResponse(url, method, init); + }), + writable: true, + }); - await waitFor(() => { - expect(screen.getByText("Dirty checkout")).toBeInTheDocument(); - }); - const deployButton = screen.getByRole("button", { - name: "Deploy latest main", - }); - expect(deployButton).toBeDisabled(); - expect(deployButton).toHaveAttribute( - "aria-describedby", - "deploy-checkout-disabled-reason" - ); - expect( - screen.getAllByText( - "Deploy and merge are blocked until local changes in the production checkout are resolved." - ).length - ).toBeGreaterThan(1); + view = renderPage(createElement(Delivery)); - view.unmount(); - view.queryClient.clear(); + await waitFor(() => { + expect(screen.getByText("Dirty checkout")).toBeInTheDocument(); + }); + const deployButton = screen.getByRole("button", { + name: "Deploy latest main", + }); + expect(deployButton).toBeDisabled(); + expect(deployButton).toHaveAttribute( + "aria-describedby", + "deploy-checkout-disabled-reason" + ); + expect( + screen.getAllByText( + "Deploy and merge are blocked until local changes in the production checkout are resolved." + ).length + ).toBeGreaterThan(1); + } finally { + view?.unmount(); + view?.queryClient.clear(); + Object.defineProperty(globalThis, "fetch", { + configurable: true, + value: originalFetch, + writable: true, + }); + } }); it("labels post-restart deployment checks as verifying", async () => { diff --git a/src/test/pullRequestDevelopmentCard.test.tsx b/src/test/pullRequestDevelopmentCard.test.tsx index b899c5b6f..d4634c2e4 100644 --- a/src/test/pullRequestDevelopmentCard.test.tsx +++ b/src/test/pullRequestDevelopmentCard.test.tsx @@ -38,6 +38,24 @@ describe("PullRequestDevelopmentCard", () => { expect(screen.getByText("Status unavailable")).toBeInTheDocument(); expect(screen.getByText("Preview status failed")).toBeInTheDocument(); + + rerender( + + ); + + expect(screen.getByText("View only")).toBeInTheDocument(); + expect( + screen.getByText( + "PR dev controls are available only from the production Dashboard." + ) + ).toBeInTheDocument(); }); it("renders every managed lifecycle with bounded preview details", () => { diff --git a/systemd/mira-dashboard-worker.service b/systemd/mira-dashboard-worker.service index 344dc197e..335ffaf27 100644 --- a/systemd/mira-dashboard-worker.service +++ b/systemd/mira-dashboard-worker.service @@ -6,17 +6,13 @@ Wants=network-online.target [Service] Type=simple UMask=0077 -WorkingDirectory=/home/ubuntu/projects/mira-dashboard-releases/current/backend +WorkingDirectory=%h/projects/mira-dashboard/production/releases/current/backend Environment=NODE_ENV=production Environment=MIRA_DASHBOARD_EXECUTION_ROLE=worker Environment=MIRA_DASHBOARD_ENABLE_JOB_SCOPES=1 Environment=MIRA_DASHBOARD_JOB_SCOPE_OWNER=mira-dashboard-worker.service -Environment=MIRA_DASHBOARD_DB_PATH=/home/ubuntu/projects/mira-dashboard-state/mira-dashboard.db -Environment=MIRA_DASHBOARD_LOG_ROTATION_LOCK_FILE=/home/ubuntu/projects/mira-dashboard-state/log-rotation.lock -Environment=MIRA_DASHBOARD_OPENCLAW_HOME=/home/ubuntu/projects/mira-dashboard-state/openclaw-client -Environment=MIRA_DASHBOARD_RELEASE_ROOT=/home/ubuntu/projects/mira-dashboard-releases/current -Environment=MIRA_DASHBOARD_RELEASES_ROOT=/home/ubuntu/projects/mira-dashboard-releases -ExecStart=/usr/local/bin/doppler run --config prd --project rajohan --preserve-env=NODE_ENV,MIRA_DASHBOARD_EXECUTION_ROLE,MIRA_DASHBOARD_ENABLE_JOB_SCOPES,MIRA_DASHBOARD_JOB_SCOPE_OWNER,MIRA_DASHBOARD_DB_PATH,MIRA_DASHBOARD_LOG_ROTATION_LOCK_FILE,MIRA_DASHBOARD_OPENCLAW_HOME,MIRA_DASHBOARD_RELEASE_ROOT,MIRA_DASHBOARD_RELEASES_ROOT -- /home/ubuntu/.bun/bin/bun dist/workerStart.js +Environment=MIRA_DASHBOARD_PROJECT_ROOT=/home/ubuntu/projects/mira-dashboard +ExecStart=/usr/local/bin/doppler run --config prd --project rajohan --preserve-env=NODE_ENV,MIRA_DASHBOARD_EXECUTION_ROLE,MIRA_DASHBOARD_ENABLE_JOB_SCOPES,MIRA_DASHBOARD_JOB_SCOPE_OWNER,MIRA_DASHBOARD_PROJECT_ROOT -- /home/ubuntu/.bun/bin/bun dist/workerStart.js Restart=on-failure RestartSec=5 KillMode=control-group diff --git a/systemd/mira-dashboard.service b/systemd/mira-dashboard.service index 3705a2dbd..ee9626f74 100644 --- a/systemd/mira-dashboard.service +++ b/systemd/mira-dashboard.service @@ -6,17 +6,13 @@ Wants=network-online.target [Service] Type=simple UMask=0077 -WorkingDirectory=/home/ubuntu/projects/mira-dashboard-releases/current/backend +WorkingDirectory=%h/projects/mira-dashboard/production/releases/current/backend Environment=NODE_ENV=production Environment=MIRA_DASHBOARD_EXECUTION_ROLE=web Environment=MIRA_DASHBOARD_ENABLE_JOB_SCOPES=1 Environment=MIRA_DASHBOARD_JOB_SCOPE_OWNER=mira-dashboard.service -Environment=MIRA_DASHBOARD_DB_PATH=/home/ubuntu/projects/mira-dashboard-state/mira-dashboard.db -Environment=MIRA_DASHBOARD_LOG_ROTATION_LOCK_FILE=/home/ubuntu/projects/mira-dashboard-state/log-rotation.lock -Environment=MIRA_DASHBOARD_OPENCLAW_HOME=/home/ubuntu/projects/mira-dashboard-state/openclaw-client -Environment=MIRA_DASHBOARD_RELEASE_ROOT=/home/ubuntu/projects/mira-dashboard-releases/current -Environment=MIRA_DASHBOARD_RELEASES_ROOT=/home/ubuntu/projects/mira-dashboard-releases -ExecStart=/usr/local/bin/doppler run --config prd --project rajohan --preserve-env=NODE_ENV,MIRA_DASHBOARD_EXECUTION_ROLE,MIRA_DASHBOARD_ENABLE_JOB_SCOPES,MIRA_DASHBOARD_JOB_SCOPE_OWNER,MIRA_DASHBOARD_DB_PATH,MIRA_DASHBOARD_LOG_ROTATION_LOCK_FILE,MIRA_DASHBOARD_OPENCLAW_HOME,MIRA_DASHBOARD_RELEASE_ROOT,MIRA_DASHBOARD_RELEASES_ROOT -- /home/ubuntu/.bun/bin/bun dist/serverStart.js +Environment=MIRA_DASHBOARD_PROJECT_ROOT=/home/ubuntu/projects/mira-dashboard +ExecStart=/usr/local/bin/doppler run --config prd --project rajohan --preserve-env=NODE_ENV,MIRA_DASHBOARD_EXECUTION_ROLE,MIRA_DASHBOARD_ENABLE_JOB_SCOPES,MIRA_DASHBOARD_JOB_SCOPE_OWNER,MIRA_DASHBOARD_PROJECT_ROOT -- /home/ubuntu/.bun/bin/bun dist/serverStart.js Restart=on-failure RestartSec=5 KillMode=control-group From 6403d3c7e768db35a36cc453f7bc625470f5412d Mon Sep 17 00:00:00 2001 From: mira-2026 Date: Mon, 27 Jul 2026 22:35:56 +0200 Subject: [PATCH 3/9] refactor: minimize Dashboard runtime environment --- README.md | 2 +- backend/src/database.ts | 17 +- backend/src/development/developmentStack.ts | 174 ++++++-------- backend/src/frontendAssets.ts | 14 +- backend/src/gateway.ts | 20 +- backend/src/http.ts | 7 +- backend/src/lib/dashboardPaths.ts | 24 ++ backend/src/lib/jobResources.ts | 14 +- backend/src/lib/logRoots.ts | 7 +- backend/src/lib/values.ts | 7 +- backend/src/releaseDeployment.ts | 96 +++----- backend/src/releaseManager.ts | 7 +- backend/src/releaseManifest.ts | 10 +- backend/src/requestPolicy.ts | 7 +- backend/src/routes/authRoutes.ts | 6 +- backend/src/routes/mediaRoutes.ts | 1 - backend/src/routes/metricsRoutes.ts | 27 ++- backend/src/routes/sttRoutes.ts | 8 +- backend/src/routes/ttsRoutes.ts | 7 +- backend/src/serverStart.ts | 16 +- backend/src/serverStartPolicy.ts | 14 +- backend/src/services/cacheRefresh.ts | 4 +- backend/src/services/dockerUpdater.ts | 7 +- backend/src/services/jobWorker.ts | 5 +- backend/src/services/logRotation.ts | 51 ++--- .../src/services/pullRequestPreviewHost.ts | 216 +++--------------- .../src/services/pullRequestPreviewPolicy.ts | 19 +- backend/src/services/pullRequestPreviews.ts | 34 ++- backend/src/services/pullRequests.ts | 79 +++---- backend/test/developmentStack.test.ts | 8 +- backend/test/dockerUpdater.test.ts | 9 +- backend/test/jobExecutionQueue.test.ts | 3 +- backend/test/pullRequestPreview.test.ts | 152 ++++-------- backend/test/releaseDeployment.test.ts | 108 +++++---- backend/test/routeAndServiceBehavior.test.ts | 6 - backend/test/serverStartupPolicy.test.ts | 160 +++++-------- backend/test/serviceBehavior.test.ts | 117 ++++------ backend/test/setup.ts | 20 +- backend/test/utilityBehavior.test.ts | 47 +++- docs/architecture/database.md | 14 +- docs/architecture/gateway-and-chat.md | 3 +- docs/architecture/overview.md | 13 +- docs/development/local-dev.md | 3 +- docs/operations/docker-updater.md | 2 - docs/operations/runbooks.md | 1 - docs/operations/scheduler-cache-backups.md | 13 +- docs/security/auth-and-trust-boundaries.md | 3 +- docs/setup/new-vps.md | 7 +- docs/setup/production-deploy.md | 15 +- docs/setup/secrets-and-env.md | 187 ++++++++------- scripts/developmentTailscale.ts | 2 - systemd/mira-dashboard-worker.service | 5 +- systemd/mira-dashboard.service | 5 +- 53 files changed, 719 insertions(+), 1084 deletions(-) diff --git a/README.md b/README.md index 73e846b95..dac713fa2 100644 --- a/README.md +++ b/README.md @@ -142,7 +142,7 @@ CI and local verification. ## Production checkout and PR worktrees -`/home/ubuntu/projects/mira-dashboard/production/checkout` is the production checkout. Keep it on `main`; the running service and deploy workflow build from this path only after Raymond approves a merge/deploy. +`/home/ubuntu/projects/mira-dashboard/production/checkout` is the clean production control checkout. Keep it on `main`; after Raymond approves a merge/deploy, the deploy workflow updates this source and builds the exact commit in an isolated detached worktree. Production never builds in or executes from the control checkout. Feature and autopilot work must use separate git worktrees under `/home/ubuntu/projects/mira-dashboard/development/worktrees`, for example: diff --git a/backend/src/database.ts b/backend/src/database.ts index 039d20c2b..baf02af44 100644 --- a/backend/src/database.ts +++ b/backend/src/database.ts @@ -10,8 +10,8 @@ import { secureSqliteFilePermissions, } from "./databaseStorage.ts"; import { - configuredDashboardProjectPaths, - resolveDashboardProjectPaths, + resolveDashboardProjectPathsForRuntime, + resolveDashboardRuntimePath, } from "./lib/dashboardPaths.ts"; type DatabaseSync = Database; @@ -29,14 +29,11 @@ function resolveDatabasePath(): { configuredDatabasePath: string | undefined; databasePath: string; } { - const projectPaths = - configuredDashboardProjectPaths() ?? - (process.env.NODE_ENV === "production" - ? resolveDashboardProjectPaths() - : undefined); - const configuredDatabasePath = - process.env.MIRA_DASHBOARD_DB_PATH?.trim() || - projectPaths?.productionDatabasePath; + const projectPaths = resolveDashboardProjectPathsForRuntime(); + const configuredDatabasePath = resolveDashboardRuntimePath( + projectPaths?.productionDatabasePath, + process.env.MIRA_DASHBOARD_DB_PATH + ); return { configuredDatabasePath, databasePath: configuredDatabasePath diff --git a/backend/src/development/developmentStack.ts b/backend/src/development/developmentStack.ts index b55863fa2..f32e2a558 100644 --- a/backend/src/development/developmentStack.ts +++ b/backend/src/development/developmentStack.ts @@ -35,9 +35,9 @@ import { const DEVELOPMENT_STATE_MARKER = ".mira-dashboard-development-state.json"; const DEVELOPMENT_SECRET_FILE = ".secret-encryption-key"; const RELEASE_SHA_PATTERN = /^[\da-f]{40}$/u; -const HOST_PATTERN = /^(?:localhost|[\da-f:.]+|[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?)$/iu; const RP_ID_PATTERN = /^(?:localhost|[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*)$/u; +const MANAGED_STATE_BASENAME_PATTERN = /^pr-\d+$/u; const DEFAULT_FRONTEND_PORT = 5173; const DEFAULT_BACKEND_PORT = 3101; const DEFAULT_GATEWAY_URL = "ws://127.0.0.1:18789"; @@ -249,28 +249,6 @@ function configuredPort( return port; } -function configuredHost( - name: string, - value: string | undefined, - fallback: string -): string { - const host = value?.trim() || fallback; - if (host.length > 253 || !HOST_PATTERN.test(host) || /[\s/\\\0]/u.test(host)) { - throw new TypeError(`${name} must be a valid listen hostname or IP address`); - } - return host; -} - -function configuredStateOwner(value: string | undefined, fallback: string): string { - const owner = value?.trim() || fallback; - if (!owner || owner.length > 512 || /[\r\n\0]/u.test(owner)) { - throw new TypeError( - "MIRA_DASHBOARD_DEV_STATE_OWNER must be a non-empty stable identifier" - ); - } - return owner; -} - function isEnvironmentFlagEnabled( name: string, value: string | undefined, @@ -387,8 +365,8 @@ export function resolveDevelopmentStackConfig( throw new TypeError("Frontend and backend development ports must be distinct"); } const hostHome = absoluteNonRootPath( - "MIRA_DASHBOARD_DEV_HOST_HOME", - environment.MIRA_DASHBOARD_DEV_HOST_HOME, + "HOME", + environment.HOME, environment.HOME?.trim() || os.homedir() ); if (!hostHome) { @@ -406,6 +384,11 @@ export function resolveDevelopmentStackConfig( if (!stateRoot) { throw new Error("Development state root could not be resolved"); } + const stateBasename = path.basename(stateRoot); + const isManagedPreviewState = + path.dirname(stateRoot) === + path.join(hostDashboardPaths.developmentPreviewStateRoot, "states") && + MANAGED_STATE_BASENAME_PATTERN.test(stateBasename); const publicOrigin = normalizedPublicOrigin( environment.MIRA_DASHBOARD_DEV_PUBLIC_ORIGIN, frontendPort @@ -416,18 +399,14 @@ export function resolveDevelopmentStackConfig( ); const gatewayUrl = normalizedGatewayUrl(environment.MIRA_DASHBOARD_DEV_GATEWAY_URL); const openClawSourceRoot = absoluteNonRootPath( - "MIRA_DASHBOARD_DEV_OPENCLAW_SOURCE_ROOT", - environment.MIRA_DASHBOARD_DEV_OPENCLAW_SOURCE_ROOT, + "OPENCLAW_HOME", + environment.OPENCLAW_HOME, path.join(hostHome, ".openclaw") ); return { apiTarget: `http://127.0.0.1:${backendPort}`, - backendHost: configuredHost( - "MIRA_DASHBOARD_DEV_BACKEND_HOST", - environment.MIRA_DASHBOARD_DEV_BACKEND_HOST, - "127.0.0.1" - ), + backendHost: "127.0.0.1", backendPort, databasePath: path.join(stateRoot, "mira-dashboard.db"), databaseSource: absoluteNonRootPath( @@ -435,11 +414,7 @@ export function resolveDevelopmentStackConfig( environment.MIRA_DASHBOARD_DEV_DB_SOURCE, hostDashboardPaths.productionDatabasePath ), - frontendHost: configuredHost( - "MIRA_DASHBOARD_DEV_FRONTEND_HOST", - environment.MIRA_DASHBOARD_DEV_FRONTEND_HOST, - "127.0.0.1" - ), + frontendHost: "127.0.0.1", frontendPort, gatewayTokenFile, gatewayUrl: gatewayUrl || DEFAULT_GATEWAY_URL, @@ -468,14 +443,12 @@ export function resolveDevelopmentStackConfig( rpId: publicOrigin.hostname.toLowerCase(), secretEncryptionKeyPath: path.join(stateRoot, DEVELOPMENT_SECRET_FILE), sourceWebAuthnRpId: normalizedOptionalRpId( - "MIRA_DASHBOARD_DEV_SOURCE_WEBAUTHN_RP_ID", - environment.MIRA_DASHBOARD_DEV_SOURCE_WEBAUTHN_RP_ID || - environment.MIRA_DASHBOARD_WEBAUTHN_RP_ID - ), - stateOwner: configuredStateOwner( - environment.MIRA_DASHBOARD_DEV_STATE_OWNER, - "local-dashboard-dev" + "MIRA_DASHBOARD_WEBAUTHN_RP_ID", + environment.MIRA_DASHBOARD_WEBAUTHN_RP_ID ), + stateOwner: isManagedPreviewState + ? `managed-${stateBasename}` + : "local-dashboard-dev", stateRoot, workspaceSource: absoluteNonRootPath( "MIRA_DASHBOARD_DEV_WORKSPACE_SOURCE", @@ -599,21 +572,44 @@ function backfillCompletedDeploymentHistory( const target = new Database(targetPath); try { if (!hasTable(target, "deployment_jobs")) return; - target.run("DELETE FROM deployment_jobs WHERE status NOT IN ('isOk', 'failed')"); - const existing = target - .query( - "SELECT 1 FROM deployment_jobs WHERE status IN ('isOk', 'failed') LIMIT 1" - ) - .get(); - if (existing) return; - - const source = new Database(sourcePath, { readonly: true }); - let rows: CompletedDeploymentHistoryRow[]; + target.run("BEGIN IMMEDIATE"); try { - if (!hasTable(source, "deployment_jobs")) return; - rows = source + target.run( + "DELETE FROM deployment_jobs WHERE status NOT IN ('isOk', 'failed')" + ); + const existing = target .query( - `SELECT + "SELECT 1 FROM deployment_jobs WHERE status IN ('isOk', 'failed') LIMIT 1" + ) + .get(); + if (!existing) { + const source = new Database(sourcePath, { readonly: true }); + let rows: CompletedDeploymentHistoryRow[]; + try { + rows = hasTable(source, "deployment_jobs") + ? (source + .query( + `SELECT + id, + status, + started_at, + updated_at, + commit_sha, + commit_title, + note, + stdout, + stderr + FROM deployment_jobs + WHERE status IN ('isOk', 'failed') + ORDER BY started_at, id` + ) + .all() as CompletedDeploymentHistoryRow[]) + : []; + } finally { + source.close(); + } + const insert = target.prepare( + `INSERT INTO deployment_jobs ( id, status, started_at, @@ -623,44 +619,22 @@ function backfillCompletedDeploymentHistory( note, stdout, stderr - FROM deployment_jobs - WHERE status IN ('isOk', 'failed') - ORDER BY started_at, id` - ) - .all() as CompletedDeploymentHistoryRow[]; - } finally { - source.close(); - } - if (rows.length === 0) return; - - target.run("BEGIN IMMEDIATE"); - try { - const insert = target.prepare( - `INSERT INTO deployment_jobs ( - id, - status, - started_at, - updated_at, - commit_sha, - commit_title, - note, - stdout, - stderr - ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` - ); - for (const row of rows) { - insert.run( - row.id, - row.status, - row.started_at, - row.updated_at, - row.commit_sha, - row.commit_title, - row.note, - row.stdout, - row.stderr + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` ); + for (const row of rows) { + insert.run( + row.id, + row.status, + row.started_at, + row.updated_at, + row.commit_sha, + row.commit_title, + row.note, + row.stdout, + row.stderr + ); + } } target.run("COMMIT"); } catch (error) { @@ -1070,9 +1044,7 @@ function developmentGatewayToken( } token = readFileSync(config.gatewayTokenFile, "utf8").trim(); } else { - token = - environment.OPENCLAW_GATEWAY_TOKEN?.trim() || - environment.OPENCLAW_TOKEN?.trim(); + token = environment.OPENCLAW_GATEWAY_TOKEN?.trim(); } if (!token || token.length > 16_384 || /[\r\n\0]/u.test(token)) { throw new Error( @@ -1089,25 +1061,19 @@ export function developmentBackendEnvironment( const gatewayToken = developmentGatewayToken(config); return { ...inheritedChildEnvironment(), - BUN_BINARY: process.execPath, HOME: config.openClawHome, MIRA_DASHBOARD_ALLOWED_ORIGINS: config.publicOrigin, - MIRA_DASHBOARD_COOKIE_NAMESPACE: `mira_dashboard_dev_${config.frontendPort}`, MIRA_DASHBOARD_DB_PATH: config.databasePath, + MIRA_DASHBOARD_DEV_COOKIE_NAMESPACE: `mira_dashboard_dev_${config.frontendPort}`, MIRA_DASHBOARD_DEV_SAFE_MODE: "1", - MIRA_DASHBOARD_DISABLE_SCHEDULER: "0", - MIRA_DASHBOARD_EXECUTION_ROLE: "combined", MIRA_DASHBOARD_FRONTEND_PATH: config.repositoryRoot, MIRA_DASHBOARD_HOST: config.backendHost, - MIRA_DASHBOARD_JOB_PROFILE: "isolated", MIRA_DASHBOARD_LOG_ROTATION_LOCK_FILE: path.join( config.stateRoot, "log-rotation.lock" ), MIRA_DASHBOARD_LOGS_ROOT: developmentLogsRoot(config), - MIRA_DASHBOARD_METRICS_DISK_PATH: config.repositoryRoot, MIRA_DASHBOARD_OPENCLAW_HOME: config.openClawClientHome, - MIRA_DASHBOARD_RELEASE_ROOT: config.repositoryRoot, MIRA_DASHBOARD_RELEASES_ROOT: config.releaseRoot, MIRA_DASHBOARD_ROOT: config.repositoryRoot, MIRA_DASHBOARD_SECRET_ENCRYPTION_KEY: developmentSecretEncryptionKey(config), diff --git a/backend/src/frontendAssets.ts b/backend/src/frontendAssets.ts index d4c5c7ec2..0309986c1 100644 --- a/backend/src/frontendAssets.ts +++ b/backend/src/frontendAssets.ts @@ -8,19 +8,11 @@ export function resolveFrontendPath( releaseRoot = getProcessReleaseRoot() ): string { const releaseFrontendPath = path.join(releaseRoot, "dist"); - const configuredPath = environment.MIRA_DASHBOARD_FRONTEND_PATH?.trim(); - if (!configuredPath) { + if (environment.NODE_ENV === "production") { return releaseFrontendPath; } - if ( - environment.NODE_ENV === "production" && - path.resolve(configuredPath) !== path.resolve(releaseFrontendPath) - ) { - throw new Error( - "MIRA_DASHBOARD_FRONTEND_PATH cannot override the checksummed release frontend in production" - ); - } - return configuredPath; + const configuredPath = environment.MIRA_DASHBOARD_FRONTEND_PATH?.trim(); + return configuredPath || releaseFrontendPath; } export function isFrontendIndexReady(): boolean { diff --git a/backend/src/gateway.ts b/backend/src/gateway.ts index 30165c711..5f8fac769 100644 --- a/backend/src/gateway.ts +++ b/backend/src/gateway.ts @@ -6,8 +6,8 @@ import { OpenClawChatBridge } from "./chat/openClawChatBridge.ts"; import { SqliteOpenClawChatSnapshotStore } from "./chat/openClawChatSnapshotStore.ts"; import type { DashboardSocket } from "./dashboardSocket.ts"; import { - configuredDashboardProjectPaths, - resolveDashboardProjectPaths, + resolveDashboardProjectPathsForRuntime, + resolveDashboardRuntimePath, } from "./lib/dashboardPaths.ts"; import { errorMessage } from "./lib/errors.ts"; import { @@ -36,12 +36,8 @@ function defaultOpenClawHome(): string { } const DEFAULT_DASHBOARD_OPENCLAW_HOME = - ( - configuredDashboardProjectPaths() ?? - (process.env.NODE_ENV === "production" - ? resolveDashboardProjectPaths() - : undefined) - )?.productionOpenClawHome ?? Path.join(process.cwd(), "data", "openclaw-client"); + resolveDashboardProjectPathsForRuntime()?.productionOpenClawHome ?? + Path.join(process.cwd(), "data", "openclaw-client"); /** Performs load or create dashboard device IDentity. */ function loadOrCreateDashboardDeviceIdentity( @@ -211,10 +207,10 @@ type GatewayClientConstructor = new ( const gatewayRuntime = { clientConstructor: OpenClawGatewayClient as GatewayClientConstructor, dashboardOpenClawHome: validateOpenClawRoot( - nonEmptyEnvironmentFallback( - "MIRA_DASHBOARD_OPENCLAW_HOME", - DEFAULT_DASHBOARD_OPENCLAW_HOME - ).trim(), + resolveDashboardRuntimePath( + resolveDashboardProjectPathsForRuntime()?.productionOpenClawHome, + process.env.MIRA_DASHBOARD_OPENCLAW_HOME + ) ?? DEFAULT_DASHBOARD_OPENCLAW_HOME, "MIRA_DASHBOARD_OPENCLAW_HOME" ), openClawHome: validateOpenClawRoot( diff --git a/backend/src/http.ts b/backend/src/http.ts index 3ad0809aa..26d65a18c 100644 --- a/backend/src/http.ts +++ b/backend/src/http.ts @@ -10,10 +10,13 @@ export function resolveDashboardCookieNames( environment: Record = process.env ): { pendingLogin: string; session: string } { const namespace = - environment.MIRA_DASHBOARD_COOKIE_NAMESPACE?.trim() || DEFAULT_COOKIE_NAMESPACE; + environment.NODE_ENV === "production" + ? DEFAULT_COOKIE_NAMESPACE + : environment.MIRA_DASHBOARD_DEV_COOKIE_NAMESPACE?.trim() || + DEFAULT_COOKIE_NAMESPACE; if (!COOKIE_NAMESPACE_PATTERN.test(namespace)) { throw new TypeError( - "MIRA_DASHBOARD_COOKIE_NAMESPACE must contain 1-48 lowercase letters, digits, or underscores" + "MIRA_DASHBOARD_DEV_COOKIE_NAMESPACE must contain 1-48 lowercase letters, digits, or underscores" ); } return { diff --git a/backend/src/lib/dashboardPaths.ts b/backend/src/lib/dashboardPaths.ts index 242c56a62..aa2123e56 100644 --- a/backend/src/lib/dashboardPaths.ts +++ b/backend/src/lib/dashboardPaths.ts @@ -59,3 +59,27 @@ export function resolveDashboardProjectPaths( environment.MIRA_DASHBOARD_PROJECT_ROOT?.trim() || FALLBACK_DASHBOARD_PROJECT_ROOT ); } + +export function resolveDashboardProjectPathsForRuntime( + environment: NodeJS.ProcessEnv = process.env +): DashboardProjectPaths | undefined { + return ( + configuredDashboardProjectPaths(environment) ?? + (environment.NODE_ENV === "production" + ? resolveDashboardProjectPaths(environment) + : undefined) + ); +} + +/** + * Keeps the production layout immutable while allowing isolated development + * children and tests to inject process-specific paths. + */ +export function resolveDashboardRuntimePath( + derivedPath: string | undefined, + internalOverride: string | undefined, + environment: NodeJS.ProcessEnv = process.env +): string | undefined { + const override = internalOverride?.trim() || undefined; + return environment.NODE_ENV === "production" ? derivedPath : override || derivedPath; +} diff --git a/backend/src/lib/jobResources.ts b/backend/src/lib/jobResources.ts index 231671760..95ba16315 100644 --- a/backend/src/lib/jobResources.ts +++ b/backend/src/lib/jobResources.ts @@ -26,6 +26,7 @@ interface JobResourcePolicy { } const resourceContext = new AsyncLocalStorage(); +const PRODUCTION_JOB_SCOPE_OWNER = "mira-dashboard-worker.service"; const resourcePolicies: Record = { interactive: { @@ -124,9 +125,14 @@ export function scopedJobProcessEnvironment( } 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 []; - return ["--property", `BindsTo=${owner}`, "--property", `After=${owner}`]; + return environment.NODE_ENV === "production" + ? [ + "--property", + `BindsTo=${PRODUCTION_JOB_SCOPE_OWNER}`, + "--property", + `After=${PRODUCTION_JOB_SCOPE_OWNER}`, + ] + : []; } /** Wraps child commands in a constrained transient scope while a worker action runs. */ @@ -139,7 +145,7 @@ export function scopedJobProcessCommand( if ( !context || isSystemdRunExecutable(executable) || - environment.MIRA_DASHBOARD_ENABLE_JOB_SCOPES !== "1" + environment.NODE_ENV !== "production" ) { return { arguments: [...arguments_], executable }; } diff --git a/backend/src/lib/logRoots.ts b/backend/src/lib/logRoots.ts index a010ec5a8..410da0458 100644 --- a/backend/src/lib/logRoots.ts +++ b/backend/src/lib/logRoots.ts @@ -13,7 +13,8 @@ function invalidLogRoot(message: string): Error { export function logUnavailableReason( environment: Record = process.env ): string | undefined { - return environment.MIRA_DASHBOARD_DEV_SAFE_MODE === "1" && + return environment.NODE_ENV !== "production" && + environment.MIRA_DASHBOARD_DEV_SAFE_MODE === "1" && !environment.MIRA_DASHBOARD_LOGS_ROOT?.trim() ? ISOLATED_DEV_LOGS_UNAVAILABLE_REASON : undefined; @@ -21,7 +22,9 @@ export function logUnavailableReason( export function resolveRealLogsDirectory(): string { const configuredRoot = - process.env.MIRA_DASHBOARD_LOGS_ROOT?.trim() || DEFAULT_LOGS_DIRECTORY; + process.env.NODE_ENV === "production" + ? DEFAULT_LOGS_DIRECTORY + : process.env.MIRA_DASHBOARD_LOGS_ROOT?.trim() || DEFAULT_LOGS_DIRECTORY; const resolvedRoot = path.resolve(configuredRoot); if (!path.isAbsolute(configuredRoot)) { diff --git a/backend/src/lib/values.ts b/backend/src/lib/values.ts index 04981a489..3a28838eb 100644 --- a/backend/src/lib/values.ts +++ b/backend/src/lib/values.ts @@ -21,8 +21,11 @@ export function resolveDashboardPort(value = process.env.PORT): number { } /** Returns the explicit Dashboard bind host or the production-compatible default. */ -export function resolveDashboardHost(value = process.env.MIRA_DASHBOARD_HOST): string { - const host = value?.trim(); +export function resolveDashboardHost( + value = process.env.MIRA_DASHBOARD_HOST, + environment: Record = process.env +): string { + const host = environment.NODE_ENV === "production" ? undefined : value?.trim(); if (!host) { return "0.0.0.0"; } diff --git a/backend/src/releaseDeployment.ts b/backend/src/releaseDeployment.ts index 851cd7c77..41e2eb8bc 100644 --- a/backend/src/releaseDeployment.ts +++ b/backend/src/releaseDeployment.ts @@ -22,24 +22,11 @@ export const MANAGED_DASHBOARD_UNITS = { } as const; export const MANAGED_DASHBOARD_PRESERVED_ENVIRONMENT = [ "NODE_ENV", - "MIRA_DASHBOARD_EXECUTION_ROLE", - "MIRA_DASHBOARD_ENABLE_JOB_SCOPES", - "MIRA_DASHBOARD_JOB_SCOPE_OWNER", "MIRA_DASHBOARD_PROJECT_ROOT", ] as const; const MANAGED_DASHBOARD_UNIT_POLICY_ENVIRONMENT = { - "mira-dashboard-worker.service": [ - "NODE_ENV=production", - "MIRA_DASHBOARD_EXECUTION_ROLE=worker", - "MIRA_DASHBOARD_ENABLE_JOB_SCOPES=1", - "MIRA_DASHBOARD_JOB_SCOPE_OWNER=mira-dashboard-worker.service", - ], - "mira-dashboard.service": [ - "NODE_ENV=production", - "MIRA_DASHBOARD_EXECUTION_ROLE=web", - "MIRA_DASHBOARD_ENABLE_JOB_SCOPES=1", - "MIRA_DASHBOARD_JOB_SCOPE_OWNER=mira-dashboard.service", - ], + "mira-dashboard-worker.service": ["NODE_ENV=production"], + "mira-dashboard.service": ["NODE_ENV=production"], } as const satisfies Record; export interface DashboardReleaseCommandResult { @@ -61,9 +48,7 @@ export type DashboardReleaseCommandRunner = ( export interface StageDashboardReleaseOptions { bunExecutable?: string; commandRunner?: DashboardReleaseCommandRunner; - databasePath?: string; onProgress?: (message: string) => void; - openClawHome?: string; releasesRoot?: string; signal?: AbortSignal; sourceRoot?: string; @@ -85,22 +70,29 @@ export interface ManagedDashboardUnitContract { type ManagedDashboardUnitName = keyof typeof MANAGED_DASHBOARD_UNITS; +const MANAGED_RELEASE_BUILD_ENVIRONMENT = [ + "HOME", + "HTTPS_PROXY", + "HTTP_PROXY", + "LANG", + "LC_ALL", + "NO_PROXY", + "PATH", + "TZ", +] as const; + function managedReleaseEnvironment( - contract: ManagedDashboardUnitContract, - releaseRoot: string + contract: ManagedDashboardUnitContract ): NodeJS.ProcessEnv { + const environment: NodeJS.ProcessEnv = {}; + for (const key of MANAGED_RELEASE_BUILD_ENVIRONMENT) { + if (process.env[key] !== undefined) { + environment[key] = process.env[key]; + } + } return { - ...process.env, - MIRA_DASHBOARD_DB_PATH: contract.databasePath, - MIRA_DASHBOARD_LOG_ROTATION_LOCK_FILE: contract.logRotationLockFile, - MIRA_DASHBOARD_OPENCLAW_HOME: contract.openClawHome, - MIRA_DASHBOARD_PREVIEW_ROOT: contract.previewRoot, - MIRA_DASHBOARD_PREVIEW_WORKTREE_PATH: contract.previewWorktreePath, + ...environment, MIRA_DASHBOARD_PROJECT_ROOT: contract.projectRoot, - MIRA_DASHBOARD_RELEASE_ROOT: releaseRoot, - MIRA_DASHBOARD_RELEASES_ROOT: contract.releasesRoot, - MIRA_DASHBOARD_ROOT: contract.sourceRoot, - MIRA_DASHBOARD_WORKTREE_ROOT: contract.worktreeRoot, NODE_ENV: "production", }; } @@ -216,50 +208,40 @@ async function defaultCommandRunner( } export function managedDashboardUnitContract( - releasesRoot = resolveDashboardReleasesRoot(), - databasePath?: string, - openClawHome?: string + releasesRoot = resolveDashboardReleasesRoot() ): ManagedDashboardUnitContract { const projectPaths = resolveDashboardProjectPaths(); const root = resolveAbsoluteNonRootPath(releasesRoot, "Dashboard releases root"); return { databasePath: resolveAbsoluteNonRootPath( - databasePath ?? - process.env.MIRA_DASHBOARD_DB_PATH ?? - projectPaths.productionDatabasePath, + projectPaths.productionDatabasePath, "Dashboard database path" ), logRotationLockFile: resolveAbsoluteNonRootPath( - process.env.MIRA_DASHBOARD_LOG_ROTATION_LOCK_FILE ?? - projectPaths.productionLogRotationLockFile, + projectPaths.productionLogRotationLockFile, "Dashboard log rotation lock file" ), openClawHome: resolveAbsoluteNonRootPath( - openClawHome ?? - process.env.MIRA_DASHBOARD_OPENCLAW_HOME ?? - projectPaths.productionOpenClawHome, + projectPaths.productionOpenClawHome, "Dashboard OpenClaw home" ), previewRoot: resolveAbsoluteNonRootPath( - process.env.MIRA_DASHBOARD_PREVIEW_ROOT ?? - projectPaths.developmentPreviewStateRoot, + projectPaths.developmentPreviewStateRoot, "Dashboard preview state root" ), previewWorktreePath: resolveAbsoluteNonRootPath( - process.env.MIRA_DASHBOARD_PREVIEW_WORKTREE_PATH ?? - projectPaths.developmentPreviewRoot, + projectPaths.developmentPreviewRoot, "Dashboard preview worktree path" ), projectRoot: projectPaths.projectRoot, releaseRoot: path.join(root, "current"), releasesRoot: root, sourceRoot: resolveAbsoluteNonRootPath( - process.env.MIRA_DASHBOARD_ROOT ?? projectPaths.productionCheckoutRoot, + projectPaths.productionCheckoutRoot, "Dashboard source root" ), worktreeRoot: resolveAbsoluteNonRootPath( - process.env.MIRA_DASHBOARD_WORKTREE_ROOT ?? - projectPaths.developmentWorktreeRoot, + projectPaths.developmentWorktreeRoot, "Dashboard worktree root" ), }; @@ -329,13 +311,7 @@ export async function stageDashboardRelease( ); const commandRunner = options.commandRunner ?? defaultCommandRunner; const projectPaths = resolveDashboardProjectPaths(); - const contract = managedDashboardUnitContract( - releasesRoot, - options.databasePath ?? - process.env.MIRA_DASHBOARD_DB_PATH ?? - projectPaths.productionDatabasePath, - options.openClawHome - ); + const contract = managedDashboardUnitContract(releasesRoot); let existingRelease: ManagedDashboardRelease | undefined; try { existingRelease = await loadManagedRelease(releasesRoot, expectedCommit); @@ -348,7 +324,7 @@ export async function stageDashboardRelease( options.onProgress?.("Preflighting existing immutable release"); await commandRunner(bunExecutable, ["dist/databasePreflight.js"], { cwd: path.join(existingRelease.path, "backend"), - environment: managedReleaseEnvironment(contract, existingRelease.path), + environment: managedReleaseEnvironment(contract), signal: options.signal, timeoutMs: 120_000, }); @@ -356,15 +332,11 @@ export async function stageDashboardRelease( } const sourceRoot = resolveAbsoluteNonRootPath( - options.sourceRoot ?? - process.env.MIRA_DASHBOARD_ROOT ?? - projectPaths.productionCheckoutRoot, + options.sourceRoot ?? projectPaths.productionCheckoutRoot, "Dashboard source root" ); const worktreeRoot = resolveAbsoluteNonRootPath( - options.worktreeRoot ?? - process.env.MIRA_DASHBOARD_WORKTREE_ROOT ?? - projectPaths.developmentWorktreeRoot, + options.worktreeRoot ?? projectPaths.developmentWorktreeRoot, "Dashboard worktree root" ); await assertRealDirectory(sourceRoot, "Dashboard source root"); @@ -373,7 +345,7 @@ export async function stageDashboardRelease( worktreeRoot, `release-${expectedCommit.slice(0, 12)}-${randomUUID()}` ); - const environment = managedReleaseEnvironment(contract, worktreePath); + const environment = managedReleaseEnvironment(contract); let isWorktreeCreated = false; let stagedRelease: ManagedDashboardRelease | undefined; let stagingError: unknown; diff --git a/backend/src/releaseManager.ts b/backend/src/releaseManager.ts index a1ab1bec7..ba33afcf4 100644 --- a/backend/src/releaseManager.ts +++ b/backend/src/releaseManager.ts @@ -15,6 +15,7 @@ import type { DatabaseMigrationIdentity } from "./databaseMigrations/index.ts"; import { configuredDashboardProjectPaths, resolveDashboardProjectPaths, + resolveDashboardRuntimePath, } from "./lib/dashboardPaths.ts"; import { guardedPath, writeTextNoFollowGuarded } from "./lib/guardedOps.ts"; import { resolveAbsoluteNonRootPath } from "./lib/safePath.ts"; @@ -369,9 +370,11 @@ async function writeReleaseTransitionJournal( } export function resolveDashboardReleasesRoot( - configuredRoot = process.env.MIRA_DASHBOARD_RELEASES_ROOT ?? + configuredRoot = resolveDashboardRuntimePath( configuredDashboardProjectPaths()?.productionReleasesRoot ?? - resolveDashboardProjectPaths({}).productionReleasesRoot + resolveDashboardProjectPaths({}).productionReleasesRoot, + process.env.MIRA_DASHBOARD_RELEASES_ROOT + ) ?? resolveDashboardProjectPaths({}).productionReleasesRoot ): string { return resolveAbsoluteNonRootPath(configuredRoot, "Dashboard releases root"); } diff --git a/backend/src/releaseManifest.ts b/backend/src/releaseManifest.ts index 43c2fe0d2..6fa16524d 100644 --- a/backend/src/releaseManifest.ts +++ b/backend/src/releaseManifest.ts @@ -710,12 +710,10 @@ export async function verifyReleaseArtifacts( } function inferProcessReleaseRoot(): string { - const configured = process.env.MIRA_DASHBOARD_RELEASE_ROOT?.trim(); - const candidate = configured - ? path.resolve(configured) - : path.basename(process.cwd()) === "backend" - ? path.dirname(process.cwd()) - : path.resolve(import.meta.dirname, "..", ".."); + const candidate = + path.basename(process.cwd()) === "backend" + ? path.dirname(process.cwd()) + : path.resolve(import.meta.dirname, "..", ".."); try { return fs.realpathSync(candidate); } catch { diff --git a/backend/src/requestPolicy.ts b/backend/src/requestPolicy.ts index 1e674a016..43d831bf0 100644 --- a/backend/src/requestPolicy.ts +++ b/backend/src/requestPolicy.ts @@ -158,6 +158,7 @@ export function isDevelopmentHostMutationBlocked( environment: Record = process.env ): boolean { if ( + environment.NODE_ENV === "production" || environment.MIRA_DASHBOARD_DEV_SAFE_MODE !== "1" || SAFE_REQUEST_METHODS.has(request.method.toUpperCase()) ) { @@ -173,7 +174,10 @@ export function isDevelopmentHostMutationBlocked( export function isDevelopmentExternalNotificationSuppressed( environment: Record = process.env ): boolean { - return environment.MIRA_DASHBOARD_DEV_SAFE_MODE === "1"; + return ( + environment.NODE_ENV !== "production" && + environment.MIRA_DASHBOARD_DEV_SAFE_MODE === "1" + ); } export { @@ -188,6 +192,7 @@ export function isDevelopmentGatewayMethodBlocked( environment: Record = process.env ): boolean { return ( + environment.NODE_ENV !== "production" && environment.MIRA_DASHBOARD_DEV_SAFE_MODE === "1" && !isDevelopmentGatewayMethodAllowed(method) ); diff --git a/backend/src/routes/authRoutes.ts b/backend/src/routes/authRoutes.ts index 4089783c5..ca25626fd 100644 --- a/backend/src/routes/authRoutes.ts +++ b/backend/src/routes/authRoutes.ts @@ -206,11 +206,7 @@ function isGatewayAuthFailure(error: unknown): boolean { } function environmentGatewayToken(): string | undefined { - return ( - process.env.OPENCLAW_GATEWAY_TOKEN?.trim() || - process.env.OPENCLAW_TOKEN?.trim() || - undefined - ); + return process.env.OPENCLAW_GATEWAY_TOKEN?.trim() || undefined; } const firstUserBootstrapState = { diff --git a/backend/src/routes/mediaRoutes.ts b/backend/src/routes/mediaRoutes.ts index 8a724da51..aa9a6fba7 100644 --- a/backend/src/routes/mediaRoutes.ts +++ b/backend/src/routes/mediaRoutes.ts @@ -84,7 +84,6 @@ function mimeTypeFromPath(filePath: string): string { function configuredGatewayToken(): string | undefined { return ( process.env.OPENCLAW_GATEWAY_TOKEN?.trim() || - process.env.OPENCLAW_TOKEN?.trim() || getPersistedGatewayToken()?.trim() || undefined ); diff --git a/backend/src/routes/metricsRoutes.ts b/backend/src/routes/metricsRoutes.ts index 8baca9cda..5d5ede111 100644 --- a/backend/src/routes/metricsRoutes.ts +++ b/backend/src/routes/metricsRoutes.ts @@ -67,6 +67,8 @@ interface MetricsResponse extends SystemMetricsResponse { tokens: TokenMetrics; } +const PREFERRED_LINUX_NETWORK_INTERFACE = "enp0s6"; + const metricsRouteState: { networkSampleLock: Promise; previousNetworkSample: @@ -108,15 +110,14 @@ async function getNetworkMetrics(): Promise { let didReadNetwork = false; if (os.platform() === "linux") { - const preferredInterface = - process.env.MIRA_DASHBOARD_NETWORK_INTERFACE?.trim() || "enp0s6"; try { - // Prefer the VPS default Linux interface, but allow deployments to - // override it; if neither it nor another non-loopback interface is - // available, network metrics fall back through the route error path. + // Prefer the VPS default Linux interface, then fall back to every + // non-loopback interface when it is unavailable. const availableInterfaces = await readdir("/sys/class/net"); - const interfaces = availableInterfaces.includes(preferredInterface) - ? [preferredInterface] + const interfaces = availableInterfaces.includes( + PREFERRED_LINUX_NETWORK_INTERFACE + ) + ? [PREFERRED_LINUX_NETWORK_INTERFACE] : availableInterfaces.filter((name) => name !== "lo"); for (const name of interfaces) { @@ -151,9 +152,11 @@ async function getNetworkMetrics(): Promise { }); const nonLoopbackRows = rows.filter((row) => row.name !== "lo"); const selectedRows = nonLoopbackRows.some( - (row) => row.name === preferredInterface + (row) => row.name === PREFERRED_LINUX_NETWORK_INTERFACE ) - ? nonLoopbackRows.filter((row) => row.name === preferredInterface) + ? nonLoopbackRows.filter( + (row) => row.name === PREFERRED_LINUX_NETWORK_INTERFACE + ) : nonLoopbackRows; for (const row of selectedRows) { downloadBytes += row.rxBytes; @@ -233,11 +236,7 @@ async function getSystemMetrics(): Promise { try { const isDarwin = os.platform() === "darwin"; - const configuredDiskPath = process.env.MIRA_DASHBOARD_METRICS_DISK_PATH?.trim(); - const diskPath = - configuredDiskPath && path.isAbsolute(configuredDiskPath) - ? path.resolve(configuredDiskPath) - : "/"; + const diskPath = path.resolve(process.cwd()); const dfArguments = isDarwin ? ["-k", diskPath] : ["-B1", "--output=size,used,pcent", diskPath]; diff --git a/backend/src/routes/sttRoutes.ts b/backend/src/routes/sttRoutes.ts index d91038676..2d5cc2d23 100644 --- a/backend/src/routes/sttRoutes.ts +++ b/backend/src/routes/sttRoutes.ts @@ -4,8 +4,8 @@ import { stringFallback } from "../lib/values.ts"; const MAX_AUDIO_BYTES = 20 * 1024 * 1024; const ELEVENLABS_TIMEOUT_MS = 60_000; const ELEVENLABS_API_URL = "https://api.elevenlabs.io/v1/speech-to-text"; -const ELEVENLABS_STT_MODEL = process.env.ELEVENLABS_STT_MODEL || "scribe_v2"; -const ELEVENLABS_STT_LANGUAGE = process.env.ELEVENLABS_STT_LANGUAGE || "nor"; +const ELEVENLABS_STT_MODEL = "scribe_v2"; +const ELEVENLABS_STT_LANGUAGE = "nor"; const sttRouteState: { activeTranscriptionToken?: string } = {}; @@ -65,9 +65,7 @@ async function transcribeWithElevenLabs( formData.append("model_id", ELEVENLABS_STT_MODEL); formData.append("tag_audio_events", "false"); formData.append("diarize", "false"); - if (ELEVENLABS_STT_LANGUAGE && ELEVENLABS_STT_LANGUAGE !== "auto") { - formData.append("language_code", ELEVENLABS_STT_LANGUAGE); - } + formData.append("language_code", ELEVENLABS_STT_LANGUAGE); try { try { diff --git a/backend/src/routes/ttsRoutes.ts b/backend/src/routes/ttsRoutes.ts index 3f2fb4d4f..05e583220 100644 --- a/backend/src/routes/ttsRoutes.ts +++ b/backend/src/routes/ttsRoutes.ts @@ -2,11 +2,8 @@ import { json, readJson, readResponseTextFallback } from "../http.ts"; import { errorMessage, httpStatusCode } from "../lib/errors.ts"; const ELEVENLABS_TTS_TIMEOUT_MS = 60_000; -const ELEVENLABS_TTS_MODEL = process.env.ELEVENLABS_TTS_MODEL || "eleven_turbo_v2_5"; -const ELEVENLABS_TTS_VOICE_ID = - process.env.ELEVENLABS_TTS_VOICE_ID || - process.env.ELEVENLABS_VOICE_ID || - "q7O4dHCU5KzDbUYNsckR"; +const ELEVENLABS_TTS_MODEL = "eleven_turbo_v2_5"; +const ELEVENLABS_TTS_VOICE_ID = "q7O4dHCU5KzDbUYNsckR"; const MAX_TTS_TEXT_LENGTH = 4000; interface TtsRequestBody { diff --git a/backend/src/serverStart.ts b/backend/src/serverStart.ts index fc07bd2e8..c1da2edea 100644 --- a/backend/src/serverStart.ts +++ b/backend/src/serverStart.ts @@ -39,7 +39,6 @@ export function resolveGatewayToken( ): string | undefined { return ( environment.OPENCLAW_GATEWAY_TOKEN?.trim() || - environment.OPENCLAW_TOKEN?.trim() || persistedToken()?.trim() || undefined ); @@ -128,11 +127,8 @@ export function isDirectEntrypoint(isMain = import.meta.main): boolean { return isMain; } -export function shouldStartOnImport( - startOnImport = process.env.MIRA_DASHBOARD_START_ON_IMPORT, - isDirect = isDirectEntrypoint() -): boolean { - return startOnImport === "1" || isDirect; +export function shouldStartOnImport(isDirect = isDirectEntrypoint()): boolean { + return isDirect; } interface BackendServerEntrypointOptions { @@ -140,8 +136,6 @@ interface BackendServerEntrypointOptions { isDirect?: boolean; reportFailure?: (error: unknown) => void; runServer?: () => Promise; - startServer?: () => Promise | void; - startOnImport?: string; } function reportBackendServerFailure(error: unknown): void { @@ -174,14 +168,8 @@ export async function startBackendServerEntrypoint({ isDirect = isDirectEntrypoint(), reportFailure = reportBackendServerFailure, runServer = runBackendServer, - startServer = startBackendServer, - startOnImport = process.env.MIRA_DASHBOARD_START_ON_IMPORT, }: BackendServerEntrypointOptions = {}): Promise { - if (!shouldStartOnImport(startOnImport, isDirect)) { - return; - } if (!isDirect) { - await startServer(); return; } let exitCode = 0; diff --git a/backend/src/serverStartPolicy.ts b/backend/src/serverStartPolicy.ts index 99d850537..6f700bff5 100644 --- a/backend/src/serverStartPolicy.ts +++ b/backend/src/serverStartPolicy.ts @@ -1,17 +1,5 @@ -export type DashboardExecutionRole = "combined" | "web" | "worker"; - -export function dashboardExecutionRole( - environment: Record = process.env -): DashboardExecutionRole { - const role = environment.MIRA_DASHBOARD_EXECUTION_ROLE?.trim(); - return role === "web" || role === "worker" ? role : "combined"; -} - export function shouldStartScheduledJobs( environment: Record = process.env ): boolean { - return ( - environment.MIRA_DASHBOARD_DISABLE_SCHEDULER !== "1" && - dashboardExecutionRole(environment) !== "web" - ); + return environment.NODE_ENV !== "production"; } diff --git a/backend/src/services/cacheRefresh.ts b/backend/src/services/cacheRefresh.ts index 862a30190..a9552b014 100644 --- a/backend/src/services/cacheRefresh.ts +++ b/backend/src/services/cacheRefresh.ts @@ -1864,7 +1864,9 @@ async function refreshDockerSummaryCache() { } async function refreshDatabaseSummaryCache() { - const isIsolated = process.env.MIRA_DASHBOARD_JOB_PROFILE === "isolated"; + const isIsolated = + process.env.NODE_ENV !== "production" && + process.env.MIRA_DASHBOARD_DEV_SAFE_MODE === "1"; const previousEntry = isIsolated ? await getCacheEntry(DATABASE_SUMMARY_KEY) : undefined; diff --git a/backend/src/services/dockerUpdater.ts b/backend/src/services/dockerUpdater.ts index e8f11e507..f7ab65bd8 100644 --- a/backend/src/services/dockerUpdater.ts +++ b/backend/src/services/dockerUpdater.ts @@ -1121,7 +1121,7 @@ function servicePlatform(service: ManagedServiceRow): string { } return typeof metadata.platform === "string" && metadata.platform ? metadata.platform - : process.env.MIRA_DOCKER_UPDATER_PLATFORM || hostDockerPlatform(); + : hostDockerPlatform(); } function isImageMatchPlatform(image: JsonRecord, platform: string): boolean { @@ -1222,7 +1222,10 @@ async function lookupRegistryV2(service: ManagedServiceRow, signal?: AbortSignal async function lookupLatest(service: ManagedServiceRow, signal?: AbortSignal) { signal?.throwIfAborted(); - if (process.env.MIRA_DOCKER_UPDATER_SKIP_REGISTRY === "1") { + if ( + process.env.NODE_ENV !== "production" && + process.env.MIRA_DOCKER_UPDATER_SKIP_REGISTRY === "1" + ) { return { latestTag: service.current_tag, latestDigest: service.current_digest, diff --git a/backend/src/services/jobWorker.ts b/backend/src/services/jobWorker.ts index ede2bab73..2d1fb6d56 100644 --- a/backend/src/services/jobWorker.ts +++ b/backend/src/services/jobWorker.ts @@ -35,7 +35,10 @@ export type DashboardJobProfile = "full" | "isolated"; export function dashboardJobProfile( environment: Record = process.env ): DashboardJobProfile { - return environment.MIRA_DASHBOARD_JOB_PROFILE === "isolated" ? "isolated" : "full"; + return environment.NODE_ENV !== "production" && + environment.MIRA_DASHBOARD_DEV_SAFE_MODE === "1" + ? "isolated" + : "full"; } function trackWorkerStop(operation: () => Promise): Promise { diff --git a/backend/src/services/logRotation.ts b/backend/src/services/logRotation.ts index ade7f4ac7..9cb202a54 100644 --- a/backend/src/services/logRotation.ts +++ b/backend/src/services/logRotation.ts @@ -4,8 +4,8 @@ import path from "node:path"; import { database } from "../database.ts"; import { - configuredDashboardProjectPaths, - resolveDashboardProjectPaths, + resolveDashboardProjectPathsForRuntime, + resolveDashboardRuntimePath, } from "../lib/dashboardPaths.ts"; import { runProcess } from "../lib/processes.ts"; import { resolveAbsoluteNonRootPath } from "../lib/safePath.ts"; @@ -72,26 +72,32 @@ const ELEVATED_LOG_ROTATION_TIMEOUT_MS = 5 * 60_000; const ELEVATED_LOG_ROTATION_MAX_BUFFER = 16 * 1024 * 1024; const LOG_ROTATION_JOB_ID = "ops.log-rotation"; const LOG_ROTATION_FAILURE_OUTPUT_MAX_CHARS = 100_000; -const BUN_EXECUTABLE = process.env.BUN_BINARY || "bun"; -const ELEVATED_LOG_ROTATION_FORWARDED_ENVIRONMENT = [ +const ELEVATED_LOG_ROTATION_RUNTIME_ENVIRONMENT = [ "LANG", "NODE_ENV", "TZ", "MIRA_DASHBOARD_PROJECT_ROOT", +] as const; +const ELEVATED_LOG_ROTATION_INTERNAL_ENVIRONMENT = [ "MIRA_DASHBOARD_DB_PATH", "MIRA_DASHBOARD_LOG_ROTATION_LOCK_FILE", ] as const; +function elevatedLogRotationForwardedEnvironment(): readonly string[] { + return process.env.NODE_ENV === "production" + ? ELEVATED_LOG_ROTATION_RUNTIME_ENVIRONMENT + : [ + ...ELEVATED_LOG_ROTATION_RUNTIME_ENVIRONMENT, + ...ELEVATED_LOG_ROTATION_INTERNAL_ENVIRONMENT, + ]; +} + function resolveLogRotationLockFile(): string { return resolveAbsoluteNonRootPath( - process.env.MIRA_DASHBOARD_LOG_ROTATION_LOCK_FILE?.trim() || - ( - configuredDashboardProjectPaths() ?? - (process.env.NODE_ENV === "production" - ? resolveDashboardProjectPaths() - : undefined) - )?.productionLogRotationLockFile || - DEFAULT_LOCK_FILE, + resolveDashboardRuntimePath( + resolveDashboardProjectPathsForRuntime()?.productionLogRotationLockFile, + process.env.MIRA_DASHBOARD_LOG_ROTATION_LOCK_FILE + ) ?? DEFAULT_LOCK_FILE, "MIRA_DASHBOARD_LOG_ROTATION_LOCK_FILE" ); } @@ -143,23 +149,8 @@ function defaultConfigPath(): string { return BUNDLED_CONFIG_PATH; } -function resolveExecutableFromPath(executable: string): string | undefined { - if (path.isAbsolute(executable)) { - return executable; - } - if (executable.includes(path.sep)) { - return path.resolve(executable); - } - - return Bun.which(executable) ?? undefined; -} - function resolveBunExecutable(): string { - const resolved = resolveExecutableFromPath(BUN_EXECUTABLE); - if (resolved) { - return resolved; - } - return BUN_EXECUTABLE === "bun" ? process.execPath : BUN_EXECUTABLE; + return process.execPath; } function fileHandleReadableStream( @@ -2051,7 +2042,7 @@ function buildElevatedLogRotationCliArguments( ].join("\n"); return [ "-n", - `--preserve-env=${ELEVATED_LOG_ROTATION_FORWARDED_ENVIRONMENT.join(",")}`, + `--preserve-env=${elevatedLogRotationForwardedEnvironment().join(",")}`, resolveBunExecutable(), "--input-type=module", "--eval", @@ -2066,7 +2057,7 @@ function elevatedLogRotationEnvironment(): NodeJS.ProcessEnv { const allowed = [ "PATH", "HOME", - ...ELEVATED_LOG_ROTATION_FORWARDED_ENVIRONMENT, + ...elevatedLogRotationForwardedEnvironment(), ] as const; const environment: NodeJS.ProcessEnv = {}; // Keep sudo environment preservation narrow: runtime lookup, locale, and state paths. diff --git a/backend/src/services/pullRequestPreviewHost.ts b/backend/src/services/pullRequestPreviewHost.ts index 9a5e89a06..c16c1a984 100644 --- a/backend/src/services/pullRequestPreviewHost.ts +++ b/backend/src/services/pullRequestPreviewHost.ts @@ -15,6 +15,7 @@ import { rmSync, writeFileSync, } from "node:fs"; +import os from "node:os"; import path from "node:path"; import { getPersistedGatewayToken } from "../auth.ts"; @@ -39,9 +40,10 @@ const PREVIEW_READY_POLL_MS = 500; const MAX_COMMAND_BUFFER = 10 * 1024 * 1024; const MAX_PREVIEW_RECORD_BYTES = 256 * 1024; const COMMIT_PATTERN = /^[\da-f]{40}$/u; -const UNIT_NAME_PATTERN = /^[A-Za-z0-9_.@-]+\.service$/u; const DEFAULT_GATEWAY_URL = "ws://127.0.0.1:18789"; const DEFAULT_GATEWAY_PROXY_PORT = 18_790; +const DEFAULT_PREVIEW_BACKEND_PORT = 3101; +const DEFAULT_PREVIEW_FRONTEND_PORT = 5173; const MANAGED_STATE_DIRECTORY_PATTERN = /^pr-([1-9]\d*)$/u; const PREVIEW_REFERENCE = "refs/mira-dashboard/previews/active"; const PREVIEW_GATEWAY_PROXY_ENTRYPOINT = "pullRequestPreviewGatewayProxy.js"; @@ -110,6 +112,7 @@ export interface PullRequestPreviewConfig { managedWorktreePath: string; openClawConfigSource?: string; previewRoot: string; + projectRoot: string; recentAuthMinutes?: string; releaseSource?: string; sessionIdleMinutes?: string; @@ -180,31 +183,6 @@ function absoluteNonRootPath(name: string, value: string): string { return resolved; } -function optionalAbsoluteNonRootPath( - name: string, - value: string | undefined -): string | undefined { - const configured = value?.trim(); - return configured ? absoluteNonRootPath(name, configured) : undefined; -} - -function configuredPort( - name: string, - value: string | undefined, - fallback: number -): number { - const normalized = value?.trim(); - if (!normalized) return fallback; - if (!/^\d+$/u.test(normalized)) { - throw new TypeError(`${name} must be an integer between 1 and 65535`); - } - const port = Number(normalized); - if (!Number.isSafeInteger(port) || port < 1 || port > 65_535) { - throw new TypeError(`${name} must be an integer between 1 and 65535`); - } - return port; -} - function configuredGatewayUrl(value: string | undefined): string | undefined { const configured = value?.trim(); if (!configured) return undefined; @@ -212,7 +190,7 @@ function configuredGatewayUrl(value: string | undefined): string | undefined { try { url = new URL(configured); } catch { - throw new TypeError("MIRA_DASHBOARD_PREVIEW_GATEWAY_URL must be a valid URL"); + throw new TypeError("OPENCLAW_GATEWAY_URL must be a valid URL"); } if ( !["ws:", "wss:"].includes(url.protocol) || @@ -221,7 +199,7 @@ function configuredGatewayUrl(value: string | undefined): string | undefined { url.hash ) { throw new TypeError( - "MIRA_DASHBOARD_PREVIEW_GATEWAY_URL must be ws:// or wss:// without credentials or a fragment" + "OPENCLAW_GATEWAY_URL must be ws:// or wss:// without credentials or a fragment" ); } return url.href; @@ -239,33 +217,6 @@ function optionalEnvironmentValue( return configured; } -function resolveExecutable( - value: string | undefined, - fallback: string, - searchPath: string | undefined = process.env.PATH -): string { - const configured = value?.trim(); - const resolved = - configured || - Bun.which(fallback, { PATH: searchPath }) || - (fallback === "bun" ? process.execPath : undefined); - if (!resolved || !path.isAbsolute(resolved)) { - throw new TypeError(`${fallback} executable must resolve to an absolute path`); - } - return path.resolve(resolved); -} - -function gitCommonDirectory( - dashboardRoot: string, - configured: string | undefined -): string { - const explicit = optionalAbsoluteNonRootPath( - "MIRA_DASHBOARD_PREVIEW_GIT_COMMON_DIR", - configured - ); - return explicit || path.join(dashboardRoot, ".git"); -} - function defaultGatewayProxyEntrypoint(): string { const runtimeEntrypoint = process.argv[1]; if (runtimeEntrypoint && path.isAbsolute(runtimeEntrypoint)) { @@ -285,127 +236,40 @@ export function resolvePullRequestPreviewConfig( environment: Record = process.env ): PullRequestPreviewConfig { const projectPaths = resolveDashboardProjectPaths(environment); - const dashboardRoot = absoluteNonRootPath( - "MIRA_DASHBOARD_ROOT", - environment.MIRA_DASHBOARD_ROOT?.trim() || projectPaths.productionCheckoutRoot - ); - const previewRoot = absoluteNonRootPath( - "MIRA_DASHBOARD_PREVIEW_ROOT", - environment.MIRA_DASHBOARD_PREVIEW_ROOT?.trim() || - projectPaths.developmentPreviewStateRoot - ); - const managedWorktreePath = absoluteNonRootPath( - "MIRA_DASHBOARD_PREVIEW_WORKTREE_PATH", - environment.MIRA_DASHBOARD_PREVIEW_WORKTREE_PATH?.trim() || - projectPaths.developmentPreviewRoot - ); - if ( - [dashboardRoot, previewRoot].some( - (protectedRoot) => - managedWorktreePath === protectedRoot || - isPathStrictlyWithin(managedWorktreePath, protectedRoot) || - isPathStrictlyWithin(protectedRoot, managedWorktreePath) - ) - ) { - throw new TypeError( - "MIRA_DASHBOARD_PREVIEW_WORKTREE_PATH must not overlap Dashboard source or preview state" - ); - } - const frontendPort = configuredPort( - "MIRA_DASHBOARD_PREVIEW_FRONTEND_PORT", - environment.MIRA_DASHBOARD_PREVIEW_FRONTEND_PORT, - 5173 - ); - const backendPort = configuredPort( - "MIRA_DASHBOARD_PREVIEW_BACKEND_PORT", - environment.MIRA_DASHBOARD_PREVIEW_BACKEND_PORT, - 3101 - ); - const gatewayProxyPort = configuredPort( - "MIRA_DASHBOARD_PREVIEW_GATEWAY_PROXY_PORT", - environment.MIRA_DASHBOARD_PREVIEW_GATEWAY_PROXY_PORT, - DEFAULT_GATEWAY_PROXY_PORT - ); - if (new Set([frontendPort, backendPort, gatewayProxyPort]).size !== 3) { - throw new TypeError( - "Dashboard preview frontend, backend, and Gateway proxy ports must differ" - ); - } - const unitName = environment.MIRA_DASHBOARD_PREVIEW_UNIT?.trim() || PREVIEW_UNIT; - if (!UNIT_NAME_PATTERN.test(unitName)) { - throw new TypeError( - "MIRA_DASHBOARD_PREVIEW_UNIT must be a valid .service unit name" - ); - } - const gatewayProxyUnitName = - environment.MIRA_DASHBOARD_PREVIEW_GATEWAY_PROXY_UNIT?.trim() || - PREVIEW_GATEWAY_PROXY_UNIT; - if ( - gatewayProxyUnitName === unitName || - !UNIT_NAME_PATTERN.test(gatewayProxyUnitName) - ) { - throw new TypeError( - "MIRA_DASHBOARD_PREVIEW_GATEWAY_PROXY_UNIT must be a distinct valid .service unit name" - ); - } - const allowedAuthors = resolvePullRequestPreviewAllowedAuthors( - environment.MIRA_DASHBOARD_PREVIEW_ALLOWED_AUTHORS - ); - const openClawSourceRoot = optionalAbsoluteNonRootPath( - "MIRA_DASHBOARD_PREVIEW_OPENCLAW_SOURCE_ROOT", - environment.MIRA_DASHBOARD_PREVIEW_OPENCLAW_SOURCE_ROOT?.trim() || - "/home/ubuntu/.openclaw" + const dashboardRoot = projectPaths.productionCheckoutRoot; + const previewRoot = projectPaths.developmentPreviewStateRoot; + const managedWorktreePath = projectPaths.developmentPreviewRoot; + const openClawSourceRoot = absoluteNonRootPath( + "OPENCLAW_HOME", + environment.OPENCLAW_HOME?.trim() || + path.join(environment.HOME?.trim() || os.homedir(), ".openclaw") ); + const allowedAuthors = resolvePullRequestPreviewAllowedAuthors(); return { allowedAuthors, - backendPort, - bunExecutable: resolveExecutable( - environment.BUN_BINARY, - "bun", - environment.PATH ?? process.env.PATH - ), + backendPort: DEFAULT_PREVIEW_BACKEND_PORT, + bunExecutable: absoluteNonRootPath("Bun executable", process.execPath), dashboardRoot, - databaseTemplate: optionalAbsoluteNonRootPath( - "MIRA_DASHBOARD_PREVIEW_DB_TEMPLATE", - environment.MIRA_DASHBOARD_PREVIEW_DB_TEMPLATE?.trim() || - projectPaths.productionDatabasePath - ), - frontendPort, - gatewayProxyEntrypoint: absoluteNonRootPath( - "MIRA_DASHBOARD_PREVIEW_GATEWAY_PROXY_ENTRYPOINT", - environment.MIRA_DASHBOARD_PREVIEW_GATEWAY_PROXY_ENTRYPOINT?.trim() || - defaultGatewayProxyEntrypoint() - ), + databaseTemplate: projectPaths.productionDatabasePath, + frontendPort: DEFAULT_PREVIEW_FRONTEND_PORT, + gatewayProxyEntrypoint: defaultGatewayProxyEntrypoint(), gatewayProxyIdentityFile: path.join(previewRoot, "gateway-proxy-identity.json"), - gatewayProxyPort, - gatewayProxyUnitName, - gatewayTokenFile: - optionalAbsoluteNonRootPath( - "MIRA_DASHBOARD_PREVIEW_GATEWAY_TOKEN_FILE", - environment.MIRA_DASHBOARD_PREVIEW_GATEWAY_TOKEN_FILE - ) || path.join(previewRoot, "gateway.token"), + gatewayProxyPort: DEFAULT_GATEWAY_PROXY_PORT, + gatewayProxyUnitName: PREVIEW_GATEWAY_PROXY_UNIT, + gatewayTokenFile: path.join(previewRoot, "gateway.token"), gatewayUpstreamTokenFile: path.join(previewRoot, "gateway-upstream.token"), gatewayUrl: - configuredGatewayUrl(environment.MIRA_DASHBOARD_PREVIEW_GATEWAY_URL) || - DEFAULT_GATEWAY_URL, - gitCommonDirectory: gitCommonDirectory( - dashboardRoot, - environment.MIRA_DASHBOARD_PREVIEW_GIT_COMMON_DIR - ), + configuredGatewayUrl(environment.OPENCLAW_GATEWAY_URL) || DEFAULT_GATEWAY_URL, + gitCommonDirectory: path.join(dashboardRoot, ".git"), managedWorktreePath, - openClawConfigSource: openClawSourceRoot - ? path.join(openClawSourceRoot, "openclaw.json") - : undefined, + openClawConfigSource: path.join(openClawSourceRoot, "openclaw.json"), previewRoot, + projectRoot: projectPaths.projectRoot, recentAuthMinutes: optionalEnvironmentValue( "MIRA_DASHBOARD_RECENT_AUTH_MINUTES", environment.MIRA_DASHBOARD_RECENT_AUTH_MINUTES ), - releaseSource: optionalAbsoluteNonRootPath( - "MIRA_DASHBOARD_PREVIEW_RELEASES_SOURCE", - environment.MIRA_DASHBOARD_PREVIEW_RELEASES_SOURCE?.trim() || - projectPaths.productionReleasesRoot - ), + releaseSource: projectPaths.productionReleasesRoot, sessionIdleMinutes: optionalEnvironmentValue( "MIRA_DASHBOARD_SESSION_IDLE_MINUTES", environment.MIRA_DASHBOARD_SESSION_IDLE_MINUTES @@ -415,10 +279,8 @@ export function resolvePullRequestPreviewConfig( environment.MIRA_DASHBOARD_WEBAUTHN_RP_ID ), stateFile: path.join(previewRoot, PREVIEW_RECORD_FILE), - unitName, - workspaceSource: openClawSourceRoot - ? path.join(openClawSourceRoot, "workspace") - : undefined, + unitName: PREVIEW_UNIT, + workspaceSource: path.join(openClawSourceRoot, "workspace"), }; } @@ -701,6 +563,7 @@ function safeInstallEnvironment( ensureRealDirectory(cacheDirectory); environment.BUN_INSTALL_CACHE_DIR = cacheDirectory; environment.HOME = installerHome; + environment.MIRA_DASHBOARD_PROJECT_ROOT = config.projectRoot; return environment; } @@ -1151,10 +1014,9 @@ async function preparePreviewState( MIRA_DASHBOARD_DEV_GATEWAY_TOKEN_FILE: config.gatewayTokenFile, MIRA_DASHBOARD_DEV_GATEWAY_URL: previewGatewayProxyUrl(config), MIRA_DASHBOARD_DEV_PUBLIC_ORIGIN: publicOrigin, - MIRA_DASHBOARD_DEV_STATE_OWNER: `managed-pr-${number}`, MIRA_DASHBOARD_DEV_STATE_ROOT: stateRoot, ...(config.sourceWebAuthnRpId && { - MIRA_DASHBOARD_DEV_SOURCE_WEBAUTHN_RP_ID: config.sourceWebAuthnRpId, + MIRA_DASHBOARD_WEBAUTHN_RP_ID: config.sourceWebAuthnRpId, }), ...(config.openClawConfigSource && { MIRA_DASHBOARD_DEV_OPENCLAW_CONFIG_SOURCE: config.openClawConfigSource, @@ -1200,12 +1062,11 @@ function sandboxDirectories(...targets: string[]): string[] { /** Builds the filesystem-isolated process used by the transient preview unit. */ export function buildPullRequestPreviewSandboxCommand(input: { config: PullRequestPreviewConfig; - number: number; publicOrigin: string; stateRoot: string; worktreePath: string; }): string[] { - const { config, number, publicOrigin, stateRoot, worktreePath } = input; + const { config, publicOrigin, stateRoot, worktreePath } = input; const arguments_ = [ "bwrap", "--unshare-all", @@ -1277,15 +1138,12 @@ export function buildPullRequestPreviewSandboxCommand(input: { "PATH", "/usr/bin:/bin", "--setenv", - "MIRA_DASHBOARD_DEV_BACKEND_HOST", - "127.0.0.1", + "MIRA_DASHBOARD_PROJECT_ROOT", + config.projectRoot, "--setenv", "MIRA_DASHBOARD_DEV_BACKEND_PORT", String(config.backendPort), "--setenv", - "MIRA_DASHBOARD_DEV_FRONTEND_HOST", - "127.0.0.1", - "--setenv", "MIRA_DASHBOARD_DEV_FRONTEND_PORT", String(config.frontendPort), "--setenv", @@ -1301,9 +1159,6 @@ export function buildPullRequestPreviewSandboxCommand(input: { "MIRA_DASHBOARD_DEV_PUBLIC_ORIGIN", publicOrigin, "--setenv", - "MIRA_DASHBOARD_DEV_STATE_OWNER", - `managed-pr-${number}`, - "--setenv", "MIRA_DASHBOARD_DEV_STATE_ROOT", stateRoot ); @@ -1317,7 +1172,7 @@ export function buildPullRequestPreviewSandboxCommand(input: { if (config.sourceWebAuthnRpId) { arguments_.push( "--setenv", - "MIRA_DASHBOARD_DEV_SOURCE_WEBAUTHN_RP_ID", + "MIRA_DASHBOARD_WEBAUTHN_RP_ID", config.sourceWebAuthnRpId ); } @@ -1829,7 +1684,6 @@ export async function startPullRequestPreview( ); const sandboxCommand = buildPullRequestPreviewSandboxCommand({ config, - number, publicOrigin, stateRoot, worktreePath: preparedWorktree, diff --git a/backend/src/services/pullRequestPreviewPolicy.ts b/backend/src/services/pullRequestPreviewPolicy.ts index 06f219d5e..7f5d1f79c 100644 --- a/backend/src/services/pullRequestPreviewPolicy.ts +++ b/backend/src/services/pullRequestPreviewPolicy.ts @@ -1,21 +1,8 @@ -const DEFAULT_ALLOWED_AUTHORS = "mira-2026,rajohan"; +const PULL_REQUEST_PREVIEW_ALLOWED_AUTHORS = ["mira-2026", "rajohan"] as const; /** Resolves the single backend-owned allowlist used by preview auth and UI metadata. */ -export function resolvePullRequestPreviewAllowedAuthors( - configuredValue: string | undefined -): ReadonlySet { - const allowedAuthors = new Set( - (configuredValue === undefined ? DEFAULT_ALLOWED_AUTHORS : configuredValue) - .split(",") - .map((author) => author.trim().toLowerCase()) - .filter(Boolean) - ); - if (allowedAuthors.size === 0) { - throw new TypeError( - "MIRA_DASHBOARD_PREVIEW_ALLOWED_AUTHORS must contain at least one author" - ); - } - return allowedAuthors; +export function resolvePullRequestPreviewAllowedAuthors(): ReadonlySet { + return new Set(PULL_REQUEST_PREVIEW_ALLOWED_AUTHORS); } /** Checks one GitHub login against the normalized backend preview allowlist. */ diff --git a/backend/src/services/pullRequestPreviews.ts b/backend/src/services/pullRequestPreviews.ts index c4310f013..1f419c189 100644 --- a/backend/src/services/pullRequestPreviews.ts +++ b/backend/src/services/pullRequestPreviews.ts @@ -149,15 +149,24 @@ function previewFromExecution(execution: JobExecution): PullRequestPreviewStatus return parsePullRequestPreviewStatus(output.preview); } +function unavailablePreviewControls(): PullRequestPreviewStatus | undefined { + if ( + process.env.NODE_ENV === "production" || + process.env.MIRA_DASHBOARD_DEV_SAFE_MODE !== "1" + ) { + return; + } + return { + controlsAvailable: false, + message: PREVIEW_CONTROLS_UNAVAILABLE_MESSAGE, + status: "stopped", + }; +} + /** Reads the current preview state, including queued lifecycle transitions. */ export async function getPullRequestPreviewStatus(): Promise { - if (process.env.MIRA_DASHBOARD_DEV_SAFE_MODE === "1") { - return { - controlsAvailable: false, - message: PREVIEW_CONTROLS_UNAVAILABLE_MESSAGE, - status: "stopped", - }; - } + const unavailable = unavailablePreviewControls(); + if (unavailable) return unavailable; const preview = await readPullRequestPreviewStatus(); const activeExecution = listJobExecutions(200).find( (execution) => @@ -193,7 +202,12 @@ export async function getPullRequestPreviewStatus(): Promise { - if (process.env.MIRA_DASHBOARD_DEV_SAFE_MODE === "1") return; + if ( + process.env.NODE_ENV !== "production" && + process.env.MIRA_DASHBOARD_DEV_SAFE_MODE === "1" + ) { + return; + } try { if ( listJobExecutions(200).some( @@ -241,6 +255,8 @@ export async function reconcileClosedPullRequestPreview( export async function prepareAndStartPullRequestPreview( number: number ): Promise { + const unavailable = unavailablePreviewControls(); + if (unavailable) return unavailable; const candidate = await findPullRequest(number); const current = await getPullRequestPreviewStatus(); if ( @@ -289,6 +305,8 @@ export async function prepareAndStartPullRequestPreview( export async function prepareAndStopPullRequestPreview( number?: number ): Promise { + const unavailable = unavailablePreviewControls(); + if (unavailable) return unavailable; const execution = enqueueJobExecution({ actionKey: "dashboard.preview.stop", displayName: number ? `Stop PR #${number} preview` : "Stop PR preview", diff --git a/backend/src/services/pullRequests.ts b/backend/src/services/pullRequests.ts index 73a492d8e..7b3c701b1 100644 --- a/backend/src/services/pullRequests.ts +++ b/backend/src/services/pullRequests.ts @@ -98,29 +98,13 @@ const PASSING_CHECK_VALUES = new Set(["success", "successful", "neutral", "skipp const OPINIONATED_REVIEW_STATES = new Set(["APPROVED", "CHANGES_REQUESTED", "DISMISSED"]); const ACTIVE_DEPLOYMENT_STATUSES = new Set(["building", "verifying"]); const FULL_COMMIT_SHA_PATTERN = /^[\da-f]{40}$/u; -const BUN_EXECUTABLE = process.env.BUN_BINARY || "bun"; const publicPullRequestCache: { failure?: { expiresAt: number; message: string }; value?: { expiresAt: number; pullRequests: PullRequestSummary[] }; } = {}; -function resolveExecutableFromPath(executable: string): string | undefined { - if (path.isAbsolute(executable)) { - return executable; - } - if (executable.includes(path.sep)) { - return path.resolve(executable); - } - - return Bun.which(executable, { PATH: process.env.PATH }) ?? undefined; -} - function resolveBunExecutable(): string { - const resolved = resolveExecutableFromPath(BUN_EXECUTABLE); - if (resolved) { - return resolved; - } - return BUN_EXECUTABLE === "bun" ? process.execPath : BUN_EXECUTABLE; + return process.execPath; } export function getResolvedRoots() { @@ -131,17 +115,21 @@ export function getResolvedRoots() { } function getDashboardRoot(): string { - return resolveConfiguredRoot( - "MIRA_DASHBOARD_ROOT", - resolveDashboardProjectPaths().productionCheckoutRoot - ); + return process.env.NODE_ENV === "production" + ? resolveDashboardProjectPaths().productionCheckoutRoot + : resolveConfiguredRoot( + "MIRA_DASHBOARD_ROOT", + resolveDashboardProjectPaths().productionCheckoutRoot + ); } function getDashboardWorktreeRoot(): string { - return resolveConfiguredRoot( - "MIRA_DASHBOARD_WORKTREE_ROOT", - resolveDashboardProjectPaths().developmentWorktreeRoot - ); + return process.env.NODE_ENV === "production" + ? resolveDashboardProjectPaths().developmentWorktreeRoot + : resolveConfiguredRoot( + "MIRA_DASHBOARD_WORKTREE_ROOT", + resolveDashboardProjectPaths().developmentWorktreeRoot + ); } /** Represents command result. */ @@ -919,14 +907,9 @@ function buildReviewCommandEnvironment(): NodeJS.ProcessEnv { return buildGithubCommandEnvironment(githubToken); } -/** Returns the configured reviewer author. */ -function reviewerAuthor(): string { - return process.env.RAJOHAN_GITHUB_USERNAME?.trim() || DEFAULT_REVIEWER_AUTHOR; -} - /** Returns whether the configured reviewer has approved the pull request. */ function hasReviewerApproval(pr: PullRequestSummary): boolean { - const author = reviewerAuthor(); + const author = DEFAULT_REVIEWER_AUTHOR; const reviews = ( pr.latestOpinionatedReviews?.nodes?.length ? pr.latestOpinionatedReviews.nodes @@ -954,7 +937,7 @@ function isPullRequestReviewApproved(pr: PullRequestSummary): boolean { /** Returns whether the configured reviewer can approve the pull request. */ function canReviewerApprove(pr: PullRequestSummary): boolean { return ( - pr.author?.login !== reviewerAuthor() && + pr.author?.login !== DEFAULT_REVIEWER_AUTHOR && !pr.isDraft && !isPullRequestReviewApproved(pr) ); @@ -965,9 +948,7 @@ function normalizePullRequest(pr: PullRequestSummary): PullRequestSummary { const rest = { ...pr }; delete rest.latestOpinionatedReviews; delete rest.reviews; - const previewAllowedAuthors = resolvePullRequestPreviewAllowedAuthors( - process.env.MIRA_DASHBOARD_PREVIEW_ALLOWED_AUTHORS - ); + const previewAllowedAuthors = resolvePullRequestPreviewAllowedAuthors(); return { ...rest, @@ -1342,6 +1323,7 @@ async function runGhJsonLines( /** Lists open pull requests targeting the dashboard production branch. */ export async function listDashboardPullRequests(): Promise { if ( + process.env.NODE_ENV !== "production" && process.env.MIRA_DASHBOARD_DEV_SAFE_MODE === "1" && !configuredGithubReadToken() ) { @@ -1672,7 +1654,7 @@ function validateDashboardPrForApproval(pr: PullRequestSummary): void { /** Validates a pull request can receive Rajohan's review approval. */ function validateDashboardPrForReviewApproval(pr: PullRequestSummary): void { validateDashboardPr(pr); - if (pr.author?.login === reviewerAuthor()) { + if (pr.author?.login === DEFAULT_REVIEWER_AUTHOR) { throw new Error("Rajohan cannot approve his own pull request"); } if (isPullRequestReviewApproved(pr)) { @@ -1928,13 +1910,11 @@ try { ].join(" "); } -function releaseLifecycleInvocation( - releasesRoot: string, - lifecycleCommand: string -): string { +function releaseLifecycleInvocation(lifecycleCommand: string): string { return [ - `MIRA_DASHBOARD_RELEASES_ROOT=${shellQuote(releasesRoot)}`, - `MIRA_DASHBOARD_DB_PATH=${shellQuote(getMiraDatabasePath())}`, + `MIRA_DASHBOARD_PROJECT_ROOT=${shellQuote( + resolveDashboardProjectPaths().projectRoot + )}`, "NODE_ENV=production", shellQuote(resolveBunExecutable()), shellQuote(lifecycleCommand), @@ -2085,11 +2065,9 @@ async function scheduleReleaseCutover( "releaseLifecycle.js" ); const activationLifecycleEnvironment = releaseLifecycleInvocation( - releasesRoot, activationLifecycleCommand ); const guardedLifecycleEnvironment = releaseLifecycleInvocation( - releasesRoot, guardedLifecycleCommand ); const restoreCommand = isNewActivation @@ -2206,10 +2184,7 @@ async function scheduleReleaseRollback( "dist", "releaseLifecycle.js" ); - const lifecycleEnvironment = releaseLifecycleInvocation( - releasesRoot, - lifecycleCommand - ); + const lifecycleEnvironment = releaseLifecycleInvocation(lifecycleCommand); const targetShort = targetCommit.slice(0, 8); const originalShort = originalCommit.slice(0, 8); const okJob: DeploymentJob = { @@ -2331,6 +2306,7 @@ function didScheduleOrphanedReleaseCutoverRecovery( const script = [ "sleep 1", ...releaseCutoverShellFunctions(), + `project_root=${shellQuote(resolveDashboardProjectPaths().projectRoot)}`, `releases_root=${shellQuote(releasesRoot)}`, `candidate_commit=${shellQuote(candidateCommit)}`, `recovery_mode=${shellQuote(recoveryMode)}`, @@ -2364,14 +2340,12 @@ function didScheduleOrphanedReleaseCutoverRecovery( ' [ -f "$activation_lifecycle" ] && [ ! -L "$activation_lifecycle" ]', "}", "run_activation_lifecycle() {", - ' MIRA_DASHBOARD_RELEASES_ROOT="$releases_root" \\', - ` MIRA_DASHBOARD_DB_PATH=${shellQuote(getMiraDatabasePath())} \\`, + ' MIRA_DASHBOARD_PROJECT_ROOT="$project_root" \\', " NODE_ENV=production \\", ' "$bun_executable" "$activation_lifecycle" "$@"', "}", "run_candidate_lifecycle() {", - ' MIRA_DASHBOARD_RELEASES_ROOT="$releases_root" \\', - ` MIRA_DASHBOARD_DB_PATH=${shellQuote(getMiraDatabasePath())} \\`, + ' MIRA_DASHBOARD_PROJECT_ROOT="$project_root" \\', " NODE_ENV=production \\", ' "$bun_executable" "$candidate_lifecycle" "$@"', "}", @@ -2513,7 +2487,6 @@ async function runDeploymentJob( signal: options.signal, timeoutMs: options.timeoutMs, }), - databasePath: getMiraDatabasePath(), onProgress: () => { currentJob = refreshDeploymentHeartbeat(currentJob); }, diff --git a/backend/test/developmentStack.test.ts b/backend/test/developmentStack.test.ts index 52f05935d..a2db9dd89 100644 --- a/backend/test/developmentStack.test.ts +++ b/backend/test/developmentStack.test.ts @@ -484,16 +484,11 @@ describe("development stack", () => { const environment = developmentBackendEnvironment(config); expect(environment).toMatchObject({ - BUN_BINARY: process.execPath, - MIRA_DASHBOARD_COOKIE_NAMESPACE: "mira_dashboard_dev_5173", MIRA_DASHBOARD_DB_PATH: config.databasePath, + MIRA_DASHBOARD_DEV_COOKIE_NAMESPACE: "mira_dashboard_dev_5173", MIRA_DASHBOARD_DEV_SAFE_MODE: "1", - MIRA_DASHBOARD_DISABLE_SCHEDULER: "0", - MIRA_DASHBOARD_EXECUTION_ROLE: "combined", MIRA_DASHBOARD_FRONTEND_PATH: root, - MIRA_DASHBOARD_JOB_PROFILE: "isolated", MIRA_DASHBOARD_LOGS_ROOT: path.join(stateRoot, "logs"), - MIRA_DASHBOARD_METRICS_DISK_PATH: root, OPENCLAW_GATEWAY_TOKEN: "development-gateway-token", OPENCLAW_GATEWAY_URL: "ws://127.0.0.1:18789/", }); @@ -761,7 +756,6 @@ describe("development stack", () => { cwd: path.join(root, "backend"), env: expect.objectContaining({ MIRA_DASHBOARD_DEV_SAFE_MODE: "1", - MIRA_DASHBOARD_JOB_PROFILE: "isolated", }), }); expect(spawnSpy.mock.calls[1]?.[0]).toEqual([ diff --git a/backend/test/dockerUpdater.test.ts b/backend/test/dockerUpdater.test.ts index bc08e0774..db39ea55e 100644 --- a/backend/test/dockerUpdater.test.ts +++ b/backend/test/dockerUpdater.test.ts @@ -462,7 +462,6 @@ describe("Docker updater tag patterns", () => { rememberEnvironment("MIRA_DOCKER_BIN"); rememberEnvironment("MIRA_DOCKER_COMPOSE_WRAPPER"); rememberEnvironment("MIRA_DOCKER_UPDATER_SKIP_REGISTRY"); - rememberEnvironment("MIRA_DOCKER_UPDATER_PLATFORM"); const appsRoot = createTemporaryRoot("mira-docker-updater-lock-abort-"); const appRoot = path.join(appsRoot, "unit-lock-abort-app"); const composePath = path.join(appRoot, "compose.yaml"); @@ -495,7 +494,6 @@ describe("Docker updater tag patterns", () => { process.env.MIRA_DOCKER_BIN = "docker"; process.env.MIRA_DOCKER_COMPOSE_WRAPPER = composeWrapper; delete process.env.MIRA_DOCKER_UPDATER_SKIP_REGISTRY; - process.env.MIRA_DOCKER_UPDATER_PLATFORM = "linux/amd64"; const fetchSpy = jest.spyOn(globalThis, "fetch").mockImplementation((async ( input: Request | string | URL @@ -633,7 +631,6 @@ describe("Docker updater tag patterns", () => { rememberEnvironment("MIRA_DOCKER_BIN"); rememberEnvironment("MIRA_DOCKER_COMPOSE_WRAPPER"); rememberEnvironment("MIRA_DOCKER_UPDATER_SKIP_REGISTRY"); - rememberEnvironment("MIRA_DOCKER_UPDATER_PLATFORM"); const appsRoot = createTemporaryRoot("mira-docker-updater-apply-"); const appRoot = path.join(appsRoot, "unit-apply-app"); const composePath = path.join(appRoot, "compose.yaml"); @@ -644,6 +641,7 @@ describe("Docker updater tag patterns", () => { "services:", " web:", " image: ghcr.io/unit/web:1.0.0", + " platform: linux/amd64", " labels:", " mira.updater.enabled: 'true'", " mira.updater.autoUpdate: 'false'", @@ -657,7 +655,6 @@ describe("Docker updater tag patterns", () => { process.env.MIRA_DOCKER_BIN = "docker"; process.env.MIRA_DOCKER_COMPOSE_WRAPPER = path.join(appsRoot, "compose-wrapper"); delete process.env.MIRA_DOCKER_UPDATER_SKIP_REGISTRY; - process.env.MIRA_DOCKER_UPDATER_PLATFORM = "linux/amd64"; const fetchSpy = jest.spyOn(globalThis, "fetch").mockImplementation((async ( input: Request | string | URL ) => { @@ -753,7 +750,6 @@ describe("Docker updater tag patterns", () => { rememberEnvironment("MIRA_DOCKER_BIN"); rememberEnvironment("MIRA_DOCKER_COMPOSE_WRAPPER"); rememberEnvironment("MIRA_DOCKER_UPDATER_SKIP_REGISTRY"); - rememberEnvironment("MIRA_DOCKER_UPDATER_PLATFORM"); const appsRoot = createTemporaryRoot("mira-docker-updater-formatting-"); const appRoot = path.join(appsRoot, "unit-formatting-app"); const composePath = path.join(appRoot, "compose.yaml"); @@ -787,7 +783,6 @@ describe("Docker updater tag patterns", () => { process.env.MIRA_DOCKER_BIN = "docker"; process.env.MIRA_DOCKER_COMPOSE_WRAPPER = path.join(appsRoot, "compose-wrapper"); delete process.env.MIRA_DOCKER_UPDATER_SKIP_REGISTRY; - process.env.MIRA_DOCKER_UPDATER_PLATFORM = "linux/amd64"; const fetchSpy = jest.spyOn(globalThis, "fetch").mockImplementation((async ( input: Request | string | URL ) => { @@ -837,7 +832,6 @@ describe("Docker updater tag patterns", () => { rememberEnvironment("MIRA_DOCKER_BIN"); rememberEnvironment("MIRA_DOCKER_COMPOSE_WRAPPER"); rememberEnvironment("MIRA_DOCKER_UPDATER_SKIP_REGISTRY"); - rememberEnvironment("MIRA_DOCKER_UPDATER_PLATFORM"); const appsRoot = createTemporaryRoot("mira-docker-updater-complex-scalar-"); const appRoot = path.join(appsRoot, "unit-complex-scalar-app"); const composePath = path.join(appRoot, "compose.yaml"); @@ -872,7 +866,6 @@ describe("Docker updater tag patterns", () => { process.env.MIRA_DOCKER_BIN = "docker"; process.env.MIRA_DOCKER_COMPOSE_WRAPPER = path.join(appsRoot, "compose-wrapper"); delete process.env.MIRA_DOCKER_UPDATER_SKIP_REGISTRY; - process.env.MIRA_DOCKER_UPDATER_PLATFORM = "linux/amd64"; const fetchSpy = jest.spyOn(globalThis, "fetch").mockImplementation((async ( input: Request | string | URL ) => { diff --git a/backend/test/jobExecutionQueue.test.ts b/backend/test/jobExecutionQueue.test.ts index 90c219199..f68ab4f96 100644 --- a/backend/test/jobExecutionQueue.test.ts +++ b/backend/test/jobExecutionQueue.test.ts @@ -480,8 +480,7 @@ printf 'LoadState=loaded\nActiveState=active\n' "docker", ["exec", "worker", "sh", "-c", 'printf "%s" "$JOB_COMMAND"'], { - MIRA_DASHBOARD_ENABLE_JOB_SCOPES: "1", - MIRA_DASHBOARD_JOB_SCOPE_OWNER: "mira-dashboard-worker.service", + NODE_ENV: "production", } ) ); diff --git a/backend/test/pullRequestPreview.test.ts b/backend/test/pullRequestPreview.test.ts index c1c55a4a6..940b48df3 100644 --- a/backend/test/pullRequestPreview.test.ts +++ b/backend/test/pullRequestPreview.test.ts @@ -32,7 +32,7 @@ import { stopPullRequestPreview, } from "../src/services/pullRequestPreviewHost.ts"; import { - getPullRequestPreviewStatus as getManagedPullRequestPreviewStatus, + getPullRequestPreviewStatus as getDeliveryPullRequestPreviewStatus, prepareAndStartPullRequestPreview, prepareAndStopPullRequestPreview, reconcileClosedPullRequestPreview, @@ -69,7 +69,7 @@ function previewConfig(root: string): PullRequestPreviewConfig { return { allowedAuthors: new Set(["mira-2026", "rajohan"]), backendPort: 3101, - bunExecutable: "/home/ubuntu/.bun/bin/bun", + bunExecutable: process.execPath, dashboardRoot: path.join(root, "dashboard"), frontendPort: 5173, gatewayProxyEntrypoint: path.resolve( @@ -89,6 +89,7 @@ function previewConfig(root: string): PullRequestPreviewConfig { gitCommonDirectory: path.join(root, "dashboard", ".git"), managedWorktreePath: path.join(root, "managed-preview"), previewRoot: path.join(root, "preview"), + projectRoot: root, stateFile: path.join(root, "preview", "active-preview.json"), unitName: "mira-dashboard-pr-preview.service", }; @@ -154,7 +155,9 @@ describe("managed pull request preview", () => { it("keeps host preview controls out of isolated Dashboard dev", async () => { const previousSafeMode = process.env.MIRA_DASHBOARD_DEV_SAFE_MODE; const statusSpy = jest.spyOn(previewHost, "getPullRequestPreviewStatus"); + const enqueueSpy = jest.spyOn(jobExecutionQueue, "enqueueJobExecution"); const executionsSpy = jest.spyOn(jobExecutionQueue, "listJobExecutions"); + const pullRequestsSpy = jest.spyOn(pullRequests, "listDashboardPullRequests"); const stateNumbersSpy = jest.spyOn( previewHost, "listManagedPullRequestPreviewStateNumbers" @@ -162,7 +165,19 @@ describe("managed pull request preview", () => { process.env.MIRA_DASHBOARD_DEV_SAFE_MODE = "1"; try { - await expect(getManagedPullRequestPreviewStatus()).resolves.toEqual({ + await expect(getDeliveryPullRequestPreviewStatus()).resolves.toEqual({ + controlsAvailable: false, + message: + "PR dev controls are available only from the production Dashboard.", + status: "stopped", + }); + await expect(prepareAndStartPullRequestPreview(342)).resolves.toEqual({ + controlsAvailable: false, + message: + "PR dev controls are available only from the production Dashboard.", + status: "stopped", + }); + await expect(prepareAndStopPullRequestPreview(342)).resolves.toEqual({ controlsAvailable: false, message: "PR dev controls are available only from the production Dashboard.", @@ -170,7 +185,9 @@ describe("managed pull request preview", () => { }); await reconcileClosedPullRequestPreview([]); expect(statusSpy).not.toHaveBeenCalled(); + expect(enqueueSpy).not.toHaveBeenCalled(); expect(executionsSpy).not.toHaveBeenCalled(); + expect(pullRequestsSpy).not.toHaveBeenCalled(); expect(stateNumbersSpy).not.toHaveBeenCalled(); } finally { if (previousSafeMode === undefined) { @@ -179,7 +196,9 @@ describe("managed pull request preview", () => { process.env.MIRA_DASHBOARD_DEV_SAFE_MODE = previousSafeMode; } statusSpy.mockRestore(); + enqueueSpy.mockRestore(); executionsSpy.mockRestore(); + pullRequestsSpy.mockRestore(); stateNumbersSpy.mockRestore(); } }); @@ -188,39 +207,28 @@ describe("managed pull request preview", () => { const root = mkdtempSync(path.join(tmpdir(), "mira-preview-bun-path-")); try { const config = resolvePullRequestPreviewConfig({ - MIRA_DASHBOARD_PREVIEW_ROOT: path.join(root, "state"), - MIRA_DASHBOARD_PREVIEW_WORKTREE_PATH: path.join(root, "managed-preview"), - MIRA_DASHBOARD_ROOT: path.join(root, "dashboard"), + HOME: root, + MIRA_DASHBOARD_PROJECT_ROOT: root, PATH: path.join(root, "empty-bin"), }); expect(config.bunExecutable).toBe(process.execPath); expect(path.isAbsolute(config.bunExecutable)).toBe(true); - expect(() => - resolvePullRequestPreviewConfig({ - BUN_BINARY: "bun", - MIRA_DASHBOARD_PREVIEW_ROOT: path.join(root, "state"), - MIRA_DASHBOARD_PREVIEW_WORKTREE_PATH: path.join( - root, - "managed-preview" - ), - MIRA_DASHBOARD_ROOT: path.join(root, "dashboard"), - PATH: path.join(root, "empty-bin"), - }) - ).toThrow("bun executable must resolve to an absolute path"); } finally { rmSync(root, { force: true, recursive: true }); } }); - it("resolves a single-slot host contract without accepting ambiguous config", () => { + it("derives the fixed single-slot host contract from the project root", () => { const root = mkdtempSync(path.join(tmpdir(), "mira-preview-config-")); try { - const rootOnlyConfig = resolvePullRequestPreviewConfig({ - BUN_BINARY: "/home/ubuntu/.bun/bin/bun", + const config = resolvePullRequestPreviewConfig({ + HOME: root, MIRA_DASHBOARD_PROJECT_ROOT: root, + OPENCLAW_GATEWAY_URL: "wss://gateway.example/ws", }); - expect(rootOnlyConfig).toMatchObject({ + expect(config).toMatchObject({ + backendPort: 3101, dashboardRoot: path.join(root, "production", "checkout"), databaseTemplate: path.join( root, @@ -228,98 +236,33 @@ describe("managed pull request preview", () => { "state", "mira-dashboard.db" ), - managedWorktreePath: path.join(root, "development", "preview"), - previewRoot: path.join(root, "development", "state", "preview"), - releaseSource: path.join(root, "production", "releases"), - }); - - const config = resolvePullRequestPreviewConfig({ - BUN_BINARY: "/home/ubuntu/.bun/bin/bun", - MIRA_DASHBOARD_PREVIEW_BACKEND_PORT: "4101", - MIRA_DASHBOARD_PREVIEW_FRONTEND_PORT: "4173", - MIRA_DASHBOARD_PREVIEW_ROOT: path.join(root, "state"), - MIRA_DASHBOARD_PREVIEW_WORKTREE_PATH: path.join(root, "managed-preview"), - MIRA_DASHBOARD_ROOT: path.join(root, "dashboard"), - }); - expect(config).toMatchObject({ - backendPort: 4101, - frontendPort: 4173, + frontendPort: 5173, gatewayProxyPort: 18_790, gatewayProxyUnitName: "mira-dashboard-pr-preview-gateway.service", - gatewayTokenFile: path.join(root, "state", "gateway.token"), - gatewayUpstreamTokenFile: path.join( + gatewayTokenFile: path.join( root, + "development", "state", - "gateway-upstream.token" + "preview", + "gateway.token" ), - gatewayUrl: "ws://127.0.0.1:18789", - managedWorktreePath: path.join(root, "managed-preview"), - previewRoot: path.join(root, "state"), + gatewayUrl: "wss://gateway.example/ws", + managedWorktreePath: path.join(root, "development", "preview"), + previewRoot: path.join(root, "development", "state", "preview"), + projectRoot: root, + releaseSource: path.join(root, "production", "releases"), unitName: "mira-dashboard-pr-preview.service", }); expect(config.allowedAuthors).toEqual(new Set(["mira-2026", "rajohan"])); expect(() => resolvePullRequestPreviewConfig({ - BUN_BINARY: "/home/ubuntu/.bun/bin/bun", - MIRA_DASHBOARD_PREVIEW_ALLOWED_AUTHORS: " , ", - MIRA_DASHBOARD_PREVIEW_WORKTREE_PATH: path.join( - root, - "managed-preview" - ), - MIRA_DASHBOARD_ROOT: path.join(root, "dashboard"), + HOME: root, + MIRA_DASHBOARD_PROJECT_ROOT: root, + OPENCLAW_GATEWAY_URL: "https://gateway.example/ws", }) ).toThrow( - "MIRA_DASHBOARD_PREVIEW_ALLOWED_AUTHORS must contain at least one author" + "OPENCLAW_GATEWAY_URL must be ws:// or wss:// without credentials or a fragment" ); - - for (const environment of [ - { - BUN_BINARY: "/home/ubuntu/.bun/bin/bun", - MIRA_DASHBOARD_PREVIEW_BACKEND_PORT: "5173", - MIRA_DASHBOARD_PREVIEW_FRONTEND_PORT: "5173", - MIRA_DASHBOARD_PREVIEW_WORKTREE_PATH: path.join( - root, - "managed-preview" - ), - MIRA_DASHBOARD_ROOT: path.join(root, "dashboard"), - }, - { - BUN_BINARY: "/home/ubuntu/.bun/bin/bun", - MIRA_DASHBOARD_PREVIEW_GATEWAY_URL: "https://gateway.example/ws", - MIRA_DASHBOARD_PREVIEW_WORKTREE_PATH: path.join( - root, - "managed-preview" - ), - MIRA_DASHBOARD_ROOT: path.join(root, "dashboard"), - }, - { - BUN_BINARY: "/home/ubuntu/.bun/bin/bun", - MIRA_DASHBOARD_PREVIEW_UNIT: "../preview.service", - MIRA_DASHBOARD_PREVIEW_WORKTREE_PATH: path.join( - root, - "managed-preview" - ), - MIRA_DASHBOARD_ROOT: path.join(root, "dashboard"), - }, - ]) { - expect(() => resolvePullRequestPreviewConfig(environment)).toThrow(); - } - for (const managedWorktreePath of [ - path.join(root, "dashboard"), - path.join(root, "dashboard", "preview"), - path.join(root, "state", "preview"), - ]) { - expect(() => - resolvePullRequestPreviewConfig({ - BUN_BINARY: "/home/ubuntu/.bun/bin/bun", - MIRA_DASHBOARD_PREVIEW_ROOT: path.join(root, "state"), - MIRA_DASHBOARD_PREVIEW_WORKTREE_PATH: managedWorktreePath, - MIRA_DASHBOARD_ROOT: path.join(root, "dashboard"), - }) - ).toThrow( - "MIRA_DASHBOARD_PREVIEW_WORKTREE_PATH must not overlap Dashboard source or preview state" - ); - } } finally { rmSync(root, { force: true, recursive: true }); } @@ -338,7 +281,6 @@ describe("managed pull request preview", () => { const stateRoot = path.join(config.previewRoot, "states", "pr-335"); const command = buildPullRequestPreviewSandboxCommand({ config, - number: 335, publicOrigin: "https://dashboard.example:5173", stateRoot, worktreePath, @@ -354,15 +296,15 @@ describe("managed pull request preview", () => { "--ro-bind", "--bind", "/etc/resolv.conf", - "MIRA_DASHBOARD_DEV_STATE_OWNER", - "managed-pr-335", + "MIRA_DASHBOARD_PROJECT_ROOT", + config.projectRoot, "MIRA_DASHBOARD_DEV_GATEWAY_TOKEN_FILE", "/run/mira-dashboard-preview/gateway.token", "MIRA_DASHBOARD_DEV_GATEWAY_URL", "ws://127.0.0.1:18790/gateway", "MIRA_DASHBOARD_DEV_HOT_RELOAD", "0", - "MIRA_DASHBOARD_DEV_SOURCE_WEBAUTHN_RP_ID", + "MIRA_DASHBOARD_WEBAUTHN_RP_ID", "dashboard.example", ]) { expect(command).toContain(value); diff --git a/backend/test/releaseDeployment.test.ts b/backend/test/releaseDeployment.test.ts index bc20dd4cd..3ce4500c5 100644 --- a/backend/test/releaseDeployment.test.ts +++ b/backend/test/releaseDeployment.test.ts @@ -11,7 +11,11 @@ import path from "node:path"; import { afterEach, describe, expect, it } from "bun:test"; -import { resolveDashboardProjectPaths } from "../src/lib/dashboardPaths.ts"; +import { + resolveDashboardProjectPaths, + resolveDashboardProjectPathsForRuntime, + resolveDashboardRuntimePath, +} from "../src/lib/dashboardPaths.ts"; import { assertManagedDashboardUnitProperties, type DashboardReleaseCommandRunner, @@ -62,14 +66,9 @@ function stagingOptions() { const sourceRoot = path.join(base, "source"); const worktreeRoot = path.join(base, "worktrees"); const releasesRoot = path.join(base, "managed"); - const databasePath = path.join(base, "state", "mira-dashboard.db"); - const openClawHome = path.join(base, "state", "openclaw-client"); mkdirSync(sourceRoot); mkdirSync(worktreeRoot); - mkdirSync(path.dirname(databasePath)); return { - databasePath, - openClawHome, releasesRoot, sourceRoot, worktreeRoot, @@ -103,6 +102,32 @@ describe("immutable release deployment", () => { MIRA_DASHBOARD_PROJECT_ROOT: "/", }) ).toThrow("Dashboard project root must be an absolute non-root path"); + expect( + resolveDashboardProjectPathsForRuntime({ + MIRA_DASHBOARD_PROJECT_ROOT: "/srv/runtime-dashboard", + NODE_ENV: "development", + })?.projectRoot + ).toBe("/srv/runtime-dashboard"); + expect( + resolveDashboardProjectPathsForRuntime({ + NODE_ENV: "development", + }) + ).toBeUndefined(); + expect( + resolveDashboardProjectPathsForRuntime({ + NODE_ENV: "production", + })?.projectRoot + ).toBe(PRODUCTION_PATHS.projectRoot); + expect( + resolveDashboardRuntimePath("/derived", "/internal", { + NODE_ENV: "production", + }) + ).toBe("/derived"); + expect( + resolveDashboardRuntimePath("/derived", "/internal", { + NODE_ENV: "development", + }) + ).toBe("/internal"); }); it("keeps shipped managed units aligned with the production contract", () => { @@ -150,7 +175,12 @@ describe("immutable release deployment", () => { "MIRA_DASHBOARD_ROOT", "MIRA_DASHBOARD_WORKTREE_ROOT", ]) { - expect(unit).not.toContain(`Environment=${obsoleteEnvironment}=`); + expect(unit).not.toMatch( + new RegExp( + String.raw`(?:^Environment=|\s)${obsoleteEnvironment}=`, + "m" + ) + ); } } }); @@ -175,7 +205,7 @@ describe("immutable release deployment", () => { arguments_: readonly string[]; command: string; cwd: string; - releaseRoot: string | undefined; + dashboardEnvironment: Record; }> = []; const progress: string[] = []; const runner: DashboardReleaseCommandRunner = async ( @@ -187,7 +217,11 @@ describe("immutable release deployment", () => { arguments_, command, cwd: commandOptions.cwd, - releaseRoot: commandOptions.environment.MIRA_DASHBOARD_RELEASE_ROOT, + dashboardEnvironment: Object.fromEntries( + Object.entries(commandOptions.environment).filter(([key]) => + key.startsWith("MIRA_DASHBOARD_") + ) + ), }); if (command === "git" && arguments_[0] === "worktree") { if (arguments_[1] === "add") { @@ -240,9 +274,11 @@ describe("immutable release deployment", () => { "Building and preflighting release", "Publishing verified immutable release", ]); - const buildReleaseRoots = new Set(calls.map(({ releaseRoot }) => releaseRoot)); - expect(buildReleaseRoots.size).toBe(1); - expect([...buildReleaseRoots][0]).toStartWith(`${options.worktreeRoot}/release-`); + for (const call of calls) { + expect(call.dashboardEnvironment).toEqual({ + MIRA_DASHBOARD_PROJECT_ROOT: resolveDashboardProjectPaths().projectRoot, + }); + } }); it("reruns database preflight when reusing a verified immutable release", async () => { @@ -278,8 +314,7 @@ describe("immutable release deployment", () => { arguments_: readonly string[]; command: string; cwd: string; - databasePath: string | undefined; - releaseRoot: string | undefined; + dashboardEnvironment: Record; }> = []; const reused = await stageDashboardRelease(COMMIT_SHA, { ...options, @@ -288,8 +323,11 @@ describe("immutable release deployment", () => { arguments_, command, cwd: commandOptions.cwd, - databasePath: commandOptions.environment.MIRA_DASHBOARD_DB_PATH, - releaseRoot: commandOptions.environment.MIRA_DASHBOARD_RELEASE_ROOT, + dashboardEnvironment: Object.fromEntries( + Object.entries(commandOptions.environment).filter(([key]) => + key.startsWith("MIRA_DASHBOARD_") + ) + ), }); return { stderr: "", stdout: "" }; }, @@ -301,8 +339,10 @@ describe("immutable release deployment", () => { arguments_: ["dist/databasePreflight.js"], command: process.execPath, cwd: path.join(reused.path, "backend"), - databasePath: options.databasePath, - releaseRoot: reused.path, + dashboardEnvironment: { + MIRA_DASHBOARD_PROJECT_ROOT: + resolveDashboardProjectPaths().projectRoot, + }, }, ]); await expect( @@ -504,31 +544,14 @@ describe("immutable release deployment", () => { it("validates paths, commits, and CLI commands", async () => { const options = stagingOptions(); - expect(() => - managedDashboardUnitContract( - options.releasesRoot, - options.databasePath, - options.openClawHome - ) - ).not.toThrow(); - expect(() => - managedDashboardUnitContract( - options.releasesRoot, - "relative.db", - options.openClawHome - ) - ).toThrow("database path must be an absolute non-root path"); - expect(() => - managedDashboardUnitContract("/", options.databasePath, options.openClawHome) - ).toThrow("releases root must be an absolute non-root path"); - const contract = managedDashboardUnitContract( - options.releasesRoot, - options.databasePath, - options.openClawHome + expect(() => managedDashboardUnitContract(options.releasesRoot)).not.toThrow(); + expect(() => managedDashboardUnitContract("/")).toThrow( + "releases root must be an absolute non-root path" ); + const contract = managedDashboardUnitContract(options.releasesRoot); const properties = [ `WorkingDirectory=${contract.releaseRoot}/backend`, - `Environment=NODE_ENV=production MIRA_DASHBOARD_EXECUTION_ROLE=web MIRA_DASHBOARD_ENABLE_JOB_SCOPES=1 MIRA_DASHBOARD_JOB_SCOPE_OWNER=mira-dashboard.service MIRA_DASHBOARD_PROJECT_ROOT=${contract.projectRoot}`, + `Environment=NODE_ENV=production MIRA_DASHBOARD_PROJECT_ROOT=${contract.projectRoot}`, `ExecStart={ path=/usr/local/bin/doppler ; argv[]=/usr/local/bin/doppler run --preserve-env=${MANAGED_DASHBOARD_PRESERVED_ENVIRONMENT.join(",")} -- bun dist/serverStart.js ; }`, ].join("\n"); expect(() => @@ -558,10 +581,7 @@ describe("immutable release deployment", () => { expect(() => assertManagedDashboardUnitProperties( "mira-dashboard.service", - properties.replace( - "MIRA_DASHBOARD_EXECUTION_ROLE=web", - "MIRA_DASHBOARD_EXECUTION_ROLE=worker" - ), + properties.replace("NODE_ENV=production", "NODE_ENV=development"), contract ) ).toThrow("missing stable managed release environment"); diff --git a/backend/test/routeAndServiceBehavior.test.ts b/backend/test/routeAndServiceBehavior.test.ts index fd8906f07..680b284fe 100644 --- a/backend/test/routeAndServiceBehavior.test.ts +++ b/backend/test/routeAndServiceBehavior.test.ts @@ -588,9 +588,7 @@ describe("backend route and service behavior", () => { it("restores Gateway state when first-user bootstrap closes during token validation", async () => { isolateOpenClawEnvironment("mira-first-user-race-close-coverage-"); rememberEnvironment("OPENCLAW_GATEWAY_TOKEN"); - rememberEnvironment("OPENCLAW_TOKEN"); delete process.env.OPENCLAW_GATEWAY_TOKEN; - delete process.env.OPENCLAW_TOKEN; const gatewayModule = await import("../src/gateway.ts"); const gateway = gatewayModule.default; const originalInit = gateway.init; @@ -645,9 +643,7 @@ describe("backend route and service behavior", () => { it("shuts down rejected first-user bootstrap Gateway when no previous token exists", async () => { isolateOpenClawEnvironment("mira-first-user-race-shutdown-coverage-"); rememberEnvironment("OPENCLAW_GATEWAY_TOKEN"); - rememberEnvironment("OPENCLAW_TOKEN"); delete process.env.OPENCLAW_GATEWAY_TOKEN; - delete process.env.OPENCLAW_TOKEN; const gatewayModule = await import("../src/gateway.ts"); const gateway = gatewayModule.default; const originalShutdown = gateway.shutdown; @@ -3298,10 +3294,8 @@ describe("backend route and service behavior", () => { it("proxies managed Gateway media without exposing its bearer token", async () => { rememberEnvironment("OPENCLAW_GATEWAY_URL"); rememberEnvironment("OPENCLAW_GATEWAY_TOKEN"); - rememberEnvironment("OPENCLAW_TOKEN"); process.env.OPENCLAW_GATEWAY_URL = "wss://gateway.example.test/base"; process.env.OPENCLAW_GATEWAY_TOKEN = "environment-secret"; - delete process.env.OPENCLAW_TOKEN; const previousToken = database .prepare("SELECT value FROM app_config WHERE key = 'gateway_token'") .get() as { value: string } | undefined; diff --git a/backend/test/serverStartupPolicy.test.ts b/backend/test/serverStartupPolicy.test.ts index e699d204d..d7e14afd8 100644 --- a/backend/test/serverStartupPolicy.test.ts +++ b/backend/test/serverStartupPolicy.test.ts @@ -10,30 +10,13 @@ import * as releaseManifestModule from "../src/releaseManifest.ts"; const TEST_RELEASE_COMMIT = "a".repeat(40); describe("server start scheduler policy", () => { - it("starts scheduled jobs unless explicitly disabled", async () => { + it("starts scheduled jobs only in the combined non-production server", async () => { const { shouldStartScheduledJobs } = await import("../src/serverStartPolicy.ts"); expect(shouldStartScheduledJobs({})).toBe(true); - expect( - shouldStartScheduledJobs({ - MIRA_DASHBOARD_DISABLE_SCHEDULER: "0", - }) - ).toBe(true); - expect( - shouldStartScheduledJobs({ - MIRA_DASHBOARD_DISABLE_SCHEDULER: "1", - }) - ).toBe(false); - expect( - shouldStartScheduledJobs({ - MIRA_DASHBOARD_EXECUTION_ROLE: "web", - }) - ).toBe(false); - expect( - shouldStartScheduledJobs({ - MIRA_DASHBOARD_EXECUTION_ROLE: "combined", - }) - ).toBe(true); + expect(shouldStartScheduledJobs({ NODE_ENV: "development" })).toBe(true); + expect(shouldStartScheduledJobs({ NODE_ENV: "test" })).toBe(true); + expect(shouldStartScheduledJobs({ NODE_ENV: "production" })).toBe(false); }); it("keeps production frontend assets inside the checksummed release", async () => { @@ -53,7 +36,7 @@ describe("server start scheduler policy", () => { releaseRoot ) ).toBe(releaseFrontend); - expect(() => + expect( resolveFrontendPath( { MIRA_DASHBOARD_FRONTEND_PATH: "/tmp/unverified-frontend", @@ -61,7 +44,7 @@ describe("server start scheduler policy", () => { }, releaseRoot ) - ).toThrow("cannot override the checksummed release frontend"); + ).toBe(releaseFrontend); expect( resolveFrontendPath( { @@ -85,17 +68,10 @@ describe("server start scheduler policy", () => { resolveGatewayToken( { OPENCLAW_GATEWAY_TOKEN: " gateway-token ", - OPENCLAW_TOKEN: "legacy-token", }, () => "persisted-token" ) ).toBe("gateway-token"); - expect( - resolveGatewayToken( - { OPENCLAW_TOKEN: " legacy-token " }, - () => "persisted-token" - ) - ).toBe("legacy-token"); expect(resolveGatewayToken({}, () => " persisted-token ")).toBe( "persisted-token" ); @@ -104,31 +80,15 @@ describe("server start scheduler policy", () => { expect(isDirectEntrypoint(true)).toBe(true); expect(isDirectEntrypoint(false)).toBe(false); - expect(shouldStartOnImport("1", false)).toBe(true); - expect(shouldStartOnImport(undefined, true)).toBe(true); - expect(shouldStartOnImport("0", false)).toBe(false); + expect(shouldStartOnImport(true)).toBe(true); + expect(shouldStartOnImport(false)).toBe(false); const disabledRunner = jest.fn(async () => {}); - const disabledStarter = jest.fn(() => {}); await startBackendServerEntrypoint({ isDirect: false, runServer: disabledRunner, - startServer: disabledStarter, - startOnImport: "0", }); expect(disabledRunner).not.toHaveBeenCalled(); - expect(disabledStarter).not.toHaveBeenCalled(); - - const importedRunner = jest.fn(async () => {}); - const importedStarter = jest.fn(() => {}); - await startBackendServerEntrypoint({ - isDirect: false, - runServer: importedRunner, - startServer: importedStarter, - startOnImport: "1", - }); - expect(importedRunner).not.toHaveBeenCalled(); - expect(importedStarter).toHaveBeenCalledTimes(1); const directServer = Promise.withResolvers(); const exitProcess = jest.fn(() => {}); @@ -148,17 +108,6 @@ describe("server start scheduler policy", () => { await directStartup; expect(isDirectStartupComplete).toBe(true); expect(exitProcess).toHaveBeenCalledWith(0); - - const startupError = new Error("imported startup failed"); - await expect( - startBackendServerEntrypoint({ - isDirect: false, - startOnImport: "1", - startServer: () => { - throw startupError; - }, - }) - ).rejects.toBe(startupError); }); it("reports direct backend entrypoint failures", async () => { @@ -242,17 +191,23 @@ describe("server start scheduler policy", () => { it("verifies the production worker release before opening SQLite", async () => { const temporaryRoot = mkdtempSync(path.join(tmpdir(), "mira-worker-release-")); - const databasePath = path.join(temporaryRoot, "dashboard.db"); + const databasePath = path.join( + temporaryRoot, + "production", + "state", + "mira-dashboard.db" + ); + const backendRoot = path.join(temporaryRoot, "backend"); + mkdirSync(backendRoot); const child = Bun.spawn({ cmd: [ process.execPath, path.resolve(import.meta.dirname, "../src/workerStart.ts"), ], - cwd: path.resolve(import.meta.dirname, ".."), + cwd: backendRoot, env: { ...process.env, - MIRA_DASHBOARD_DB_PATH: databasePath, - MIRA_DASHBOARD_RELEASE_ROOT: temporaryRoot, + MIRA_DASHBOARD_PROJECT_ROOT: temporaryRoot, NODE_ENV: "production", }, stderr: "pipe", @@ -279,17 +234,23 @@ describe("server start scheduler policy", () => { it("verifies the production backend release before opening SQLite", async () => { const temporaryRoot = mkdtempSync(path.join(tmpdir(), "mira-backend-release-")); - const databasePath = path.join(temporaryRoot, "dashboard.db"); + const databasePath = path.join( + temporaryRoot, + "production", + "state", + "mira-dashboard.db" + ); + const backendRoot = path.join(temporaryRoot, "backend"); + mkdirSync(backendRoot); const child = Bun.spawn({ cmd: [ process.execPath, path.resolve(import.meta.dirname, "../src/serverStart.ts"), ], - cwd: path.resolve(import.meta.dirname, ".."), + cwd: backendRoot, env: { ...process.env, - MIRA_DASHBOARD_DB_PATH: databasePath, - MIRA_DASHBOARD_RELEASE_ROOT: temporaryRoot, + MIRA_DASHBOARD_PROJECT_ROOT: temporaryRoot, NODE_ENV: "production", PORT: "0", }, @@ -433,8 +394,8 @@ describe("server start scheduler policy", () => { it("starts listening-time services with a configured gateway token", async () => { const originalGatewayToken = process.env.OPENCLAW_GATEWAY_TOKEN; - const originalSchedulerDisabled = process.env.MIRA_DASHBOARD_DISABLE_SCHEDULER; - process.env.MIRA_DASHBOARD_DISABLE_SCHEDULER = "1"; + const originalNodeEnvironment = process.env.NODE_ENV; + process.env.NODE_ENV = "production"; const gatewayModule = await import("../src/gateway.ts"); const { database } = await import("../src/database.ts"); const serverStartModule = await import("../src/serverStart.ts"); @@ -462,10 +423,10 @@ describe("server start scheduler policy", () => { } else { process.env.OPENCLAW_GATEWAY_TOKEN = originalGatewayToken; } - if (originalSchedulerDisabled === undefined) { - delete process.env.MIRA_DASHBOARD_DISABLE_SCHEDULER; + if (originalNodeEnvironment === undefined) { + delete process.env.NODE_ENV; } else { - process.env.MIRA_DASHBOARD_DISABLE_SCHEDULER = originalSchedulerDisabled; + process.env.NODE_ENV = originalNodeEnvironment; } database .prepare("DELETE FROM cache_entries WHERE key = 'quotas.summary'") @@ -475,11 +436,9 @@ describe("server start scheduler policy", () => { it("starts the combined worker with the verified release identity", async () => { const originalGatewayToken = process.env.OPENCLAW_GATEWAY_TOKEN; - const originalSchedulerDisabled = process.env.MIRA_DASHBOARD_DISABLE_SCHEDULER; - const originalExecutionRole = process.env.MIRA_DASHBOARD_EXECUTION_ROLE; + const originalNodeEnvironment = process.env.NODE_ENV; process.env.OPENCLAW_GATEWAY_TOKEN = "test-token"; - delete process.env.MIRA_DASHBOARD_DISABLE_SCHEDULER; - process.env.MIRA_DASHBOARD_EXECUTION_ROLE = "combined"; + process.env.NODE_ENV = "development"; const gatewayModule = await import("../src/gateway.ts"); const jobWorker = await import("../src/services/jobWorker.ts"); const serverStartModule = await import("../src/serverStart.ts"); @@ -500,26 +459,19 @@ describe("server start scheduler policy", () => { } else { process.env.OPENCLAW_GATEWAY_TOKEN = originalGatewayToken; } - if (originalSchedulerDisabled === undefined) { - delete process.env.MIRA_DASHBOARD_DISABLE_SCHEDULER; - } else { - process.env.MIRA_DASHBOARD_DISABLE_SCHEDULER = originalSchedulerDisabled; - } - if (originalExecutionRole === undefined) { - delete process.env.MIRA_DASHBOARD_EXECUTION_ROLE; + if (originalNodeEnvironment === undefined) { + delete process.env.NODE_ENV; } else { - process.env.MIRA_DASHBOARD_EXECUTION_ROLE = originalExecutionRole; + process.env.NODE_ENV = originalNodeEnvironment; } } }); it("warns but keeps startup alive when no gateway token is configured", async () => { const originalGatewayToken = process.env.OPENCLAW_GATEWAY_TOKEN; - const originalLegacyToken = process.env.OPENCLAW_TOKEN; - const originalSchedulerDisabled = process.env.MIRA_DASHBOARD_DISABLE_SCHEDULER; - process.env.MIRA_DASHBOARD_DISABLE_SCHEDULER = "1"; + const originalNodeEnvironment = process.env.NODE_ENV; + process.env.NODE_ENV = "production"; delete process.env.OPENCLAW_GATEWAY_TOKEN; - delete process.env.OPENCLAW_TOKEN; const gatewayModule = await import("../src/gateway.ts"); const { database } = await import("../src/database.ts"); const serverStartModule = await import("../src/serverStart.ts"); @@ -553,15 +505,10 @@ describe("server start scheduler policy", () => { } else { process.env.OPENCLAW_GATEWAY_TOKEN = originalGatewayToken; } - if (originalLegacyToken === undefined) { - delete process.env.OPENCLAW_TOKEN; - } else { - process.env.OPENCLAW_TOKEN = originalLegacyToken; - } - if (originalSchedulerDisabled === undefined) { - delete process.env.MIRA_DASHBOARD_DISABLE_SCHEDULER; + if (originalNodeEnvironment === undefined) { + delete process.env.NODE_ENV; } else { - process.env.MIRA_DASHBOARD_DISABLE_SCHEDULER = originalSchedulerDisabled; + process.env.NODE_ENV = originalNodeEnvironment; } database .prepare("DELETE FROM cache_entries WHERE key = 'quotas.summary'") @@ -582,8 +529,8 @@ describe("server start scheduler policy", () => { it("rolls back listening-time startup when Gateway initialization fails", async () => { const originalGatewayToken = process.env.OPENCLAW_GATEWAY_TOKEN; - const originalSchedulerDisabled = process.env.MIRA_DASHBOARD_DISABLE_SCHEDULER; - process.env.MIRA_DASHBOARD_DISABLE_SCHEDULER = "1"; + const originalNodeEnvironment = process.env.NODE_ENV; + process.env.NODE_ENV = "production"; process.env.OPENCLAW_GATEWAY_TOKEN = "broken-token"; const gatewayModule = await import("../src/gateway.ts"); const serverStartModule = await import("../src/serverStart.ts"); @@ -615,10 +562,10 @@ describe("server start scheduler policy", () => { } else { process.env.OPENCLAW_GATEWAY_TOKEN = originalGatewayToken; } - if (originalSchedulerDisabled === undefined) { - delete process.env.MIRA_DASHBOARD_DISABLE_SCHEDULER; + if (originalNodeEnvironment === undefined) { + delete process.env.NODE_ENV; } else { - process.env.MIRA_DASHBOARD_DISABLE_SCHEDULER = originalSchedulerDisabled; + process.env.NODE_ENV = originalNodeEnvironment; } } }); @@ -664,8 +611,9 @@ describe("server start scheduler policy", () => { it("starts, stops, and handles web shutdown signals with isolated runtime state", async () => { const environmentKeys = [ "MIRA_DASHBOARD_DB_PATH", - "MIRA_DASHBOARD_DISABLE_SCHEDULER", + "MIRA_DASHBOARD_DEV_SAFE_MODE", "MIRA_DASHBOARD_FRONTEND_PATH", + "NODE_ENV", "OPENCLAW_HOME", ] as const; const originalEnvironment = Object.fromEntries( @@ -680,8 +628,9 @@ describe("server start scheduler policy", () => { writeFileSync(path.join(openclawRoot, "openclaw.json"), "{}\n"); process.env.MIRA_DASHBOARD_DB_PATH = path.join(temporaryRoot, "dashboard.db"); - process.env.MIRA_DASHBOARD_DISABLE_SCHEDULER = "1"; + process.env.MIRA_DASHBOARD_DEV_SAFE_MODE = "1"; process.env.MIRA_DASHBOARD_FRONTEND_PATH = frontendRoot; + process.env.NODE_ENV = "test"; process.env.OPENCLAW_HOME = openclawRoot; const errorSpy = jest.spyOn(console, "error").mockImplementation(() => {}); const warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {}); @@ -780,8 +729,7 @@ describe("server start scheduler policy", () => { MIRA_DASHBOARD_ALLOWED_ORIGINS: "", MIRA_DASHBOARD_AUTOMATION_CREDENTIALS: "", MIRA_DASHBOARD_DB_PATH: path.join(temporaryRoot, "dashboard.db"), - MIRA_DASHBOARD_DISABLE_SCHEDULER: "1", - MIRA_DASHBOARD_EXECUTION_ROLE: "web", + MIRA_DASHBOARD_DEV_SAFE_MODE: "1", MIRA_DASHBOARD_FRONTEND_PATH: frontendRoot, MIRA_DASHBOARD_OPENCLAW_HOME: path.join(temporaryRoot, "openclaw-client"), MIRA_DASHBOARD_SECRET_ENCRYPTION_KEY: new Uint8Array(32) @@ -789,9 +737,9 @@ describe("server start scheduler policy", () => { .toBase64(), MIRA_DASHBOARD_WEBAUTHN_ORIGINS: "", MIRA_DASHBOARD_WEBAUTHN_RP_ID: "", + NODE_ENV: "development", OPENCLAW_GATEWAY_TOKEN: "", OPENCLAW_HOME: openclawRoot, - OPENCLAW_TOKEN: "", PORT: String(port), }, stderr: "pipe", diff --git a/backend/test/serviceBehavior.test.ts b/backend/test/serviceBehavior.test.ts index b9ed10f71..4f2d1530e 100644 --- a/backend/test/serviceBehavior.test.ts +++ b/backend/test/serviceBehavior.test.ts @@ -19,6 +19,7 @@ import { afterEach, describe, expect, it, jest } from "bun:test"; import type { DashboardSocket } from "../src/dashboardSocket.ts"; import { database, sqlNullable } from "../src/database.ts"; +import { resolveDashboardProjectPaths } from "../src/lib/dashboardPaths.ts"; import * as processModule from "../src/lib/processes.ts"; import { ensureDashboardReleaseLayout, @@ -48,7 +49,7 @@ function createTemporaryRoot(prefix: string): string { } async function executeSuccessfulGuardianPath(script: string): Promise { - const firstLifecycleBranch = script.indexOf("\nif MIRA_DASHBOARD_RELEASES_ROOT="); + const firstLifecycleBranch = script.indexOf("\nif MIRA_DASHBOARD_PROJECT_ROOT="); if (firstLifecycleBranch === -1) { throw new Error("Guardian fixture is missing its lifecycle branch"); } @@ -1899,27 +1900,20 @@ describe("backend service behavior", () => { it("hands manual rollback to a detached readiness-bound guardian", async () => { rememberEnvironment("PATH"); rememberEnvironment("MIRA_DASHBOARD_PROJECT_ROOT"); - rememberEnvironment("MIRA_DASHBOARD_ROOT"); - rememberEnvironment("MIRA_DASHBOARD_RELEASES_ROOT"); - rememberEnvironment("MIRA_DASHBOARD_OPENCLAW_HOME"); - rememberEnvironment("MIRA_DASHBOARD_LOG_ROTATION_LOCK_FILE"); - rememberEnvironment("MIRA_DASHBOARD_PREVIEW_ROOT"); - rememberEnvironment("MIRA_DASHBOARD_PREVIEW_WORKTREE_PATH"); - rememberEnvironment("MIRA_DASHBOARD_WORKTREE_ROOT"); const fakeRoot = createTemporaryRoot("mira-release-rollback-root-"); + const projectPaths = resolveDashboardProjectPaths({ + MIRA_DASHBOARD_PROJECT_ROOT: fakeRoot, + }); const fakeBin = createTemporaryRoot("mira-release-rollback-bin-"); - const releasesRoot = path.join(fakeRoot, "managed-releases"); - const openClawHome = path.join(fakeRoot, "state", "openclaw-client"); - const logRotationLockFile = path.join(fakeRoot, "state", "log-rotation.lock"); - const previewRoot = path.join(fakeRoot, "preview-state"); - const previewWorktreePath = path.join(fakeRoot, "preview"); - const worktreeRoot = path.join(fakeRoot, "worktrees"); + const releasesRoot = projectPaths.productionReleasesRoot; const systemdScriptLog = path.join(fakeRoot, "rollback-guardian.sh"); const systemdArgumentsLog = path.join(fakeRoot, "rollback-systemd-run.args"); const currentCommit = "c".repeat(40); const previousCommit = "d".repeat(40); - mkdirSync(path.join(fakeRoot, "backend"), { recursive: true }); - mkdirSync(path.dirname(openClawHome), { recursive: true }); + mkdirSync(path.join(projectPaths.productionCheckoutRoot, "backend"), { + recursive: true, + }); + mkdirSync(projectPaths.productionStateRoot, { recursive: true }); await ensureDashboardReleaseLayout(releasesRoot); await createReleaseFixture( managedReleasePath(releasesRoot, currentCommit), @@ -1951,16 +1945,12 @@ if [[ "$*" != *"--user show"* ]]; then fi if [[ "$*" == *"mira-dashboard-worker.service"* ]]; then entrypoint="dist/workerStart.js" - execution_role="worker" - scope_owner="mira-dashboard-worker.service" else entrypoint="dist/serverStart.js" - execution_role="web" - scope_owner="mira-dashboard.service" fi printf '%s\n' \ - "Environment=NODE_ENV=production MIRA_DASHBOARD_EXECUTION_ROLE=$execution_role MIRA_DASHBOARD_ENABLE_JOB_SCOPES=1 MIRA_DASHBOARD_JOB_SCOPE_OWNER=$scope_owner MIRA_DASHBOARD_PROJECT_ROOT=${fakeRoot}" \ - "ExecStart={ path=/usr/local/bin/doppler ; argv[]=/usr/local/bin/doppler run --preserve-env=NODE_ENV,MIRA_DASHBOARD_EXECUTION_ROLE,MIRA_DASHBOARD_ENABLE_JOB_SCOPES,MIRA_DASHBOARD_JOB_SCOPE_OWNER,MIRA_DASHBOARD_PROJECT_ROOT -- bun $entrypoint ; }" \ + "Environment=NODE_ENV=production MIRA_DASHBOARD_PROJECT_ROOT=${fakeRoot}" \ + "ExecStart={ path=/usr/local/bin/doppler ; argv[]=/usr/local/bin/doppler run --preserve-env=NODE_ENV,MIRA_DASHBOARD_PROJECT_ROOT -- bun $entrypoint ; }" \ "WorkingDirectory=${releasesRoot}/current/backend" ` ); @@ -1979,13 +1969,6 @@ printf 'scheduled\n' chmodSync(path.join(fakeBin, "systemd-run"), 0o755); process.env.PATH = `${fakeBin}${path.delimiter}${process.env.PATH ?? ""}`; process.env.MIRA_DASHBOARD_PROJECT_ROOT = fakeRoot; - process.env.MIRA_DASHBOARD_ROOT = fakeRoot; - process.env.MIRA_DASHBOARD_RELEASES_ROOT = releasesRoot; - process.env.MIRA_DASHBOARD_OPENCLAW_HOME = openClawHome; - process.env.MIRA_DASHBOARD_LOG_ROTATION_LOCK_FILE = logRotationLockFile; - process.env.MIRA_DASHBOARD_PREVIEW_ROOT = previewRoot; - process.env.MIRA_DASHBOARD_PREVIEW_WORKTREE_PATH = previewWorktreePath; - process.env.MIRA_DASHBOARD_WORKTREE_ROOT = worktreeRoot; const { prepareAndStartRollback, registerPullRequestExecutionActions } = await import("../src/services/pullRequests.ts"); @@ -2400,23 +2383,15 @@ printf 'scheduled\n' it("publishes an immutable release and hands activation to detached cutover", async () => { rememberEnvironment("PATH"); rememberEnvironment("MIRA_DASHBOARD_PROJECT_ROOT"); - rememberEnvironment("MIRA_DASHBOARD_ROOT"); - rememberEnvironment("MIRA_DASHBOARD_WORKTREE_ROOT"); - rememberEnvironment("MIRA_DASHBOARD_RELEASES_ROOT"); - rememberEnvironment("MIRA_DASHBOARD_OPENCLAW_HOME"); - rememberEnvironment("MIRA_DASHBOARD_LOG_ROTATION_LOCK_FILE"); - rememberEnvironment("MIRA_DASHBOARD_PREVIEW_ROOT"); - rememberEnvironment("MIRA_DASHBOARD_PREVIEW_WORKTREE_PATH"); rememberEnvironment("PORT"); const fakeRoot = createTemporaryRoot("mira-pr-deploy-root-"); + const projectPaths = resolveDashboardProjectPaths({ + MIRA_DASHBOARD_PROJECT_ROOT: fakeRoot, + }); const fakeBin = createTemporaryRoot("mira-pr-deploy-bin-"); - const worktreeRoot = path.join(fakeRoot, "worktrees"); - const releasesRoot = path.join(fakeRoot, "managed-releases"); + const worktreeRoot = projectPaths.developmentWorktreeRoot; + const releasesRoot = projectPaths.productionReleasesRoot; const candidateTemplate = path.join(fakeRoot, "candidate-template"); - const openClawHome = path.join(fakeRoot, "state", "openclaw-client"); - const logRotationLockFile = path.join(fakeRoot, "state", "log-rotation.lock"); - const previewRoot = path.join(fakeRoot, "preview-state"); - const previewWorktreePath = path.join(fakeRoot, "preview"); const priorPreviousCommit = "b".repeat(40); const oldCommit = "c".repeat(40); const candidateCommit = "d".repeat(40); @@ -2425,9 +2400,11 @@ printf 'scheduled\n' const bunLog = path.join(fakeRoot, "bun.log"); const systemctlLog = path.join(fakeRoot, "systemctl.log"); const systemdLog = path.join(fakeRoot, "systemd.log"); - mkdirSync(path.join(fakeRoot, "backend"), { recursive: true }); - mkdirSync(worktreeRoot); - mkdirSync(path.dirname(openClawHome), { recursive: true }); + mkdirSync(path.join(projectPaths.productionCheckoutRoot, "backend"), { + recursive: true, + }); + mkdirSync(worktreeRoot, { recursive: true }); + mkdirSync(projectPaths.productionStateRoot, { recursive: true }); mkdirSync(candidateTemplate); await createReleaseFixture(candidateTemplate, candidateCommit, { commitTitle: "Deployable dashboard commit", @@ -2456,7 +2433,7 @@ set -euo pipefail head_commit=$(<${JSON.stringify(gitHeadFile)}) printf '%s\n' "$*" >> ${JSON.stringify(gitLog)} if [[ "$*" == "rev-parse --show-toplevel" ]]; then - printf '%s\n' ${JSON.stringify(fakeRoot)} + printf '%s\n' ${JSON.stringify(projectPaths.productionCheckoutRoot)} elif [[ "$*" == "rev-parse --abbrev-ref HEAD" ]]; then printf 'main\n' elif [[ "$*" == "rev-parse --short HEAD" ]]; then @@ -2510,16 +2487,12 @@ if [[ "$*" != *"--user show"* ]]; then fi if [[ "$*" == *"mira-dashboard-worker.service"* ]]; then entrypoint="dist/workerStart.js" - execution_role="worker" - scope_owner="mira-dashboard-worker.service" else entrypoint="dist/serverStart.js" - execution_role="web" - scope_owner="mira-dashboard.service" fi printf '%s\n' \ - "Environment=NODE_ENV=production MIRA_DASHBOARD_EXECUTION_ROLE=$execution_role MIRA_DASHBOARD_ENABLE_JOB_SCOPES=1 MIRA_DASHBOARD_JOB_SCOPE_OWNER=$scope_owner MIRA_DASHBOARD_PROJECT_ROOT=${fakeRoot}" \ - "ExecStart={ path=/usr/local/bin/doppler ; argv[]=/usr/local/bin/doppler run --preserve-env=NODE_ENV,MIRA_DASHBOARD_EXECUTION_ROLE,MIRA_DASHBOARD_ENABLE_JOB_SCOPES,MIRA_DASHBOARD_JOB_SCOPE_OWNER,MIRA_DASHBOARD_PROJECT_ROOT -- bun $entrypoint ; }" \ + "Environment=NODE_ENV=production MIRA_DASHBOARD_PROJECT_ROOT=${fakeRoot}" \ + "ExecStart={ path=/usr/local/bin/doppler ; argv[]=/usr/local/bin/doppler run --preserve-env=NODE_ENV,MIRA_DASHBOARD_PROJECT_ROOT -- bun $entrypoint ; }" \ 'WorkingDirectory=${releasesRoot}/current/backend' ` ); @@ -2537,15 +2510,27 @@ printf 'scheduled\n' chmodSync(path.join(fakeBin, "bun"), 0o755); chmodSync(path.join(fakeBin, "systemctl"), 0o755); chmodSync(path.join(fakeBin, "systemd-run"), 0o755); + const runProcess = processModule.runProcess; + const bunProcessSpy = jest + .spyOn(processModule, "runProcess") + .mockImplementation(async (command, arguments_, options) => { + if ( + command === process.execPath && + (arguments_[0] === "install" || + (arguments_[0] === "run" && arguments_[1] === "deploy:prepare") || + arguments_[0] === "dist/databasePreflight.js") + ) { + appendFileSync( + bunLog, + `${options?.cwd ?? process.cwd()}|${arguments_.join(" ")}\n` + ); + return { code: 0, stderr: "", stdout: "ok\n" }; + } + return runProcess(command, arguments_, options); + }); + cleanupCallbacks.push(() => bunProcessSpy.mockRestore()); process.env.PATH = `${fakeBin}${path.delimiter}${process.env.PATH ?? ""}`; process.env.MIRA_DASHBOARD_PROJECT_ROOT = fakeRoot; - process.env.MIRA_DASHBOARD_ROOT = fakeRoot; - process.env.MIRA_DASHBOARD_WORKTREE_ROOT = worktreeRoot; - process.env.MIRA_DASHBOARD_RELEASES_ROOT = releasesRoot; - process.env.MIRA_DASHBOARD_OPENCLAW_HOME = openClawHome; - process.env.MIRA_DASHBOARD_LOG_ROTATION_LOCK_FILE = logRotationLockFile; - process.env.MIRA_DASHBOARD_PREVIEW_ROOT = previewRoot; - process.env.MIRA_DASHBOARD_PREVIEW_WORKTREE_PATH = previewWorktreePath; process.env.PORT = "4310"; const { registerPullRequestExecutionActions, startDeployLatest } = @@ -3052,13 +3037,11 @@ fi it("lists pull requests from GitHub JSON lines and refreshes blocked merge state", async () => { rememberEnvironment("PATH"); rememberEnvironment("MIRA_DASHBOARD_ROOT"); - rememberEnvironment("RAJOHAN_GITHUB_USERNAME"); const fakeRoot = createTemporaryRoot("mira-pr-list-root-"); const fakeBin = createTemporaryRoot("mira-pr-list-bin-"); writeFakeGh(path.join(fakeBin, "gh")); process.env.PATH = `${fakeBin}${path.delimiter}${process.env.PATH ?? ""}`; process.env.MIRA_DASHBOARD_ROOT = fakeRoot; - process.env.RAJOHAN_GITHUB_USERNAME = "rajohan"; const { isDashboardPullRequestOpen, @@ -3093,7 +3076,6 @@ fi rememberEnvironment("MIRA_DASHBOARD_ROOT"); rememberEnvironment("MIRA_DASHBOARD_WORKTREE_ROOT"); rememberEnvironment("RAJOHAN_GITHUB_TOKEN"); - rememberEnvironment("RAJOHAN_GITHUB_USERNAME"); const fakeRoot = createTemporaryRoot("mira-pr-actions-root-"); const fakeBin = createTemporaryRoot("mira-pr-actions-bin-"); const ghLog = path.join(fakeRoot, "gh.log"); @@ -3115,7 +3097,6 @@ fi process.env.MIRA_DASHBOARD_ROOT = fakeRoot; process.env.MIRA_DASHBOARD_WORKTREE_ROOT = path.join(fakeRoot, "worktrees"); process.env.RAJOHAN_GITHUB_TOKEN = "review-token"; - process.env.RAJOHAN_GITHUB_USERNAME = "rajohan"; const { approvePullRequestReview, @@ -3268,7 +3249,6 @@ fi rememberEnvironment("PATH"); rememberEnvironment("MIRA_DASHBOARD_ROOT"); rememberEnvironment("MIRA_DASHBOARD_WORKTREE_ROOT"); - rememberEnvironment("RAJOHAN_GITHUB_USERNAME"); const fakeRoot = createTemporaryRoot("mira-pr-merge-root-"); const worktreeRoot = path.join(fakeRoot, "worktrees"); const localWorktree = path.join(worktreeRoot, "merge-branch"); @@ -3312,7 +3292,6 @@ fi process.env.PATH = `${fakeBin}${path.delimiter}${process.env.PATH ?? ""}`; process.env.MIRA_DASHBOARD_ROOT = fakeRoot; process.env.MIRA_DASHBOARD_WORKTREE_ROOT = worktreeRoot; - process.env.RAJOHAN_GITHUB_USERNAME = "rajohan"; try { const { registerPullRequestExecutionActions, runPullRequestApproval } = @@ -3368,7 +3347,6 @@ fi rememberEnvironment("PATH"); rememberEnvironment("MIRA_DASHBOARD_ROOT"); rememberEnvironment("MIRA_DASHBOARD_WORKTREE_ROOT"); - rememberEnvironment("RAJOHAN_GITHUB_USERNAME"); const fakeRoot = createTemporaryRoot("mira-pr-sync-fail-root-"); const worktreeRoot = path.join(fakeRoot, "worktrees"); const fakeBin = createTemporaryRoot("mira-pr-sync-fail-bin-"); @@ -3409,7 +3387,6 @@ fi process.env.PATH = `${fakeBin}${path.delimiter}${process.env.PATH ?? ""}`; process.env.MIRA_DASHBOARD_ROOT = fakeRoot; process.env.MIRA_DASHBOARD_WORKTREE_ROOT = worktreeRoot; - process.env.RAJOHAN_GITHUB_USERNAME = "rajohan"; try { const { approvePullRequest } = @@ -3509,7 +3486,6 @@ fi rememberEnvironment("MIRA_DASHBOARD_ROOT"); rememberEnvironment("MIRA_DASHBOARD_WORKTREE_ROOT"); rememberEnvironment("RAJOHAN_GITHUB_TOKEN"); - rememberEnvironment("RAJOHAN_GITHUB_USERNAME"); const fakeRoot = createTemporaryRoot("mira-pr-validation-root-"); const fakeBin = createTemporaryRoot("mira-pr-validation-bin-"); writeFakeGhForPullRequestValidation(path.join(fakeBin, "gh")); @@ -3517,7 +3493,6 @@ fi process.env.PATH = `${fakeBin}${path.delimiter}${process.env.PATH ?? ""}`; process.env.MIRA_DASHBOARD_ROOT = fakeRoot; process.env.MIRA_DASHBOARD_WORKTREE_ROOT = path.join(fakeRoot, "worktrees"); - process.env.RAJOHAN_GITHUB_USERNAME = "rajohan"; delete process.env.RAJOHAN_GITHUB_TOKEN; const { @@ -5581,7 +5556,7 @@ fi const rotationRoot = createTemporaryRoot("mira-log-rotation-lock-test-"); const logFile = path.join(rotationRoot, "locked.log"); const configFile = path.join(rotationRoot, "log-rotation.json"); - const lockFile = path.join(process.cwd(), "data", "log-rotation.lock"); + const lockFile = resolveDashboardProjectPaths().productionLogRotationLockFile; mkdirSync(path.dirname(lockFile), { recursive: true }); writeFileSync(lockFile, `${process.pid}\n`); cleanupCallbacks.push(() => { @@ -5628,7 +5603,7 @@ fi const rotationRoot = createTemporaryRoot("mira-log-rotation-stale-lock-"); const logFile = path.join(rotationRoot, "stale-lock.log"); const configFile = path.join(rotationRoot, "log-rotation.json"); - const lockFile = path.join(process.cwd(), "data", "log-rotation.lock"); + const lockFile = resolveDashboardProjectPaths().productionLogRotationLockFile; mkdirSync(path.dirname(lockFile), { recursive: true }); writeFileSync(lockFile, "999999999\n"); const staleTime = new Date(Date.now() - 13 * 60 * 60 * 1000); diff --git a/backend/test/setup.ts b/backend/test/setup.ts index f6c853b56..c5059e54b 100644 --- a/backend/test/setup.ts +++ b/backend/test/setup.ts @@ -9,17 +9,12 @@ const preloadDatabaseRoot = mkdtempSync( ); const originalDatabasePath = process.env.MIRA_DASHBOARD_DB_PATH; const originalAutomationCredentials = process.env.MIRA_DASHBOARD_AUTOMATION_CREDENTIALS; -const originalPreviewRoot = process.env.MIRA_DASHBOARD_PREVIEW_ROOT; -const originalPreviewWorktreePath = process.env.MIRA_DASHBOARD_PREVIEW_WORKTREE_PATH; +const originalProjectRoot = process.env.MIRA_DASHBOARD_PROJECT_ROOT; const originalSecretEncryptionKey = process.env.MIRA_DASHBOARD_SECRET_ENCRYPTION_KEY; process.env.NODE_ENV = "test"; process.env.MIRA_DASHBOARD_DB_PATH = path.join(preloadDatabaseRoot, "dashboard.db"); -process.env.MIRA_DASHBOARD_PREVIEW_ROOT = path.join(preloadDatabaseRoot, "preview-state"); -process.env.MIRA_DASHBOARD_PREVIEW_WORKTREE_PATH = path.join( - preloadDatabaseRoot, - "preview-worktree" -); +process.env.MIRA_DASHBOARD_PROJECT_ROOT = preloadDatabaseRoot; process.env.MIRA_DASHBOARD_SECRET_ENCRYPTION_KEY = new Uint8Array(32).fill(7).toBase64(); delete process.env.MIRA_DASHBOARD_AUTOMATION_CREDENTIALS; @@ -34,15 +29,10 @@ afterAll(() => { } else { process.env.MIRA_DASHBOARD_AUTOMATION_CREDENTIALS = originalAutomationCredentials; } - if (originalPreviewRoot === undefined) { - delete process.env.MIRA_DASHBOARD_PREVIEW_ROOT; - } else { - process.env.MIRA_DASHBOARD_PREVIEW_ROOT = originalPreviewRoot; - } - if (originalPreviewWorktreePath === undefined) { - delete process.env.MIRA_DASHBOARD_PREVIEW_WORKTREE_PATH; + if (originalProjectRoot === undefined) { + delete process.env.MIRA_DASHBOARD_PROJECT_ROOT; } else { - process.env.MIRA_DASHBOARD_PREVIEW_WORKTREE_PATH = originalPreviewWorktreePath; + process.env.MIRA_DASHBOARD_PROJECT_ROOT = originalProjectRoot; } if (originalSecretEncryptionKey === undefined) { delete process.env.MIRA_DASHBOARD_SECRET_ENCRYPTION_KEY; diff --git a/backend/test/utilityBehavior.test.ts b/backend/test/utilityBehavior.test.ts index 89b929e46..1bca66903 100644 --- a/backend/test/utilityBehavior.test.ts +++ b/backend/test/utilityBehavior.test.ts @@ -468,6 +468,9 @@ describe("backend service utilities", () => { expect(resolveDashboardPort("not-a-port")).toBe(3100); expect(resolveDashboardHost(" 127.0.0.1 ")).toBe("127.0.0.1"); expect(resolveDashboardHost("")).toBe("0.0.0.0"); + expect(resolveDashboardHost("127.0.0.1", { NODE_ENV: "production" })).toBe( + "0.0.0.0" + ); expect(() => resolveDashboardHost("bad host")).toThrow( "MIRA_DASHBOARD_HOST must be a valid bind host" ); @@ -577,12 +580,33 @@ describe("backend service utilities", () => { expect(requiresRecentMfaForGatewayMethod("config.get")).toBe(true); expect(requiresRecentMfaForGatewayMethod("cron.list")).toBe(true); expect(isDevelopmentGatewayMethodBlocked("config.patch", {})).toBe(false); - expect(dashboardJobProfile({ MIRA_DASHBOARD_JOB_PROFILE: "isolated" })).toBe( + expect( + isDevelopmentGatewayMethodBlocked("config.patch", { + MIRA_DASHBOARD_DEV_SAFE_MODE: "1", + NODE_ENV: "production", + }) + ).toBe(false); + expect( + isDevelopmentHostMutationBlocked( + new Request("http://localhost/api/docker/update", { + method: "POST", + }), + { + MIRA_DASHBOARD_DEV_SAFE_MODE: "1", + NODE_ENV: "production", + } + ) + ).toBe(false); + expect(dashboardJobProfile({ MIRA_DASHBOARD_DEV_SAFE_MODE: "1" })).toBe( "isolated" ); - expect(dashboardJobProfile({ MIRA_DASHBOARD_JOB_PROFILE: "unknown" })).toBe( - "full" - ); + expect( + dashboardJobProfile({ + MIRA_DASHBOARD_DEV_SAFE_MODE: "1", + NODE_ENV: "production", + }) + ).toBe("full"); + expect(dashboardJobProfile({ MIRA_DASHBOARD_DEV_SAFE_MODE: "0" })).toBe("full"); }); it("maps operational errors without leaking unknown values", () => { @@ -882,18 +906,27 @@ describe("backend service utilities", () => { }); expect( resolveDashboardCookieNames({ - MIRA_DASHBOARD_COOKIE_NAMESPACE: "mira_dashboard_dev_5173", + MIRA_DASHBOARD_DEV_COOKIE_NAMESPACE: "mira_dashboard_dev_5173", }) ).toEqual({ pendingLogin: "mira_dashboard_dev_5173_pending_login", session: "mira_dashboard_dev_5173_session", }); + expect( + resolveDashboardCookieNames({ + MIRA_DASHBOARD_DEV_COOKIE_NAMESPACE: "ignored_in_production", + NODE_ENV: "production", + }) + ).toEqual({ + pendingLogin: "mira_dashboard_pending_login", + session: "mira_dashboard_session", + }); for (const namespace of ["Prod", "dev-cookie", "a".repeat(49)]) { expect(() => resolveDashboardCookieNames({ - MIRA_DASHBOARD_COOKIE_NAMESPACE: namespace, + MIRA_DASHBOARD_DEV_COOKIE_NAMESPACE: namespace, }) - ).toThrow("MIRA_DASHBOARD_COOKIE_NAMESPACE"); + ).toThrow("MIRA_DASHBOARD_DEV_COOKIE_NAMESPACE"); } }); diff --git a/docs/architecture/database.md b/docs/architecture/database.md index 3e3846553..39b8ce7f0 100644 --- a/docs/architecture/database.md +++ b/docs/architecture/database.md @@ -17,12 +17,16 @@ That default is for development. Production units set: The `-wal`/`-shm` sidecars and `backups/` directory stay below the same persistent state root, outside the control checkout and immutable releases. -Override: +Isolated development, tests, and one-shot recovery checks may inject a +temporary database into their child process: ```bash MIRA_DASHBOARD_DB_PATH=/absolute/path/to/mira-dashboard.db ``` +This is not a production service/Doppler override. Production derives the +database path from `MIRA_DASHBOARD_PROJECT_ROOT`. + ## Startup Behavior `backend/src/database.ts` secures storage, opens the database, validates WAL, @@ -122,8 +126,10 @@ legacy reusable session ids do not survive the upgrade. The deploy flow uses one combined build/preflight command before restart: ```bash +export MIRA_DASHBOARD_PROJECT_ROOT=/home/ubuntu/projects/mira-dashboard cd /home/ubuntu/projects/mira-dashboard/production/checkout -/usr/local/bin/doppler run --config prd --project rajohan -- \ +/usr/local/bin/doppler run --config prd --project rajohan \ + --preserve-env=MIRA_DASHBOARD_PROJECT_ROOT -- \ bun run deploy:prepare ``` @@ -151,8 +157,8 @@ schema enforces append-only history. Any future archive/retention design must arrive through a reviewed forward migration that preserves required audit history. -Snapshots live below `dirname(MIRA_DASHBOARD_DB_PATH)/backups/`. This is -`backend/data/backups/` in development and +Snapshots live beside the active database below `backups/`. This is +`backend/data/backups/` for an unwrapped source process and `/home/ubuntu/projects/mira-dashboard/production/state/backups/` in production: | Kind | Maximum age | Maximum count | diff --git a/docs/architecture/gateway-and-chat.md b/docs/architecture/gateway-and-chat.md index ec5395d7a..0cb41af72 100644 --- a/docs/architecture/gateway-and-chat.md +++ b/docs/architecture/gateway-and-chat.md @@ -21,8 +21,7 @@ auth. On backend startup, the Gateway token is selected in this order: 1. `OPENCLAW_GATEWAY_TOKEN` -2. `OPENCLAW_TOKEN` -3. decrypted `app_config.gateway_token` AES-GCM envelope +2. decrypted `app_config.gateway_token` AES-GCM envelope Environment tokens win over the persisted database token. This is intentional: production should prefer Doppler-managed state over older bootstrap state. diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index 61eb7149b..ca3b09cb5 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -133,8 +133,7 @@ state to the browser through a Dashboard WebSocket. Startup token precedence: 1. `OPENCLAW_GATEWAY_TOKEN` -2. `OPENCLAW_TOKEN` -3. decrypted `app_config.gateway_token` AES-GCM envelope +2. decrypted `app_config.gateway_token` AES-GCM envelope The Dashboard WebSocket at `/ws` requires: @@ -164,11 +163,11 @@ Worker startup registers scheduled jobs for: - OpenClaw update notifications; - scheduled job runner. -Production uses `MIRA_DASHBOARD_EXECUTION_ROLE=web` and `worker` in separate -systemd services. Local development uses the `combined` role with the -`isolated` job profile: scheduler/worker behavior remains testable, while -backup, deploy, Docker, exec, log-rotation, PR, and OpenClaw-restart adapters -are not registered. +Production runs separate web and worker entry points. Local development runs +the combined server entry point and dev safe mode selects the isolated job +profile: scheduler/worker behavior remains testable, while backup, deploy, +Docker, exec, log-rotation, PR, and OpenClaw-restart adapters are not +registered. See [Scheduler, cache, and backups](../operations/scheduler-cache-backups.md) for job tables, cache entries, backup scripts, and inspection commands. diff --git a/docs/development/local-dev.md b/docs/development/local-dev.md index 8e39eda9b..d0c53ab3f 100644 --- a/docs/development/local-dev.md +++ b/docs/development/local-dev.md @@ -201,12 +201,11 @@ MIRA_DASHBOARD_DEV_WORKSPACE_SOURCE MIRA_DASHBOARD_DEV_OPENCLAW_CONFIG_SOURCE MIRA_DASHBOARD_DEV_GATEWAY_URL MIRA_DASHBOARD_DEV_GATEWAY_TOKEN_FILE -MIRA_DASHBOARD_PREVIEW_ROOT -MIRA_DASHBOARD_PREVIEW_WORKTREE_PATH ``` Use overrides only with absolute, non-root state/source paths. The ordinary commands already select the host's production snapshots and runtime Gateway. +Managed PR dev paths are always derived from `MIRA_DASHBOARD_PROJECT_ROOT`. ## Verification diff --git a/docs/operations/docker-updater.md b/docs/operations/docker-updater.md index 7b7e9b0fb..db40c4da1 100644 --- a/docs/operations/docker-updater.md +++ b/docs/operations/docker-updater.md @@ -11,8 +11,6 @@ Important environment variables: | Variable | Purpose | | -------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | | `MIRA_DOCKER_COMPOSE_WRAPPER` | Command used to run compose operations. Production commonly uses `/opt/docker/bin/docker-compose-doppler`. | -| `MIRA_DOCKER_UPDATER_PLATFORM` | Optional platform override for registry lookups. | -| `MIRA_DOCKER_UPDATER_SKIP_REGISTRY` | Set `1` only for tests/debugging to skip registry checks. | | `DOCKER_LOGIN` + `DOCKER_TOKEN` | Docker Hub auth. Both are required; token alone is ignored. | | `MIRA_GITHUB_USERNAME` + `MIRA_GITHUB_TOKEN` | GHCR auth for registry lookups where needed. | diff --git a/docs/operations/runbooks.md b/docs/operations/runbooks.md index a2a58d429..52ba2a975 100644 --- a/docs/operations/runbooks.md +++ b/docs/operations/runbooks.md @@ -50,7 +50,6 @@ openclaw status 3. Check Dashboard logs for `gateway token mismatch`. 4. Verify token precedence: - `OPENCLAW_GATEWAY_TOKEN` - - `OPENCLAW_TOKEN` - persisted `app_config.gateway_token` 5. If bootstrap was just reset, ensure the new token was accepted by bootstrap. diff --git a/docs/operations/scheduler-cache-backups.md b/docs/operations/scheduler-cache-backups.md index 65ba7a6a0..023510619 100644 --- a/docs/operations/scheduler-cache-backups.md +++ b/docs/operations/scheduler-cache-backups.md @@ -1,12 +1,11 @@ # Scheduler, Cache, And Backups -Dashboard runs background jobs from `backend/src/workerStart.ts`. Production -sets `MIRA_DASHBOARD_EXECUTION_ROLE=web` on the web unit and -`MIRA_DASHBOARD_EXECUTION_ROLE=worker` on the worker unit. The backward- -disables the in-process worker when explicitly requested. Ordinary local -development uses `combined` with `MIRA_DASHBOARD_JOB_PROFILE=isolated`, keeping -the scheduler/worker active without registering host backup, deploy, Docker, -exec, log-rotation, PR, or OpenClaw-restart actions. +Dashboard runs production background jobs from +`backend/src/workerStart.ts`; the separate web entry point never starts an +in-process production worker. Ordinary local development uses the combined +server entry point, and dev safe mode keeps the scheduler/worker active without +registering host backup, deploy, Docker, exec, log-rotation, PR, or +OpenClaw-restart actions. ## Scheduled Jobs diff --git a/docs/security/auth-and-trust-boundaries.md b/docs/security/auth-and-trust-boundaries.md index 863279e38..6f29acb1a 100644 --- a/docs/security/auth-and-trust-boundaries.md +++ b/docs/security/auth-and-trust-boundaries.md @@ -360,8 +360,7 @@ metadata such as length and timestamps. Startup token precedence: 1. `OPENCLAW_GATEWAY_TOKEN` -2. `OPENCLAW_TOKEN` -3. persisted `app_config.gateway_token` +2. persisted `app_config.gateway_token` If an environment token exists, it should be considered the source of truth for production. diff --git a/docs/setup/new-vps.md b/docs/setup/new-vps.md index 656021cdf..6d66c888c 100644 --- a/docs/setup/new-vps.md +++ b/docs/setup/new-vps.md @@ -49,10 +49,11 @@ Create the managed runtime roots: ```bash install -d -m 0755 \ - /home/ubuntu/projects/mira-dashboard/development/state \ /home/ubuntu/projects/mira-dashboard/development/worktrees \ /home/ubuntu/projects/mira-dashboard/production/releases -install -d -m 0700 /home/ubuntu/projects/mira-dashboard/production/state +install -d -m 0700 \ + /home/ubuntu/projects/mira-dashboard/development/state \ + /home/ubuntu/projects/mira-dashboard/production/state ``` ## Publish The Initial Managed Release @@ -92,6 +93,8 @@ env \ Activate it before installing/starting the managed systemd units: ```bash +export MIRA_DASHBOARD_PROJECT_ROOT=/home/ubuntu/projects/mira-dashboard +cd "$MIRA_DASHBOARD_PROJECT_ROOT/production/checkout" CANDIDATE_SHA="$(git rev-parse HEAD)" env \ NODE_ENV=production \ diff --git a/docs/setup/production-deploy.md b/docs/setup/production-deploy.md index 6032069cd..6f1b4d3d8 100644 --- a/docs/setup/production-deploy.md +++ b/docs/setup/production-deploy.md @@ -47,9 +47,8 @@ Mutable state is deliberately outside both Git and every release: | Dashboard Gateway device identity | `/home/ubuntu/projects/mira-dashboard/production/state/openclaw-client/` | | Log-rotation lock | `/home/ubuntu/projects/mira-dashboard/production/state/log-rotation.lock` | -The backup directory is derived from the production state root (or from -`dirname(MIRA_DASHBOARD_DB_PATH)` when that advanced override is used), so -pre-deploy and pre-migration snapshots automatically stay under the state root. +The backup directory is derived from the production state root, so pre-deploy +and pre-migration snapshots automatically stay under the state root. Kopia mounts `/home/ubuntu/projects` as its projects source; the separate state directory remains in that backup scope. @@ -64,11 +63,11 @@ MIRA_DASHBOARD_PROJECT_ROOT=/home/ubuntu/projects/mira-dashboard ``` The backend derives every production and development path in the layout above -from that root. Their Doppler command preserves it plus `NODE_ENV`, -`MIRA_DASHBOARD_EXECUTION_ROLE`, `MIRA_DASHBOARD_ENABLE_JOB_SCOPES`, and -`MIRA_DASHBOARD_JOB_SCOPE_OWNER`, so production secrets cannot replace -unit-owned paths or orchestration policy. Fine-grained path variables remain -available only as explicit development, test, and recovery overrides. +from that root. The Doppler command preserves only the root and `NODE_ENV`, so +production secrets cannot replace unit-owned paths or runtime mode. The web and +worker entry points define orchestration policy directly; it is not configurable +through environment variables. Fine-grained path variables remain internal +development, test, and one-shot recovery contracts. The OpenClaw home preserves the signed Gateway device identity across releases. Secrets remain in Doppler `rajohan/prd`; tracked unit files contain no secret diff --git a/docs/setup/secrets-and-env.md b/docs/setup/secrets-and-env.md index e2fd3f475..178965b93 100644 --- a/docs/setup/secrets-and-env.md +++ b/docs/setup/secrets-and-env.md @@ -5,12 +5,11 @@ Do not commit `.env`, `.env.local`, token dumps, or generated secret files. ## Required Core Runtime -| Variable | Required | Used by | Purpose | -| ------------------------ | ----------------- | ---------------------------------------- | ----------------------------------------------------------------------------------------------------------- | -| `OPENCLAW_GATEWAY_TOKEN` | Usually | backend startup, auth bootstrap fallback | Authenticates the backend Gateway client to OpenClaw. Startup prefers this over the persisted SQLite token. | -| `OPENCLAW_TOKEN` | Optional fallback | backend startup | Legacy/fallback Gateway token name. Used only if `OPENCLAW_GATEWAY_TOKEN` is absent. | -| `PORT` | Optional | backend server | HTTP port. Defaults to `3100`. | -| `NODE_ENV` | Recommended | backend/database | Production service sets `production`; tests set `test`. | +| Variable | Required | Used by | Purpose | +| ------------------------ | ----------- | ---------------------------------------- | ----------------------------------------------------------------------------------------------------------- | +| `OPENCLAW_GATEWAY_TOKEN` | Usually | backend startup, auth bootstrap fallback | Authenticates the backend Gateway client to OpenClaw. Startup prefers this over the persisted SQLite token. | +| `PORT` | Optional | backend server | HTTP port. Defaults to `3100`. | +| `NODE_ENV` | Recommended | backend/database | Production service sets `production`; tests set `test`. | First-user bootstrap validates the submitted Gateway token and stores it as an AES-256-GCM encrypted fallback envelope in `app_config.gateway_token`. The @@ -18,39 +17,34 @@ external Dashboard secret-encryption key is required to decrypt it. Environment token precedence is: 1. `OPENCLAW_GATEWAY_TOKEN` -2. `OPENCLAW_TOKEN` -3. persisted `app_config.gateway_token` +2. persisted `app_config.gateway_token` ## Dashboard Storage And Paths -| Variable | Required | Default | Purpose | -| --------------------------------------- | --------------------------- | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | -| `MIRA_DASHBOARD_PROJECT_ROOT` | Explicit in production unit | `/home/ubuntu/projects/mira-dashboard` | Single host layout root. Production/state/release and development paths are derived from it. | -| `MIRA_DASHBOARD_DB_PATH` | Optional advanced override | `/production/state/mira-dashboard.db` | SQLite database path. Without a configured project root, local source runs use `backend/data/mira-dashboard.db`. | -| `MIRA_DASHBOARD_LOG_ROTATION_LOCK_FILE` | Optional advanced override | `/production/state/log-rotation.lock` | Stable cross-release lock for elevated log rotation. | -| `MIRA_DASHBOARD_FRONTEND_PATH` | Optional | repo `dist/` | Static frontend build served by the backend. | -| `OPENCLAW_HOME` | Optional | `~/.openclaw` | Primary OpenClaw home for file/config/media/agent lookups when set. | -| `MIRA_DASHBOARD_OPENCLAW_HOME` | Optional advanced override | `/production/state/openclaw-client` | Dashboard Gateway-client identity home. Production uses persistent state so its signed device identity survives releases. | -| `MIRA_DASHBOARD_RELEASE_ROOT` | Internal/advanced override | inferred runtime root | Exact release root used by isolated builds and development stacks. Production infers the active immutable release from its cwd. | -| `MIRA_DASHBOARD_RELEASES_ROOT` | Optional advanced override | `/production/releases` | Managed release layout containing `releases/`, `current`, `previous`, locks, and transition journal. | -| `MIRA_DASHBOARD_ROOT` | Optional advanced override | `/production/checkout` | Clean `main` control checkout used for approved release builds and PR worktree ownership. | -| `MIRA_DASHBOARD_WORKTREE_ROOT` | Optional advanced override | `/development/worktrees` | Feature and detached release-build worktrees owned by the production checkout. | -| `WORKSPACE_ROOT` | Optional | OpenClaw workspace | Root exposed by `/api/files`. Must be absolute and normalized if set. | -| `MIRA_DASHBOARD_LOGS_ROOT` | Optional | system log root default | Root used by log stream services. | - -`MIRA_DASHBOARD_FRONTEND_PATH` is a development/test escape hatch. Production -serves the active release's checksummed `dist/`; any configured value that does -not resolve exactly to that directory is rejected. - -Production units configure only `MIRA_DASHBOARD_PROJECT_ROOT`; the backend -derives the normal layout and does not need one environment variable per path. -The advanced path variables are retained because isolated development stacks, -tests, and recovery commands sometimes need deliberately nonstandard roots. +| Variable | Required | Default | Purpose | +| ----------------------------- | --------------------------- | -------------------------------------- | --------------------------------------------------------------------------------------------------------- | +| `MIRA_DASHBOARD_PROJECT_ROOT` | Explicit in production unit | `/home/ubuntu/projects/mira-dashboard` | Single host-layout root. Every production, state, release, preview, and worktree path is derived from it. | +| `OPENCLAW_HOME` | Optional | `~/.openclaw` | Primary OpenClaw home for file/config/media/agent lookups when set. | +| `WORKSPACE_ROOT` | Optional | OpenClaw workspace | Root exposed by `/api/files`. Must be absolute and normalized if set. | + +Production accepts no per-path Dashboard overrides. The service unit supplies +only `MIRA_DASHBOARD_PROJECT_ROOT`; values with names such as +`MIRA_DASHBOARD_DB_PATH`, `MIRA_DASHBOARD_RELEASES_ROOT`, +`MIRA_DASHBOARD_ROOT`, and `MIRA_DASHBOARD_WORKTREE_ROOT` are internal +development/test child-process contracts. They are deliberately ignored in +production whenever the derived project layout is available and must not be +stored in Doppler or added to a production unit. + +`MIRA_DASHBOARD_FRONTEND_PATH`, `MIRA_DASHBOARD_LOGS_ROOT`, +`MIRA_DASHBOARD_LOG_ROTATION_LOCK_FILE`, and +`MIRA_DASHBOARD_OPENCLAW_HOME` have the same internal-only status. They let an +isolated development child use its private state and let tests inject temporary +fixtures; they are not operator configuration. Production mutable state lives in `/home/ubuntu/projects/mira-dashboard/production/state`, outside both the control checkout and immutable releases. SQLite backups live below the derived -state root (or `dirname(MIRA_DASHBOARD_DB_PATH)/backups` when overridden). +state root. Versioned `backend/config/` files are release artifacts, not external state. `OPENCLAW_HOME` remains the primary OpenClaw installation/configuration root; `MIRA_DASHBOARD_OPENCLAW_HOME` is the separate Dashboard client identity root @@ -59,18 +53,17 @@ workspace, or media. ## Network, Auth, And Browser Access -| Variable | Required | Default | Purpose | -| --------------------------------------- | --------------------------- | ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `MIRA_DASHBOARD_ALLOWED_ORIGINS` | Production browser access | same-origin/localhost behavior | Comma-separated allowed origins for browser/WebSocket checks. | -| `MIRA_DASHBOARD_AUTOMATION_CREDENTIALS` | Local non-browser callers | none | Strict JSON list of hash-only, minimum-scope automation credentials. There is no loopback auth bypass. | -| `MIRA_DASHBOARD_SECRET_ENCRYPTION_KEY` | Always | none | Base64 that decodes to exactly 32 bytes. External AES-256-GCM key for persisted Gateway token and TOTP seeds; preserve it with backups. | -| `MIRA_DASHBOARD_COOKIE_NAMESPACE` | Optional | `mira_dashboard` | Prefix for session and pending-login cookies. Dev stacks set a port-specific namespace so login cannot replace the production cookie on the same hostname. | -| `MIRA_DASHBOARD_WEBAUTHN_RP_ID` | Security-key enrollment/use | none | Stable DNS relying-party id, for example `dashboard.example.com`. Raw IP addresses are rejected. | -| `MIRA_DASHBOARD_WEBAUTHN_ORIGINS` | Security-key enrollment/use | none | Explicit comma-separated HTTPS origins belonging to the RP ID. `http://localhost` is allowed for dev only. | -| `MIRA_DASHBOARD_SESSION_IDLE_MINUTES` | Optional | `30` | Idle session lifetime, integer `5`–`1440`. Polling alone does not refresh it. | -| `MIRA_DASHBOARD_RECENT_AUTH_MINUTES` | Optional | `10` | Fresh password/MFA verification window, integer `1`–`60`. | -| `MIRA_DASHBOARD_TRUSTED_PROXY_IPS` | Optional | none | Trusted proxy IPs. Only use if the proxy strips or overwrites untrusted forwarding headers. | -| `OPENCLAW_GATEWAY_URL` | Optional | `ws://127.0.0.1:18789` | Gateway WebSocket URL. | +| Variable | Required | Default | Purpose | +| --------------------------------------- | --------------------------- | ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------- | +| `MIRA_DASHBOARD_ALLOWED_ORIGINS` | Production browser access | same-origin/localhost behavior | Comma-separated allowed origins for browser/WebSocket checks. | +| `MIRA_DASHBOARD_AUTOMATION_CREDENTIALS` | Local non-browser callers | none | Strict JSON list of hash-only, minimum-scope automation credentials. There is no loopback auth bypass. | +| `MIRA_DASHBOARD_SECRET_ENCRYPTION_KEY` | Always | none | Base64 that decodes to exactly 32 bytes. External AES-256-GCM key for persisted Gateway token and TOTP seeds; preserve it with backups. | +| `MIRA_DASHBOARD_WEBAUTHN_RP_ID` | Security-key enrollment/use | none | Stable DNS relying-party id, for example `dashboard.example.com`. Raw IP addresses are rejected. | +| `MIRA_DASHBOARD_WEBAUTHN_ORIGINS` | Security-key enrollment/use | none | Explicit comma-separated HTTPS origins belonging to the RP ID. `http://localhost` is allowed for dev only. | +| `MIRA_DASHBOARD_SESSION_IDLE_MINUTES` | Optional | `30` | Idle session lifetime, integer `5`–`1440`. Polling alone does not refresh it. | +| `MIRA_DASHBOARD_RECENT_AUTH_MINUTES` | Optional | `10` | Fresh password/MFA verification window, integer `1`–`60`. | +| `MIRA_DASHBOARD_TRUSTED_PROXY_IPS` | Optional | none | Trusted proxy IPs. Only use if the proxy strips or overwrites untrusted forwarding headers. | +| `OPENCLAW_GATEWAY_URL` | Optional | `ws://127.0.0.1:18789` | Gateway WebSocket URL. | See [Auth and trust boundaries](../security/auth-and-trust-boundaries.md) for route auth, scope names, token generation, two-step login, proxy trust, @@ -89,56 +82,50 @@ password-hashed recovery validators need no equivalent decryption key. ## Execution Roles And Resource Scopes -| Variable | Production value | Purpose | -| ---------------------------------- | ------------------- | ------------------------------------------------------------------------------------------ | -| `MIRA_DASHBOARD_EXECUTION_ROLE` | `web` / `worker` | Keeps HTTP/WebSocket handling in the web unit and scheduler/executor work in the worker. | -| `MIRA_DASHBOARD_ENABLE_JOB_SCOPES` | `1` in both units | Runs classified job children in constrained transient user scopes. | -| `MIRA_DASHBOARD_JOB_SCOPE_OWNER` | owning service unit | Binds transient scopes to their service lifecycle so restarts terminate orphaned children. | -| `MIRA_DASHBOARD_DISABLE_SCHEDULER` | unset in production | Development/test escape hatch; `1` disables scheduler/executor startup. | +Execution policy is code, not environment configuration. The web unit runs +`dist/serverStart.js`; the worker unit runs `dist/workerStart.js`. In +production, classified job children automatically run in constrained transient +scopes bound to `mira-dashboard-worker.service`. Local development uses the +combined server entry point and automatically selects the isolated job profile +when dev safe mode is active. -The tracked systemd units set these orchestration values directly and preserve -them, together with `NODE_ENV` and the managed state/release paths, through -Doppler. Doppler remains the source of auth, origin, provider, and credential -values. Production actions run in the worker, so their child scopes bind to -`mira-dashboard-worker.service`; restarting only the web unit leaves them -untouched. +The tracked systemd units therefore preserve only `NODE_ENV` and +`MIRA_DASHBOARD_PROJECT_ROOT` through Doppler. Doppler remains the source of +auth, origin, provider, and credential values. ## GitHub And PR Operations -| Variable | Required for | Purpose | -| --------------------------- | ---------------------------------------- | --------------------------------------------------------------- | -| `MIRA_GITHUB_TOKEN` | PR list/approve/reject/deploy operations | Preferred GitHub token for agent-owned Dashboard operations. | -| `MIRA_GITHUB_TOKEN_*` | Optional | Additional token candidates picked up by PR services. | -| `RAJOHAN_GITHUB_TOKEN` | Review/deploy flows | Raymond-owner token for operations that need owner permissions. | -| `RAJOHAN_GITHUB_USERNAME` | Optional | Reviewer username override. | -| `GITHUB_TOKEN` / `GH_TOKEN` | Fallback | Used only after preferred tokens. | -| `BUN_BINARY` | Optional | Overrides Bun executable for deploy/log-rotation jobs. | +| Variable | Required for | Purpose | +| --------------------------- | -------------------------------------- | --------------------------------------------------------------------------------------------- | +| `MIRA_GITHUB_TOKEN` | PR list/merge/reject/deploy operations | Preferred GitHub token for agent-owned Dashboard operations. | +| `RAJOHAN_GITHUB_TOKEN` | PR review approval | Approves Mira-authored PR reviews as `rajohan`; merge/deploy continues to use the Mira token. | +| `GITHUB_TOKEN` / `GH_TOKEN` | Fallback | Used only after the Mira token. | Do not expose these values in logs, docs, PR bodies, or reports. +Managed jobs always reuse the absolute Bun executable running the Dashboard. +There is no executable-path environment override. + ## Docker Updater | Variable | Required for | Purpose | | -------------------------------------------- | ----------------------------------------------- | -------------------------------------------------------------------------------------------------------- | | `MIRA_DOCKER_COMPOSE_WRAPPER` | Docker update execution | Compose wrapper path. Production commonly uses a Doppler-aware wrapper under `/opt/docker/bin`. | -| `MIRA_DOCKER_UPDATER_PLATFORM` | Optional | Overrides host Docker platform selection. | -| `MIRA_DOCKER_UPDATER_SKIP_REGISTRY` | Optional | Set `1` to skip registry checks. Useful for tests/debugging only. | | `DOCKER_LOGIN` | Docker Hub private/rate-limited registry access | Docker Hub username. Required together with `DOCKER_TOKEN`; token alone is not used for Docker Hub auth. | | `DOCKER_TOKEN` | Docker Hub private/rate-limited registry access | Docker Hub token. Required together with `DOCKER_LOGIN`. | | `MIRA_GITHUB_USERNAME` / `MIRA_GITHUB_TOKEN` | GHCR access | Auth for GHCR tag/digest lookup where needed. | ## External Feature Providers -| Variable | Required for | Purpose | -| ------------------------------------------------- | ----------------------- | ---------------------------------------------------- | -| `MOLTBOOK_API_KEY` | Moltbook cache/features | Authenticates Moltbook API requests. | -| `ELEVENLABS_API_KEY` | STT/TTS | ElevenLabs speech-to-text and text-to-speech. | -| `ELEVENLABS_STT_MODEL` | Optional | Defaults to `scribe_v2`. | -| `ELEVENLABS_STT_LANGUAGE` | Optional | Defaults to `nor`; use `auto` to omit language code. | -| `ELEVENLABS_TTS_MODEL` | Optional | Defaults to `eleven_turbo_v2_5`. | -| `ELEVENLABS_TTS_VOICE_ID` / `ELEVENLABS_VOICE_ID` | TTS | Voice ID for `/api/tts/speak`. | -| `OPENROUTER_API_KEY` | Cache/provider checks | Used by quota/cache refresh services. | -| `SYNTHETIC_API_KEY` | Synthetic cache checks | Used by cache refresh services. | +| Variable | Required for | Purpose | +| -------------------- | ----------------------- | --------------------------------------------- | +| `MOLTBOOK_API_KEY` | Moltbook cache/features | Authenticates Moltbook API requests. | +| `ELEVENLABS_API_KEY` | STT/TTS | ElevenLabs speech-to-text and text-to-speech. | +| `OPENROUTER_API_KEY` | Cache/provider checks | Used by quota/cache refresh services. | +| `SYNTHETIC_API_KEY` | Synthetic cache checks | Used by cache refresh services. | + +The ElevenLabs STT model/language and TTS model/voice are product constants in +code. Changing them is a reviewed code change, not a Doppler setting. ## Database Overview Integration @@ -163,28 +150,32 @@ child environment forwards the two auth timing values unchanged, uses the production RP ID only to decide whether copied WebAuthn public credentials are compatible, and does not inherit other provider or host credentials. -| Variable | Default | Purpose | -| --------------------------------------------- | -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | -| `MIRA_DASHBOARD_DEV_FRONTEND_PORT` | `5173` | Frontend hot-reload port. | -| `MIRA_DASHBOARD_DEV_BACKEND_PORT` | `3101` | Backend restart-on-change port. | -| `MIRA_DASHBOARD_DEV_HOT_RELOAD` | `1` when unset or empty | Accepts only `0` or `1`; enables frontend HMR and backend/frontend source watchers, while managed PR dev sets `0`. | -| `MIRA_DASHBOARD_DEV_PUBLIC_ORIGIN` | `http://localhost:5173` | Cookie/WebAuthn origin; remote dev derives the Tailscale HTTPS origin. | -| `MIRA_DASHBOARD_DEV_SOURCE_WEBAUTHN_RP_ID` | production `MIRA_DASHBOARD_WEBAUTHN_RP_ID` | Source snapshot RP used to retain or remove copied WebAuthn public credentials. | -| `MIRA_DASHBOARD_DEV_STATE_ROOT` | `~/projects/mira-dashboard/development/state/local` | Owner-only isolated development state. | -| `MIRA_DASHBOARD_DEV_DB_SOURCE` | `~/projects/mira-dashboard/production/state/mira-dashboard.db` | Production database used only to create a scrubbed WAL-consistent snapshot. | -| `MIRA_DASHBOARD_DEV_RELEASES_SOURCE` | `~/projects/mira-dashboard/production/releases` | Managed releases copied into isolated state. | -| `MIRA_DASHBOARD_DEV_WORKSPACE_SOURCE` | `~/.openclaw/workspace` | Workspace copied with secret and symlink filtering. | -| `MIRA_DASHBOARD_DEV_OPENCLAW_CONFIG_SOURCE` | `~/.openclaw/openclaw.json` | Source for sanitized agent-only development config. | -| `MIRA_DASHBOARD_DEV_GATEWAY_URL` | `ws://127.0.0.1:18789` | Live production Gateway used by trusted dev. | -| `MIRA_DASHBOARD_DEV_GATEWAY_TOKEN_FILE` | none | Optional owner-only token file; local commands normally use Doppler environment. | -| `MIRA_DASHBOARD_PREVIEW_ROOT` | `/development/state/preview` | Optional nonstandard host state override; shared install cache and isolated per-PR state live here. | -| `MIRA_DASHBOARD_PREVIEW_WORKTREE_PATH` | `/development/preview` | Optional nonstandard checkout override for the single managed PR-dev slot. | -| `MIRA_DASHBOARD_PREVIEW_GATEWAY_URL` | `ws://127.0.0.1:18789` | Production Gateway used only by the host-owned PR-dev capability proxy. | -| `MIRA_DASHBOARD_PREVIEW_GATEWAY_TOKEN_FILE` | `/gateway.token` | Disposable `0600` proxy credential mounted read-only into trusted PR dev. | -| `MIRA_DASHBOARD_PREVIEW_GATEWAY_PROXY_PORT` | `18790` | Loopback-only host proxy port; must differ from frontend/backend ports. | -| `MIRA_DASHBOARD_PREVIEW_GATEWAY_PROXY_UNIT` | `mira-dashboard-pr-preview-gateway.service` | Transient proxy unit name; no permanent systemd unit file is installed. | -| `MIRA_DASHBOARD_PREVIEW_OPENCLAW_SOURCE_ROOT` | `/home/ubuntu/.openclaw` | Source root for managed PR workspace/config snapshots. | -| `HOST` / `PORT` / `DASHBOARD_API_TARGET` | `127.0.0.1` / `5173` / `http://127.0.0.1:3101` | Child frontend bind and exact backend proxy target. | +| Variable | Default | Purpose | +| ------------------------------------------- | -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | +| `MIRA_DASHBOARD_DEV_FRONTEND_PORT` | `5173` | Frontend hot-reload port. | +| `MIRA_DASHBOARD_DEV_BACKEND_PORT` | `3101` | Backend restart-on-change port. | +| `MIRA_DASHBOARD_DEV_HOT_RELOAD` | `1` when unset or empty | Accepts only `0` or `1`; enables frontend HMR and backend/frontend source watchers, while managed PR dev sets `0`. | +| `MIRA_DASHBOARD_DEV_PUBLIC_ORIGIN` | `http://localhost:5173` | Cookie/WebAuthn origin; remote dev derives the Tailscale HTTPS origin. | +| `MIRA_DASHBOARD_DEV_STATE_ROOT` | `~/projects/mira-dashboard/development/state/local` | Owner-only isolated development state. | +| `MIRA_DASHBOARD_DEV_DB_SOURCE` | `~/projects/mira-dashboard/production/state/mira-dashboard.db` | Production database used only to create a scrubbed WAL-consistent snapshot. | +| `MIRA_DASHBOARD_DEV_RELEASES_SOURCE` | `~/projects/mira-dashboard/production/releases` | Managed releases copied into isolated state. | +| `MIRA_DASHBOARD_DEV_WORKSPACE_SOURCE` | `~/.openclaw/workspace` | Workspace copied with secret and symlink filtering. | +| `MIRA_DASHBOARD_DEV_OPENCLAW_CONFIG_SOURCE` | `~/.openclaw/openclaw.json` | Source for sanitized agent-only development config. | +| `MIRA_DASHBOARD_DEV_GATEWAY_URL` | `ws://127.0.0.1:18789` | Live production Gateway used by trusted dev. | +| `MIRA_DASHBOARD_DEV_GATEWAY_TOKEN_FILE` | none | Optional owner-only token file; local commands normally use Doppler environment. | +| `HOST` / `PORT` / `DASHBOARD_API_TARGET` | `127.0.0.1` / `5173` / `http://127.0.0.1:3101` | Child frontend bind and exact backend proxy target. | + +Managed PR dev has one fixed slot. Its checkout/state paths derive from +`MIRA_DASHBOARD_PROJECT_ROOT`; frontend `5173`, backend `3101`, proxy `18790`, +and transient unit names are code constants. `OPENCLAW_GATEWAY_URL` selects the +upstream Gateway when its default loopback URL is not sufficient. The trusted +GitHub authors (`mira-2026` and `rajohan`) are code constants too. + +Variables such as `MIRA_DASHBOARD_DEV_STATE_ROOT`, +`MIRA_DASHBOARD_DEV_GATEWAY_TOKEN_FILE`, and the +`MIRA_DASHBOARD_PREVIEW_GATEWAY_*` values passed with `systemd-run --setenv` +are internal subprocess contracts. The Dashboard creates them from its resolved +configuration; they are not Doppler/operator settings. See [Local development](../development/local-dev.md) for snapshot contents, blocked production mutations, cookie isolation, and the managed trusted-PR diff --git a/scripts/developmentTailscale.ts b/scripts/developmentTailscale.ts index a8f48b014..936fe70f2 100644 --- a/scripts/developmentTailscale.ts +++ b/scripts/developmentTailscale.ts @@ -223,8 +223,6 @@ async function main(): Promise { const route = await enableDevelopmentServe(port); const config = resolveDevelopmentStackConfig({ ...process.env, - MIRA_DASHBOARD_DEV_BACKEND_HOST: "127.0.0.1", - MIRA_DASHBOARD_DEV_FRONTEND_HOST: "127.0.0.1", MIRA_DASHBOARD_DEV_PUBLIC_ORIGIN: route.status.origin, }); try { diff --git a/systemd/mira-dashboard-worker.service b/systemd/mira-dashboard-worker.service index 335ffaf27..678514a0b 100644 --- a/systemd/mira-dashboard-worker.service +++ b/systemd/mira-dashboard-worker.service @@ -8,11 +8,8 @@ Type=simple UMask=0077 WorkingDirectory=%h/projects/mira-dashboard/production/releases/current/backend Environment=NODE_ENV=production -Environment=MIRA_DASHBOARD_EXECUTION_ROLE=worker -Environment=MIRA_DASHBOARD_ENABLE_JOB_SCOPES=1 -Environment=MIRA_DASHBOARD_JOB_SCOPE_OWNER=mira-dashboard-worker.service Environment=MIRA_DASHBOARD_PROJECT_ROOT=/home/ubuntu/projects/mira-dashboard -ExecStart=/usr/local/bin/doppler run --config prd --project rajohan --preserve-env=NODE_ENV,MIRA_DASHBOARD_EXECUTION_ROLE,MIRA_DASHBOARD_ENABLE_JOB_SCOPES,MIRA_DASHBOARD_JOB_SCOPE_OWNER,MIRA_DASHBOARD_PROJECT_ROOT -- /home/ubuntu/.bun/bin/bun dist/workerStart.js +ExecStart=/usr/local/bin/doppler run --config prd --project rajohan --preserve-env=NODE_ENV,MIRA_DASHBOARD_PROJECT_ROOT -- /home/ubuntu/.bun/bin/bun dist/workerStart.js Restart=on-failure RestartSec=5 KillMode=control-group diff --git a/systemd/mira-dashboard.service b/systemd/mira-dashboard.service index ee9626f74..23e907647 100644 --- a/systemd/mira-dashboard.service +++ b/systemd/mira-dashboard.service @@ -8,11 +8,8 @@ Type=simple UMask=0077 WorkingDirectory=%h/projects/mira-dashboard/production/releases/current/backend Environment=NODE_ENV=production -Environment=MIRA_DASHBOARD_EXECUTION_ROLE=web -Environment=MIRA_DASHBOARD_ENABLE_JOB_SCOPES=1 -Environment=MIRA_DASHBOARD_JOB_SCOPE_OWNER=mira-dashboard.service Environment=MIRA_DASHBOARD_PROJECT_ROOT=/home/ubuntu/projects/mira-dashboard -ExecStart=/usr/local/bin/doppler run --config prd --project rajohan --preserve-env=NODE_ENV,MIRA_DASHBOARD_EXECUTION_ROLE,MIRA_DASHBOARD_ENABLE_JOB_SCOPES,MIRA_DASHBOARD_JOB_SCOPE_OWNER,MIRA_DASHBOARD_PROJECT_ROOT -- /home/ubuntu/.bun/bin/bun dist/serverStart.js +ExecStart=/usr/local/bin/doppler run --config prd --project rajohan --preserve-env=NODE_ENV,MIRA_DASHBOARD_PROJECT_ROOT -- /home/ubuntu/.bun/bin/bun dist/serverStart.js Restart=on-failure RestartSec=5 KillMode=control-group From 55e04271bc6a5034d86987c1ae67042b8e3a9fb1 Mon Sep 17 00:00:00 2001 From: mira-2026 Date: Mon, 27 Jul 2026 23:32:01 +0200 Subject: [PATCH 4/9] fix: address deployment review findings --- .../0007DeploymentRetentionIndex.ts | 13 ++ backend/src/databaseMigrations/index.ts | 2 + backend/src/databaseSchemaCompatibility.ts | 2 +- backend/src/development/developmentStack.ts | 3 + backend/src/lib/processes.ts | 5 + backend/src/routes/sttRoutes.ts | 2 - backend/src/services/logRotation.ts | 6 +- backend/src/services/pullRequests.ts | 5 +- backend/test/databaseLifecycle.test.ts | 115 ++++++++++++++---- backend/test/developmentStack.test.ts | 31 ++++- backend/test/httpApiBehavior.test.ts | 4 + backend/test/releaseManager.test.ts | 78 ++++++------ docs/setup/secrets-and-env.md | 47 +++---- 13 files changed, 215 insertions(+), 98 deletions(-) create mode 100644 backend/src/databaseMigrations/0007DeploymentRetentionIndex.ts diff --git a/backend/src/databaseMigrations/0007DeploymentRetentionIndex.ts b/backend/src/databaseMigrations/0007DeploymentRetentionIndex.ts new file mode 100644 index 000000000..5090b8f30 --- /dev/null +++ b/backend/src/databaseMigrations/0007DeploymentRetentionIndex.ts @@ -0,0 +1,13 @@ +import type { DatabaseMigration } from "./types.ts"; + +export const deploymentRetentionIndexMigration: DatabaseMigration = { + version: 7, + name: "deployment-retention-index", + sql: ` +DROP INDEX IF EXISTS idx_deployment_jobs_retention; + +CREATE INDEX idx_deployment_jobs_retention + ON deployment_jobs(started_at DESC, id DESC, status) + WHERE status NOT IN ('building', 'verifying'); +`, +}; diff --git a/backend/src/databaseMigrations/index.ts b/backend/src/databaseMigrations/index.ts index 62739bb73..3162beaa5 100644 --- a/backend/src/databaseMigrations/index.ts +++ b/backend/src/databaseMigrations/index.ts @@ -4,6 +4,7 @@ import { sessionValidatorHashMigration } from "./0003SessionValidatorHash.ts"; import { maintenanceCoverageMigration } from "./0004MaintenanceCoverage.ts"; import { auditEventsMigration } from "./0005AuditEvents.ts"; import { multiFactorAuthenticationMigration } from "./0006MultiFactorAuthentication.ts"; +import { deploymentRetentionIndexMigration } from "./0007DeploymentRetentionIndex.ts"; import type { DatabaseMigration } from "./types.ts"; export const databaseMigrations: readonly DatabaseMigration[] = [ @@ -13,6 +14,7 @@ export const databaseMigrations: readonly DatabaseMigration[] = [ maintenanceCoverageMigration, auditEventsMigration, multiFactorAuthenticationMigration, + deploymentRetentionIndexMigration, ]; export interface DatabaseMigrationIdentity { diff --git a/backend/src/databaseSchemaCompatibility.ts b/backend/src/databaseSchemaCompatibility.ts index 597da3ae8..d7147d11a 100644 --- a/backend/src/databaseSchemaCompatibility.ts +++ b/backend/src/databaseSchemaCompatibility.ts @@ -8,7 +8,7 @@ const CURRENT_DATABASE_SCHEMA_VERSION = databaseMigrations.at(-1)?.version ?? 0; * previous release has left the rollback window. */ export const DASHBOARD_DATABASE_SCHEMA_COMPATIBILITY = Object.freeze({ - maximum: 6, + maximum: 7, minimum: 6, target: CURRENT_DATABASE_SCHEMA_VERSION, }); diff --git a/backend/src/development/developmentStack.ts b/backend/src/development/developmentStack.ts index f32e2a558..77e7aad03 100644 --- a/backend/src/development/developmentStack.ts +++ b/backend/src/development/developmentStack.ts @@ -574,6 +574,9 @@ function backfillCompletedDeploymentHistory( if (!hasTable(target, "deployment_jobs")) return; target.run("BEGIN IMMEDIATE"); try { + if (hasTable(target, "deployment_lock")) { + target.run("DELETE FROM deployment_lock"); + } target.run( "DELETE FROM deployment_jobs WHERE status NOT IN ('isOk', 'failed')" ); diff --git a/backend/src/lib/processes.ts b/backend/src/lib/processes.ts index ea1e268cb..8b5549657 100644 --- a/backend/src/lib/processes.ts +++ b/backend/src/lib/processes.ts @@ -21,6 +21,11 @@ export type BunProcess = ReturnType; const DEFAULT_MAX_BUFFER = 10 * 1024 * 1024; const DEFAULT_FORCE_KILL_GRACE_MS = 3000; +/** Returns the absolute Bun executable already running the Dashboard process. */ +export function resolveBunExecutable(): string { + return process.execPath; +} + async function readProcessText( stream: ReadableStream | undefined, maxBuffer: number diff --git a/backend/src/routes/sttRoutes.ts b/backend/src/routes/sttRoutes.ts index 2d5cc2d23..844c070d9 100644 --- a/backend/src/routes/sttRoutes.ts +++ b/backend/src/routes/sttRoutes.ts @@ -5,7 +5,6 @@ const MAX_AUDIO_BYTES = 20 * 1024 * 1024; const ELEVENLABS_TIMEOUT_MS = 60_000; const ELEVENLABS_API_URL = "https://api.elevenlabs.io/v1/speech-to-text"; const ELEVENLABS_STT_MODEL = "scribe_v2"; -const ELEVENLABS_STT_LANGUAGE = "nor"; const sttRouteState: { activeTranscriptionToken?: string } = {}; @@ -65,7 +64,6 @@ async function transcribeWithElevenLabs( formData.append("model_id", ELEVENLABS_STT_MODEL); formData.append("tag_audio_events", "false"); formData.append("diarize", "false"); - formData.append("language_code", ELEVENLABS_STT_LANGUAGE); try { try { diff --git a/backend/src/services/logRotation.ts b/backend/src/services/logRotation.ts index 9cb202a54..2030f15ab 100644 --- a/backend/src/services/logRotation.ts +++ b/backend/src/services/logRotation.ts @@ -7,7 +7,7 @@ import { resolveDashboardProjectPathsForRuntime, resolveDashboardRuntimePath, } from "../lib/dashboardPaths.ts"; -import { runProcess } from "../lib/processes.ts"; +import { resolveBunExecutable, runProcess } from "../lib/processes.ts"; import { resolveAbsoluteNonRootPath } from "../lib/safePath.ts"; import { writeCacheSuccess } from "./cacheEntryWriter.ts"; import { @@ -149,10 +149,6 @@ function defaultConfigPath(): string { return BUNDLED_CONFIG_PATH; } -function resolveBunExecutable(): string { - return process.execPath; -} - function fileHandleReadableStream( handle: fs.FileHandle, size: number diff --git a/backend/src/services/pullRequests.ts b/backend/src/services/pullRequests.ts index 7b3c701b1..916ba78e4 100644 --- a/backend/src/services/pullRequests.ts +++ b/backend/src/services/pullRequests.ts @@ -6,6 +6,7 @@ import { errorMessage } from "../lib/errors.ts"; import { killProcessGroup, pipeProcessOutput, + resolveBunExecutable, runProcess, spawnProcess, } from "../lib/processes.ts"; @@ -103,10 +104,6 @@ const publicPullRequestCache: { value?: { expiresAt: number; pullRequests: PullRequestSummary[] }; } = {}; -function resolveBunExecutable(): string { - return process.execPath; -} - export function getResolvedRoots() { return { dashboardRoot: getDashboardRoot(), diff --git a/backend/test/databaseLifecycle.test.ts b/backend/test/databaseLifecycle.test.ts index 4f3a5254c..c47bf6187 100644 --- a/backend/test/databaseLifecycle.test.ts +++ b/backend/test/databaseLifecycle.test.ts @@ -110,10 +110,10 @@ describe("Dashboard SQLite lifecycle", () => { const first = applyDatabaseMigrations(database, databasePath); const second = applyDatabaseMigrations(database, databasePath); - expect(first.applied).toEqual([1, 2, 3, 4, 5, 6]); + expect(first.applied).toEqual([1, 2, 3, 4, 5, 6, 7]); expect(first.backup).toBeUndefined(); expect(second).toEqual({ applied: [] }); - expect(validateDatabaseMigrationHistory(database)).toBe(6); + expect(validateDatabaseMigrationHistory(database)).toBe(7); expect( database .query("SELECT name FROM pragma_table_info('auth_sessions')") @@ -155,7 +155,7 @@ describe("Dashboard SQLite lifecycle", () => { usage: "USING COVERING INDEX", sql: `SELECT id FROM deployment_jobs - WHERE status NOT IN ('building', 'restart-scheduled') + WHERE status NOT IN ('building', 'verifying') ORDER BY started_at DESC, id DESC LIMIT -1 OFFSET 500`, }, @@ -246,7 +246,7 @@ describe("Dashboard SQLite lifecycle", () => { } }); - it("upgrades an existing version 3 database with migrations 4 through 6", () => { + it("upgrades an existing version 3 database with migrations 4 through 7", () => { const root = temporaryRoot("mira-db-migrations-v3-"); const databasePath = path.join(root, "dashboard.db"); const database = openWalDatabase(databasePath); @@ -255,9 +255,9 @@ describe("Dashboard SQLite lifecycle", () => { expect(validateDatabaseMigrationHistory(database)).toBe(3); expect(migrateDisposableDatabaseCopy(database)).toEqual({ - applied: [4, 5, 6], + applied: [4, 5, 6, 7], }); - expect(validateDatabaseMigrationHistory(database)).toBe(6); + expect(validateDatabaseMigrationHistory(database)).toBe(7); expect( database .query( @@ -289,7 +289,7 @@ describe("Dashboard SQLite lifecycle", () => { } }); - it("upgrades an existing version 4 database with migrations 5 and 6", () => { + it("upgrades an existing version 4 database with migrations 5 through 7", () => { const root = temporaryRoot("mira-db-migrations-v4-"); const databasePath = path.join(root, "dashboard.db"); const database = openWalDatabase(databasePath); @@ -298,9 +298,9 @@ describe("Dashboard SQLite lifecycle", () => { expect(validateDatabaseMigrationHistory(database)).toBe(4); expect(migrateDisposableDatabaseCopy(database)).toEqual({ - applied: [5, 6], + applied: [5, 6, 7], }); - expect(validateDatabaseMigrationHistory(database)).toBe(6); + expect(validateDatabaseMigrationHistory(database)).toBe(7); expect( database .query( @@ -370,9 +370,9 @@ describe("Dashboard SQLite lifecycle", () => { database.query("SELECT COUNT(*) AS count FROM auth_sessions").get() ).toEqual({ count: 2 }); expect(migrateDisposableDatabaseCopy(database)).toEqual({ - applied: [6], + applied: [6, 7], }); - expect(validateDatabaseMigrationHistory(database)).toBe(6); + expect(validateDatabaseMigrationHistory(database)).toBe(7); expect( database.query("SELECT COUNT(*) AS count FROM auth_sessions").get() ).toEqual({ count: 0 }); @@ -381,6 +381,56 @@ describe("Dashboard SQLite lifecycle", () => { } }); + it("replaces the legacy deployment retention index when upgrading version 6", () => { + const root = temporaryRoot("mira-db-migrations-v6-retention-"); + const databasePath = path.join(root, "dashboard.db"); + const database = openWalDatabase(databasePath); + try { + seedMigrationVersion(database, 6); + const legacyIndex = database + .query( + `SELECT sql + FROM sqlite_schema + WHERE type = 'index' + AND name = 'idx_deployment_jobs_retention'` + ) + .get() as { sql: string }; + expect(legacyIndex.sql).toContain("'restart-scheduled'"); + + expect(migrateDisposableDatabaseCopy(database)).toEqual({ + applied: [7], + }); + expect(validateDatabaseMigrationHistory(database)).toBe(7); + + const currentIndex = database + .query( + `SELECT sql + FROM sqlite_schema + WHERE type = 'index' + AND name = 'idx_deployment_jobs_retention'` + ) + .get() as { sql: string }; + expect(currentIndex.sql).toContain("'verifying'"); + expect(currentIndex.sql).not.toContain("'restart-scheduled'"); + + const plan = database + .query( + `EXPLAIN QUERY PLAN + SELECT id + FROM deployment_jobs + WHERE status NOT IN ('building', 'verifying') + ORDER BY started_at DESC, id DESC + LIMIT -1 OFFSET 500` + ) + .all() as Array<{ detail: string }>; + expect(plan.map((row) => row.detail).join("\n")).toContain( + "USING COVERING INDEX idx_deployment_jobs_retention" + ); + } finally { + database.close(); + } + }); + it("preserves append-only audit history during automated maintenance", () => { const root = temporaryRoot("mira-db-audit-retention-"); const databasePath = path.join(root, "dashboard.db"); @@ -453,7 +503,7 @@ describe("Dashboard SQLite lifecycle", () => { try { expect( database.query("SELECT COUNT(*) AS count FROM schema_migrations").get() - ).toEqual({ count: 6 }); + ).toEqual({ count: 7 }); } finally { database.close(); } @@ -479,7 +529,7 @@ describe("Dashboard SQLite lifecycle", () => { ); const result = applyDatabaseMigrations(database, databasePath); - expect(result.applied).toEqual([1, 2, 3, 4, 5, 6]); + expect(result.applied).toEqual([1, 2, 3, 4, 5, 6, 7]); expect(result.backup).toMatchObject({ kind: "pre-migration", restoreVerified: true, @@ -533,23 +583,23 @@ describe("Dashboard SQLite lifecycle", () => { .prepare( `INSERT INTO schema_migrations ( version, name, checksum, applied_at - ) VALUES (7, 'unknown', 'unknown', ?)` + ) VALUES (8, 'unknown', 'unknown', ?)` ) .run("2026-07-23T00:00:00.000Z"); expect(() => validateDatabaseMigrationHistory(database)).toThrow( - "incompatible SQLite migration version 7" + "incompatible SQLite migration version 8" ); database .prepare( - "UPDATE schema_migrations SET name = ?, checksum = ? WHERE version = 7" + "UPDATE schema_migrations SET name = ?, checksum = ? WHERE version = 8" ) .run("future-additive", "a".repeat(64)); - expect(validateDatabaseMigrationHistory(database, 7)).toBe(7); - expect(() => validateDatabaseMigrationHistory(database, 6)).toThrow( - "incompatible SQLite migration version 7" + expect(validateDatabaseMigrationHistory(database, 8)).toBe(8); + expect(() => validateDatabaseMigrationHistory(database, 7)).toThrow( + "incompatible SQLite migration version 8" ); - database.prepare("DELETE FROM schema_migrations WHERE version = 7").run(); + database.prepare("DELETE FROM schema_migrations WHERE version = 8").run(); database.prepare("DELETE FROM schema_migrations WHERE version = 2").run(); expect(() => validateDatabaseMigrationHistory(database)).toThrow( "not contiguous" @@ -632,8 +682,8 @@ describe("Dashboard SQLite lifecycle", () => { expect(preflightResult).toMatchObject({ backup: { kind: "pre-deploy", restoreVerified: true }, migrationTest: { - applied: [1, 2, 3, 4, 5, 6], - currentVersion: 6, + applied: [1, 2, 3, 4, 5, 6, 7], + currentVersion: 7, }, }); expect(getSqliteBackupInventory(databasePath).count).toBe(1); @@ -884,9 +934,21 @@ describe("Dashboard SQLite lifecycle", () => { id, status, started_at, updated_at ) VALUES ('old-deployment', 'isOk', ?, ?), - ('current-deployment', 'isOk', ?, ?)` + ('current-deployment', 'isOk', ?, ?), + ('active-deployment', 'verifying', ?, ?), + ('legacy-restart-scheduled-deployment', + 'restart-scheduled', ?, ?)` ) - .run(oldTimestamp, oldTimestamp, currentTimestamp, currentTimestamp); + .run( + oldTimestamp, + oldTimestamp, + currentTimestamp, + currentTimestamp, + oldTimestamp, + oldTimestamp, + oldTimestamp, + oldTimestamp + ); database .prepare( `INSERT INTO agent_task_history ( @@ -977,7 +1039,7 @@ describe("Dashboard SQLite lifecycle", () => { expect(changes).toMatchObject({ agentTaskHistory: 1, authSessions: 1, - deploymentJobs: 1, + deploymentJobs: 2, dockerUpdateEvents: 1, jobExecutions: 1, jobWorkers: 1, @@ -994,6 +1056,9 @@ describe("Dashboard SQLite lifecycle", () => { expect( database.query("SELECT id FROM job_executions ORDER BY id").all() ).toEqual([{ id: "current-execution" }]); + expect( + database.query("SELECT id FROM deployment_jobs ORDER BY id").all() + ).toEqual([{ id: "active-deployment" }, { id: "current-deployment" }]); expect(database.query("SELECT task FROM agent_task_history").all()).toEqual([ { task: "Current" }, ]); diff --git a/backend/test/developmentStack.test.ts b/backend/test/developmentStack.test.ts index a2db9dd89..5f39826ca 100644 --- a/backend/test/developmentStack.test.ts +++ b/backend/test/developmentStack.test.ts @@ -82,7 +82,10 @@ function createSnapshotSource(databasePath: string): void { CREATE TABLE auth_sessions (id TEXT PRIMARY KEY); CREATE TABLE auth_pending_logins (id TEXT PRIMARY KEY); CREATE TABLE app_config (key TEXT PRIMARY KEY, value TEXT NOT NULL); - CREATE TABLE deployment_lock (id INTEGER PRIMARY KEY); + CREATE TABLE deployment_lock ( + id INTEGER PRIMARY KEY, + job_id TEXT NOT NULL + ); CREATE TABLE deployment_jobs ( id TEXT PRIMARY KEY, status TEXT NOT NULL, @@ -166,7 +169,9 @@ function createSnapshotSource(databasePath: string): void { NULL ) `); - database.run("INSERT INTO deployment_lock (id) VALUES (1)"); + database.run( + "INSERT INTO deployment_lock (id, job_id) VALUES (1, 'deployment-active')" + ); database.run(` INSERT INTO scheduled_jobs (id, enabled, action_key, next_run_at) VALUES @@ -498,6 +503,23 @@ describe("development stack", () => { ); const legacySnapshot = new Database(config.databasePath); legacySnapshot.run("DELETE FROM deployment_jobs"); + legacySnapshot.run(` + INSERT INTO deployment_jobs ( + id, + status, + started_at, + updated_at + ) + VALUES ( + 'legacy-active-deployment', + 'verifying', + '2026-01-03T00:00:00.000Z', + '2026-01-03T00:01:00.000Z' + ) + `); + legacySnapshot.run( + "INSERT INTO deployment_lock (id, job_id) VALUES (1, 'legacy-active-deployment')" + ); legacySnapshot.close(); expect(prepareDevelopmentState(config)).toEqual({ database: "reused", @@ -512,6 +534,11 @@ describe("development stack", () => { .query("SELECT id, status FROM deployment_jobs ORDER BY id") .all() ).toEqual([{ id: "deployment", status: "isOk" }]); + expect( + backfilledSnapshot + .query("SELECT id, job_id FROM deployment_lock ORDER BY id") + .all() + ).toEqual([]); backfilledSnapshot.close(); resetDevelopmentState(config); diff --git a/backend/test/httpApiBehavior.test.ts b/backend/test/httpApiBehavior.test.ts index 030202614..0b6d72083 100644 --- a/backend/test/httpApiBehavior.test.ts +++ b/backend/test/httpApiBehavior.test.ts @@ -2072,6 +2072,10 @@ describe("Mira Dashboard backend integration", () => { expect.stringContaining("/text-to-speech/"), "https://api.elevenlabs.io/v1/speech-to-text", ]); + const sttRequestBody = providerCalls.at(-1)?.body; + expect(sttRequestBody).toBeInstanceOf(FormData); + expect((sttRequestBody as FormData).get("model_id")).toBe("scribe_v2"); + expect((sttRequestBody as FormData).get("language_code")).toBeNull(); } finally { Object.defineProperty(globalThis, "fetch", { configurable: true, diff --git a/backend/test/releaseManager.test.ts b/backend/test/releaseManager.test.ts index 981fdc436..28f87e026 100644 --- a/backend/test/releaseManager.test.ts +++ b/backend/test/releaseManager.test.ts @@ -56,16 +56,16 @@ const SECOND_COMMIT = "b".repeat(40); const THIRD_COMMIT = "c".repeat(40); const FOURTH_COMMIT = "d".repeat(40); const TEST_FUTURE_MIGRATIONS: DatabaseMigrationIdentity[] = [ - { - checksum: "7".repeat(64), - name: "test-migration-7", - version: 7, - }, { checksum: "8".repeat(64), name: "test-migration-8", version: 8, }, + { + checksum: "9".repeat(64), + name: "test-migration-9", + version: 9, + }, ]; function testLiveSchemaState( @@ -655,15 +655,15 @@ describe("Dashboard immutable release manager", () => { const candidatePath = await createManagedRelease(root, SECOND_COMMIT); await rewriteManifest(candidatePath, { migrationRegistrySha256: "c".repeat(64), - schemaMaximum: 7, + schemaMaximum: 8, schemaMinimum: 6, - schemaTarget: 7, + schemaTarget: 8, }); await activateDashboardRelease(FIRST_COMMIT, root, SCHEMA_6_OPTIONS); await expect( activateDashboardRelease(SECOND_COMMIT, root, SCHEMA_6_OPTIONS) - ).rejects.toThrow("cannot roll back after SQLite schema 7"); + ).rejects.toThrow("cannot roll back after SQLite schema 8"); expect(readlinkSync(path.join(root, "current"))).toBe(`releases/${FIRST_COMMIT}`); expect(existsSync(path.join(root, "previous"))).toBe(false); }); @@ -697,27 +697,27 @@ describe("Dashboard immutable release manager", () => { const migratedPath = await createManagedRelease(root, SECOND_COMMIT); await createManagedRelease(root, THIRD_COMMIT); await rewriteManifest(rollbackPath, { - schemaMaximum: 7, + schemaMaximum: 8, }); await rewriteManifest(migratedPath, { migrationRegistrySha256: "d".repeat(64), - schemaMaximum: 7, - schemaMinimum: 6, - schemaTarget: 7, + schemaMaximum: 8, + schemaMinimum: 7, + schemaTarget: 8, }); - let liveSchemaVersion = 6; + let liveSchemaVersion = 7; const options = { readLiveSchemaState: () => testLiveSchemaState(liveSchemaVersion), }; await activateDashboardRelease(FIRST_COMMIT, root, options); await activateDashboardRelease(SECOND_COMMIT, root, options); - liveSchemaVersion = 7; + liveSchemaVersion = 8; await rollbackDashboardRelease(root, options); await expect( activateDashboardRelease(THIRD_COMMIT, root, options) - ).rejects.toThrow("Activation release cannot open live SQLite schema 7"); + ).rejects.toThrow("Activation release cannot open live SQLite schema 8"); const state = await readDashboardReleaseState(root); expect(state.current?.commitSha).toBe(FIRST_COMMIT); expect(state.previous?.commitSha).toBe(SECOND_COMMIT); @@ -728,28 +728,30 @@ describe("Dashboard immutable release manager", () => { const currentPath = await createManagedRelease(root, FIRST_COMMIT); const candidatePath = await createManagedRelease(root, SECOND_COMMIT); await rewriteManifest(currentPath, { - schemaMaximum: 7, + schemaMaximum: 8, }); await rewriteManifest(candidatePath, { migrationRegistrySha256: "d".repeat(64), - schemaMaximum: 7, - schemaMinimum: 6, - schemaTarget: 7, + schemaMaximum: 8, + schemaMinimum: 7, + schemaTarget: 8, + }); + await activateDashboardRelease(FIRST_COMMIT, root, { + readLiveSchemaState: () => testLiveSchemaState(7), }); - await activateDashboardRelease(FIRST_COMMIT, root, SCHEMA_6_OPTIONS); await expect( activateDashboardRelease(SECOND_COMMIT, root, { readLiveSchemaState: () => - testLiveSchemaState(7, { - 7: { + testLiveSchemaState(8, { + 8: { ...TEST_FUTURE_MIGRATIONS[0]!, checksum: "f".repeat(64), }, }), }) ).rejects.toThrow( - "Activation release SQLite migration 7 identity does not match live history" + "Activation release SQLite migration 8 identity does not match live history" ); }); @@ -759,12 +761,12 @@ describe("Dashboard immutable release manager", () => { const candidatePath = await createManagedRelease(root, SECOND_COMMIT); await rewriteManifest(candidatePath, { migrationRegistrySha256: "d".repeat(64), - schemaMaximum: 7, - schemaMinimum: 7, - schemaTarget: 7, + schemaMaximum: 8, + schemaMinimum: 8, + schemaTarget: 8, }); - let liveSchemaVersion = 6; + let liveSchemaVersion = 7; const options = { readLiveSchemaState: () => testLiveSchemaState(liveSchemaVersion), }; @@ -779,21 +781,21 @@ describe("Dashboard immutable release manager", () => { ); await expect( activateDashboardRelease(SECOND_COMMIT, root, options) - ).rejects.toThrow("cannot roll back after SQLite schema 7"); + ).rejects.toThrow("cannot roll back after SQLite schema 8"); await runReleaseLifecycleCommand( ["activate", SECOND_COMMIT, "--coordinated-schema-cutover"], root, options ); - liveSchemaVersion = 7; + liveSchemaVersion = 8; await expect( activateDashboardRelease(SECOND_COMMIT, root, { - readLiveSchemaState: () => testLiveSchemaState(8), + readLiveSchemaState: () => testLiveSchemaState(9), }) - ).rejects.toThrow("Activation release cannot open live SQLite schema 8"); + ).rejects.toThrow("Activation release cannot open live SQLite schema 9"); await expect(rollbackDashboardRelease(root, options)).rejects.toThrow( - "Rollback release cannot open SQLite schema 7" + "Rollback release cannot open SQLite schema 8" ); expect(readlinkSync(path.join(root, "current"))).toBe( `releases/${SECOND_COMMIT}` @@ -805,16 +807,16 @@ describe("Dashboard immutable release manager", () => { const compatibleOldPath = await createManagedRelease(root, FIRST_COMMIT); const migratedPath = await createManagedRelease(root, SECOND_COMMIT); await rewriteManifest(compatibleOldPath, { - schemaMaximum: 7, + schemaMaximum: 8, }); await rewriteManifest(migratedPath, { migrationRegistrySha256: "d".repeat(64), - schemaMaximum: 7, - schemaMinimum: 7, - schemaTarget: 7, + schemaMaximum: 8, + schemaMinimum: 8, + schemaTarget: 8, }); - let liveSchemaVersion = 6; + let liveSchemaVersion = 7; const options = { readLiveSchemaState: () => testLiveSchemaState(liveSchemaVersion), }; @@ -823,7 +825,7 @@ describe("Dashboard immutable release manager", () => { ...options, schemaCutoverMode: "coordinated", }); - liveSchemaVersion = 7; + liveSchemaVersion = 8; const oldCode = await rollbackDashboardRelease(root, options); expect(oldCode.current?.commitSha).toBe(FIRST_COMMIT); diff --git a/docs/setup/secrets-and-env.md b/docs/setup/secrets-and-env.md index 178965b93..525e8638c 100644 --- a/docs/setup/secrets-and-env.md +++ b/docs/setup/secrets-and-env.md @@ -21,12 +21,16 @@ Environment token precedence is: ## Dashboard Storage And Paths -| Variable | Required | Default | Purpose | +| Variable | Required | Fallback | Purpose | | ----------------------------- | --------------------------- | -------------------------------------- | --------------------------------------------------------------------------------------------------------- | | `MIRA_DASHBOARD_PROJECT_ROOT` | Explicit in production unit | `/home/ubuntu/projects/mira-dashboard` | Single host-layout root. Every production, state, release, preview, and worktree path is derived from it. | | `OPENCLAW_HOME` | Optional | `~/.openclaw` | Primary OpenClaw home for file/config/media/agent lookups when set. | | `WORKSPACE_ROOT` | Optional | OpenClaw workspace | Root exposed by `/api/files`. Must be absolute and normalized if set. | +Below, `` means the validated +`MIRA_DASHBOARD_PROJECT_ROOT`; the fallback is +`/home/ubuntu/projects/mira-dashboard` only when that variable is unset. + Production accepts no per-path Dashboard overrides. The service unit supplies only `MIRA_DASHBOARD_PROJECT_ROOT`; values with names such as `MIRA_DASHBOARD_DB_PATH`, `MIRA_DASHBOARD_RELEASES_ROOT`, @@ -41,10 +45,9 @@ stored in Doppler or added to a production unit. isolated development child use its private state and let tests inject temporary fixtures; they are not operator configuration. -Production mutable state lives in -`/home/ubuntu/projects/mira-dashboard/production/state`, outside both the -control checkout and immutable releases. SQLite backups live below the derived -state root. +Production mutable state lives in `/production/state`, outside +both the control checkout and immutable releases. SQLite backups live below +that derived state root. Versioned `backend/config/` files are release artifacts, not external state. `OPENCLAW_HOME` remains the primary OpenClaw installation/configuration root; `MIRA_DASHBOARD_OPENCLAW_HOME` is the separate Dashboard client identity root @@ -124,8 +127,10 @@ There is no executable-path environment override. | `OPENROUTER_API_KEY` | Cache/provider checks | Used by quota/cache refresh services. | | `SYNTHETIC_API_KEY` | Synthetic cache checks | Used by cache refresh services. | -The ElevenLabs STT model/language and TTS model/voice are product constants in -code. Changing them is a reviewed code change, not a Doppler setting. +The ElevenLabs STT model and TTS model/voice are product constants in code. STT +uses `scribe_v2` without a `language_code`, allowing ElevenLabs to detect the +spoken language automatically. Changing these settings is a reviewed code +change, not a Doppler setting. ## Database Overview Integration @@ -150,20 +155,20 @@ child environment forwards the two auth timing values unchanged, uses the production RP ID only to decide whether copied WebAuthn public credentials are compatible, and does not inherit other provider or host credentials. -| Variable | Default | Purpose | -| ------------------------------------------- | -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | -| `MIRA_DASHBOARD_DEV_FRONTEND_PORT` | `5173` | Frontend hot-reload port. | -| `MIRA_DASHBOARD_DEV_BACKEND_PORT` | `3101` | Backend restart-on-change port. | -| `MIRA_DASHBOARD_DEV_HOT_RELOAD` | `1` when unset or empty | Accepts only `0` or `1`; enables frontend HMR and backend/frontend source watchers, while managed PR dev sets `0`. | -| `MIRA_DASHBOARD_DEV_PUBLIC_ORIGIN` | `http://localhost:5173` | Cookie/WebAuthn origin; remote dev derives the Tailscale HTTPS origin. | -| `MIRA_DASHBOARD_DEV_STATE_ROOT` | `~/projects/mira-dashboard/development/state/local` | Owner-only isolated development state. | -| `MIRA_DASHBOARD_DEV_DB_SOURCE` | `~/projects/mira-dashboard/production/state/mira-dashboard.db` | Production database used only to create a scrubbed WAL-consistent snapshot. | -| `MIRA_DASHBOARD_DEV_RELEASES_SOURCE` | `~/projects/mira-dashboard/production/releases` | Managed releases copied into isolated state. | -| `MIRA_DASHBOARD_DEV_WORKSPACE_SOURCE` | `~/.openclaw/workspace` | Workspace copied with secret and symlink filtering. | -| `MIRA_DASHBOARD_DEV_OPENCLAW_CONFIG_SOURCE` | `~/.openclaw/openclaw.json` | Source for sanitized agent-only development config. | -| `MIRA_DASHBOARD_DEV_GATEWAY_URL` | `ws://127.0.0.1:18789` | Live production Gateway used by trusted dev. | -| `MIRA_DASHBOARD_DEV_GATEWAY_TOKEN_FILE` | none | Optional owner-only token file; local commands normally use Doppler environment. | -| `HOST` / `PORT` / `DASHBOARD_API_TARGET` | `127.0.0.1` / `5173` / `http://127.0.0.1:3101` | Child frontend bind and exact backend proxy target. | +| Variable | Default | Purpose | +| ------------------------------------------- | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | +| `MIRA_DASHBOARD_DEV_FRONTEND_PORT` | `5173` | Frontend hot-reload port. | +| `MIRA_DASHBOARD_DEV_BACKEND_PORT` | `3101` | Backend restart-on-change port. | +| `MIRA_DASHBOARD_DEV_HOT_RELOAD` | `1` when unset or empty | Accepts only `0` or `1`; enables frontend HMR and backend/frontend source watchers, while managed PR dev sets `0`. | +| `MIRA_DASHBOARD_DEV_PUBLIC_ORIGIN` | `http://localhost:5173` | Cookie/WebAuthn origin; remote dev derives the Tailscale HTTPS origin. | +| `MIRA_DASHBOARD_DEV_STATE_ROOT` | `/development/state/local` | Owner-only isolated development state. | +| `MIRA_DASHBOARD_DEV_DB_SOURCE` | `/production/state/mira-dashboard.db` | Production database used only to create a scrubbed WAL-consistent snapshot. | +| `MIRA_DASHBOARD_DEV_RELEASES_SOURCE` | `/production/releases` | Managed releases copied into isolated state. | +| `MIRA_DASHBOARD_DEV_WORKSPACE_SOURCE` | `~/.openclaw/workspace` | Workspace copied with secret and symlink filtering. | +| `MIRA_DASHBOARD_DEV_OPENCLAW_CONFIG_SOURCE` | `~/.openclaw/openclaw.json` | Source for sanitized agent-only development config. | +| `MIRA_DASHBOARD_DEV_GATEWAY_URL` | `ws://127.0.0.1:18789` | Live production Gateway used by trusted dev. | +| `MIRA_DASHBOARD_DEV_GATEWAY_TOKEN_FILE` | none | Optional owner-only token file; local commands normally use Doppler environment. | +| `HOST` / `PORT` / `DASHBOARD_API_TARGET` | `127.0.0.1` / `5173` / `http://127.0.0.1:3101` | Child frontend bind and exact backend proxy target. | Managed PR dev has one fixed slot. Its checkout/state paths derive from `MIRA_DASHBOARD_PROJECT_ROOT`; frontend `5173`, backend `3101`, proxy `18790`, From c8c61231dea5a9399f945361f6d1c5cef48bb85d Mon Sep 17 00:00:00 2001 From: mira-2026 Date: Mon, 27 Jul 2026 23:37:27 +0200 Subject: [PATCH 5/9] test: update database overview schema expectation --- backend/test/databaseOverview.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/test/databaseOverview.test.ts b/backend/test/databaseOverview.test.ts index f8bacce19..c225deaa4 100644 --- a/backend/test/databaseOverview.test.ts +++ b/backend/test/databaseOverview.test.ts @@ -231,7 +231,7 @@ describe("database overview service", () => { backup: { count: 0, current: false, reviewAgeHours: 48 }, foreignKeysEnabled: true, journalMode: "wal", - migrations: { applied: 6, current: true, latest: 6 }, + migrations: { applied: 7, current: true, latest: 7 }, permissions: { secure: true }, status: "review", walAutoCheckpointPages: 1000, From e4d88aa67f9dfa966fa0bedca27502082509ff8c Mon Sep 17 00:00:00 2001 From: mira-2026 Date: Tue, 28 Jul 2026 01:38:20 +0200 Subject: [PATCH 6/9] fix: restore SQLite during failed release cutovers --- backend/src/databaseSchemaCompatibility.ts | 7 +- backend/src/development/developmentStack.ts | 6 +- backend/src/releaseLifecycle.ts | 103 ++++++- backend/src/releaseManager.ts | 15 - backend/src/requestPolicy.ts | 33 +++ backend/src/server.ts | 14 + .../src/services/deploymentCutoverState.ts | 20 ++ backend/src/services/pullRequests.ts | 214 ++++++++++++-- backend/src/sqliteBackup.ts | 267 +++++++++++++++++- backend/test/databaseLifecycle.test.ts | 129 +++++++++ backend/test/developmentStack.test.ts | 6 + backend/test/healthReadiness.test.ts | 2 +- backend/test/releaseManager.test.ts | 8 +- backend/test/serverStartupPolicy.test.ts | 18 ++ backend/test/serviceBehavior.test.ts | 104 ++++++- backend/test/utilityBehavior.test.ts | 87 ++++++ docs/architecture/database.md | 7 +- docs/operations/scheduler-cache-backups.md | 8 +- docs/setup/production-deploy.md | 78 +++-- 19 files changed, 1024 insertions(+), 102 deletions(-) create mode 100644 backend/src/services/deploymentCutoverState.ts diff --git a/backend/src/databaseSchemaCompatibility.ts b/backend/src/databaseSchemaCompatibility.ts index d7147d11a..02fe96374 100644 --- a/backend/src/databaseSchemaCompatibility.ts +++ b/backend/src/databaseSchemaCompatibility.ts @@ -3,9 +3,10 @@ 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. + * Runtime schema versions this release can safely open. This is not a promise + * that migrations are reversible: failed coordinated cutovers restore their + * pre-cutover snapshot before older code starts, while later manual rollbacks + * remain bounded by the live schema. */ export const DASHBOARD_DATABASE_SCHEMA_COMPATIBILITY = Object.freeze({ maximum: 7, diff --git a/backend/src/development/developmentStack.ts b/backend/src/development/developmentStack.ts index 77e7aad03..5299b6c24 100644 --- a/backend/src/development/developmentStack.ts +++ b/backend/src/development/developmentStack.ts @@ -990,7 +990,11 @@ export function prepareDevelopmentState( } else { database = "created-empty"; } - if (config.databaseSource && isRealRegularFile(config.databasePath)) { + if ( + config.databaseSource && + isRealRegularFile(config.databaseSource) && + isRealRegularFile(config.databasePath) + ) { backfillCompletedDeploymentHistory(config.databaseSource, config.databasePath); } diff --git a/backend/src/releaseLifecycle.ts b/backend/src/releaseLifecycle.ts index 9cd4adf53..028094d2f 100644 --- a/backend/src/releaseLifecycle.ts +++ b/backend/src/releaseLifecycle.ts @@ -1,3 +1,10 @@ +import { Database } from "bun:sqlite"; + +import { + assertMiraDatabasePathSafeForEnvironment, + getMiraDatabasePath, +} from "./database.ts"; +import { validateDatabaseMigrationHistory } from "./databaseMigrationRunner.ts"; import type { DashboardReleaseManagerOptions, DashboardReleaseState, @@ -10,6 +17,12 @@ import { restoreDashboardReleaseAfterFailedActivation, rollbackDashboardRelease, } from "./releaseManager.ts"; +import { + createVerifiedSqliteCutoverSnapshot, + didDiscardSqliteCutoverSnapshot, + restoreVerifiedSqliteCutoverSnapshot, + verifySqliteCutoverSnapshot, +} from "./sqliteBackup.ts"; const COORDINATED_SCHEMA_CUTOVER_FLAG = "--coordinated-schema-cutover"; const RELEASE_TRANSITION_LOCK_WAIT_MS = 30_000; @@ -31,12 +44,100 @@ function releaseSummary(state: DashboardReleaseState) { }; } +function requireSnapshotId(arguments_: string[], command: string): string { + const [snapshotId] = arguments_; + if (!snapshotId || arguments_.length !== 1) { + throw new TypeError( + `Release lifecycle ${command} requires exactly one snapshot id` + ); + } + return snapshotId; +} + +function createCutoverDatabaseSnapshot(snapshotId: string) { + const databasePath = getMiraDatabasePath(); + assertMiraDatabasePathSafeForEnvironment(databasePath); + const sourceDatabase = new Database(databasePath, { readonly: true }); + try { + sourceDatabase.run("PRAGMA busy_timeout = 5000"); + validateDatabaseMigrationHistory(sourceDatabase); + const snapshot = createVerifiedSqliteCutoverSnapshot( + sourceDatabase, + databasePath, + snapshotId, + { validateRestore: validateDatabaseMigrationHistory } + ); + return { + bytes: snapshot.bytes, + createdAt: snapshot.createdAt, + snapshotId, + }; + } finally { + sourceDatabase.close(); + } +} + +function restoreCutoverDatabaseSnapshot(snapshotId: string) { + const databasePath = getMiraDatabasePath(); + assertMiraDatabasePathSafeForEnvironment(databasePath); + const snapshot = restoreVerifiedSqliteCutoverSnapshot(databasePath, snapshotId, { + validateRestore: validateDatabaseMigrationHistory, + }); + return { + bytes: snapshot.bytes, + restored: true, + snapshotId, + }; +} + +function discardCutoverDatabaseSnapshot(snapshotId: string) { + const databasePath = getMiraDatabasePath(); + assertMiraDatabasePathSafeForEnvironment(databasePath); + return { + discarded: didDiscardSqliteCutoverSnapshot(databasePath, snapshotId), + snapshotId, + }; +} + +function verifyCutoverDatabaseSnapshot(snapshotId: string) { + const databasePath = getMiraDatabasePath(); + assertMiraDatabasePathSafeForEnvironment(databasePath); + const snapshot = verifySqliteCutoverSnapshot(databasePath, snapshotId, { + validateRestore: validateDatabaseMigrationHistory, + }); + return { + bytes: snapshot.bytes, + snapshotId, + verified: true, + }; +} + export async function runReleaseLifecycleCommand( arguments_: string[], releasesRoot = resolveDashboardReleasesRoot(), options: DashboardReleaseManagerOptions = {} ) { const [command, ...commandArguments] = arguments_; + if (command === "snapshot-database") { + return createCutoverDatabaseSnapshot( + requireSnapshotId(commandArguments, command) + ); + } + if (command === "restore-database") { + return restoreCutoverDatabaseSnapshot( + requireSnapshotId(commandArguments, command) + ); + } + if (command === "discard-database-snapshot") { + return discardCutoverDatabaseSnapshot( + requireSnapshotId(commandArguments, command) + ); + } + if (command === "verify-database-snapshot") { + return verifyCutoverDatabaseSnapshot( + requireSnapshotId(commandArguments, command) + ); + } const [commitSha, ...extra] = commandArguments; const isCoordinatedSchemaCutover = command === "activate" && @@ -134,7 +235,7 @@ export async function runReleaseLifecycleCommand( } default: { throw new TypeError( - "Usage: releaseLifecycle.js " + "Usage: releaseLifecycle.js " ); } } diff --git a/backend/src/releaseManager.ts b/backend/src/releaseManager.ts index ba33afcf4..71bf019b2 100644 --- a/backend/src/releaseManager.ts +++ b/backend/src/releaseManager.ts @@ -1286,21 +1286,6 @@ export async function activateDashboardRelease( options, maximumInspectableSchemaVersion ); - const requiresCoordinatedCutover = - requiresLiveSchemaCutover(candidate.manifest, liveSchemaState.version) || - (state.current !== undefined && - requiresCurrentSchemaCutover( - candidate.manifest, - state.current.manifest - )); - if ( - !requiresCoordinatedCutover && - options.schemaCutoverMode === "coordinated" - ) { - throw new Error( - "Coordinated schema cutover mode requires an incompatible schema boundary" - ); - } assertReleaseCanActivateLiveSchema( candidate.manifest, liveSchemaState.version, diff --git a/backend/src/requestPolicy.ts b/backend/src/requestPolicy.ts index 43d831bf0..686556a18 100644 --- a/backend/src/requestPolicy.ts +++ b/backend/src/requestPolicy.ts @@ -34,6 +34,7 @@ import { type AuditOutcome, writeAuditEvent, } from "./services/auditEvents.ts"; +import { isProductionDeploymentCutoverActive } from "./services/deploymentCutoverState.ts"; type BunHandler = ( request: Request, @@ -139,6 +140,29 @@ function isApiRoute(pathname: string): boolean { return pathname === "/api" || pathname.startsWith("/api/"); } +/** Blocks user-visible writes until a guarded deployment reaches a terminal state. */ +export function isDeploymentCutoverMutationBlocked( + request: Request, + options: { + environment?: Record; + isCutoverActive?: () => boolean; + } = {} +): boolean { + const environment = options.environment ?? process.env; + if (environment.NODE_ENV !== "production") { + return false; + } + const isCutoverActive = + options.isCutoverActive ?? (() => isProductionDeploymentCutoverActive()); + if (!isCutoverActive()) { + return false; + } + return ( + !SAFE_REQUEST_METHODS.has(request.method.toUpperCase()) || + request.headers.get("x-mira-user-activity")?.trim() === "1" + ); +} + function isAuthRoute(pathname: string): boolean { return pathname === "/api/auth" || pathname.startsWith("/api/auth/"); } @@ -473,6 +497,15 @@ function secureHandler( if (isApi && !isAllowedMutationSource(request)) { return json({ error: "Forbidden request origin" }, { status: 403 }); } + if (isApi && isDeploymentCutoverMutationBlocked(request)) { + return json( + { + code: "deployment_cutover_in_progress", + error: "Dashboard writes are paused while the release is verified", + }, + { headers: { "Retry-After": "5" }, status: 503 } + ); + } const requiresAuthentication = isApi && !isPublicApiRoute(request); const automationAuthentication = requiresAuthentication diff --git a/backend/src/server.ts b/backend/src/server.ts index 49b469c15..b2f5bd04b 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -22,6 +22,7 @@ import { } from "./requestPolicy.ts"; import { withRequestSecurity } from "./requestSecurity.ts"; import { routes } from "./routes.ts"; +import { isProductionDeploymentCutoverActive } from "./services/deploymentCutoverState.ts"; import { validateTotpStorageConfig } from "./services/multiFactorAuth.ts"; import { validateWebAuthnConfig } from "./services/webAuthn.ts"; @@ -206,6 +207,19 @@ export function createServer( server ); } + if (isProductionDeploymentCutoverActive()) { + return withRequestSecurity( + request, + new Response( + "Dashboard writes are paused while the release is verified", + { + headers: { "Retry-After": "5" }, + status: 503, + } + ), + server + ); + } const sessionToken = sessionIdFromCookie(request); const session = sessionToken ? getAuthSessionFromSessionId(sessionToken) diff --git a/backend/src/services/deploymentCutoverState.ts b/backend/src/services/deploymentCutoverState.ts new file mode 100644 index 000000000..6f4a1d631 --- /dev/null +++ b/backend/src/services/deploymentCutoverState.ts @@ -0,0 +1,20 @@ +import { database } from "../database.ts"; + +/** Keeps production writes paused while a detached release guardian owns cutover. */ +export function isProductionDeploymentCutoverActive( + environment: Record = process.env +): boolean { + if (environment.NODE_ENV !== "production") { + return false; + } + return Boolean( + database + .query( + `SELECT 1 + FROM deployment_jobs + WHERE status = 'verifying' + LIMIT 1` + ) + .get() + ); +} diff --git a/backend/src/services/pullRequests.ts b/backend/src/services/pullRequests.ts index 916ba78e4..33c3f419f 100644 --- a/backend/src/services/pullRequests.ts +++ b/backend/src/services/pullRequests.ts @@ -91,14 +91,17 @@ const PUBLIC_PR_FAILURE_CACHE_MS = 30_000; const PUBLIC_GITHUB_API_TIMEOUT_MS = 15_000; const DEPLOYMENT_RESTART_STATUS_POLL_MS = 1000; const DEPLOYMENT_RESTART_CLAIM_PAUSE_TIMEOUT_MS = 2 * 60 * 1000; +const DEPLOYMENT_CUTOVER_HANDOFF_TIMEOUT_MS = 30_000; const MAX_DEPLOYMENT_CUTOVER_CONTEXT_BYTES = 4096; -const DEPLOYMENT_CUTOVER_CONTEXT_FORMAT_VERSION = 1; +const DEPLOYMENT_CUTOVER_CONTEXT_FORMAT_VERSION = 2; const DEPLOYMENT_WORKER_STABILITY_SECONDS = Math.ceil(JOB_WORKER_HEARTBEAT_MAX_AGE_MS / 1000) + 1; const PASSING_CHECK_VALUES = new Set(["success", "successful", "neutral", "skipped"]); const OPINIONATED_REVIEW_STATES = new Set(["APPROVED", "CHANGES_REQUESTED", "DISMISSED"]); const ACTIVE_DEPLOYMENT_STATUSES = new Set(["building", "verifying"]); const FULL_COMMIT_SHA_PATTERN = /^[\da-f]{40}$/u; +const SQLITE_CUTOVER_SNAPSHOT_ID_PATTERN = + /^[\da-f]{8}-[\da-f]{4}-7[\da-f]{3}-[89ab][\da-f]{3}-[\da-f]{12}$/u; const publicPullRequestCache: { failure?: { expiresAt: number; message: string }; value?: { expiresAt: number; pullRequests: PullRequestSummary[] }; @@ -379,6 +382,7 @@ interface DeploymentLockExecutionRow { interface DeploymentCutoverContext { candidateCommit: string; + databaseSnapshotId: string; formatVersion: typeof DEPLOYMENT_CUTOVER_CONTEXT_FORMAT_VERSION; preActivationCommit: string; preActivationPreviousCommit?: string; @@ -391,6 +395,7 @@ function isRecord(value: unknown): value is Record { function createDeploymentCutoverContext( candidateCommit: string, + databaseSnapshotId: string, preActivationCommit: string, rollbackCommit: string, preActivationPreviousCommit: string | undefined @@ -402,6 +407,9 @@ function createDeploymentCutoverContext( ) { throw new TypeError("Release cutover context requires full commit SHAs"); } + if (!SQLITE_CUTOVER_SNAPSHOT_ID_PATTERN.test(databaseSnapshotId)) { + throw new TypeError("Release cutover context requires a lowercase UUIDv7"); + } if (rollbackCommit === candidateCommit) { throw new TypeError( "Release cutover context requires a distinct rollback commit" @@ -429,6 +437,7 @@ function createDeploymentCutoverContext( } return { candidateCommit, + databaseSnapshotId, formatVersion: DEPLOYMENT_CUTOVER_CONTEXT_FORMAT_VERSION, preActivationCommit, ...(preActivationPreviousCommit && { preActivationPreviousCommit }), @@ -460,6 +469,7 @@ function parseDeploymentCutoverContext( const value = output.releaseCutover; const allowedKeys = new Set([ "candidateCommit", + "databaseSnapshotId", "formatVersion", "preActivationCommit", "preActivationPreviousCommit", @@ -470,6 +480,7 @@ function parseDeploymentCutoverContext( value.formatVersion !== DEPLOYMENT_CUTOVER_CONTEXT_FORMAT_VERSION || typeof value.candidateCommit !== "string" || value.candidateCommit !== expectedCandidateCommit || + typeof value.databaseSnapshotId !== "string" || typeof value.preActivationCommit !== "string" || typeof value.rollbackCommit !== "string" || (value.preActivationPreviousCommit !== undefined && @@ -480,6 +491,7 @@ function parseDeploymentCutoverContext( try { return createDeploymentCutoverContext( value.candidateCommit, + value.databaseSnapshotId, value.preActivationCommit, value.rollbackCommit, value.preActivationPreviousCommit @@ -1907,6 +1919,74 @@ try { ].join(" "); } +/** + * Waits until the worker has durably completed the scheduling action. The + * cutover snapshot must not capture a running execution that an older worker + * would later recover as failed. + */ +function deploymentCutoverHandoffCommand( + deploymentId: string, + databaseSnapshotId: string +): string { + const script = ` +import { Database } from "bun:sqlite"; +const database = new Database(process.env.MIRA_DEPLOYMENT_DB, { readonly: true }); +database.run("PRAGMA busy_timeout = 5000"); +const deadline = Date.now() + ${DEPLOYMENT_CUTOVER_HANDOFF_TIMEOUT_MS}; +const readExecution = database.prepare(\` + SELECT + status, + json_extract(output_json, '$.releaseCutover.databaseSnapshotId') AS database_snapshot_id, + (SELECT status FROM deployment_jobs WHERE id = ?) AS deployment_status, + (SELECT job_id FROM deployment_lock WHERE id = 1) AS lock_job_id + FROM job_executions + WHERE action_key = 'dashboard.deploy' + AND json_valid(payload_json) + AND json_valid(output_json) + AND json_extract(payload_json, '$.deploymentId') = ? + ORDER BY queued_at DESC, id DESC + LIMIT 1 +\`); +let isReady = false; +try { + while (Date.now() < deadline) { + const execution = readExecution.get( + process.env.MIRA_DEPLOYMENT_ID, + process.env.MIRA_DEPLOYMENT_ID + ); + if ( + execution?.status === "success" && + execution.database_snapshot_id === process.env.MIRA_DEPLOYMENT_SNAPSHOT_ID && + execution.deployment_status === "verifying" && + execution.lock_job_id === process.env.MIRA_DEPLOYMENT_ID + ) { + isReady = true; + break; + } + if ( + execution && + execution.status !== "queued" && + execution.status !== "running" + ) { + break; + } + await Bun.sleep(100); + } +} finally { + database.close(); +} +if (!isReady) process.exitCode = 1; +`; + return [ + `MIRA_DEPLOYMENT_DB=${shellQuote(getMiraDatabasePath())}`, + `MIRA_DEPLOYMENT_ID=${shellQuote(deploymentId)}`, + `MIRA_DEPLOYMENT_SNAPSHOT_ID=${shellQuote(databaseSnapshotId)}`, + shellQuote(resolveBunExecutable()), + "-e", + shellQuote(script), + ].join(" "); +} + function releaseLifecycleInvocation(lifecycleCommand: string): string { return [ `MIRA_DASHBOARD_PROJECT_ROOT=${shellQuote( @@ -1973,6 +2053,9 @@ function releaseCutoverShellFunctions(): string[] { "restart_services() {", ` /usr/bin/systemctl --user restart ${DASHBOARD_SERVICES.join(" ")}`, "}", + "stop_services() {", + ` /usr/bin/systemctl --user stop ${DASHBOARD_SERVICES.join(" ")}`, + "}", ]; } @@ -2007,6 +2090,7 @@ async function scheduleReleaseCutover( ): Promise { const { candidateCommit, + databaseSnapshotId, preActivationCommit, preActivationPreviousCommit, rollbackCommit, @@ -2045,14 +2129,6 @@ async function scheduleReleaseCutover( ); } const releasesRoot = resolveDashboardReleasesRoot(); - const activationLifecycleCommand = path.join( - releasesRoot, - "releases", - preActivationCommit, - "backend", - "dist", - "releaseLifecycle.js" - ); const guardedLifecycleCommand = path.join( releasesRoot, "releases", @@ -2061,12 +2137,16 @@ async function scheduleReleaseCutover( "dist", "releaseLifecycle.js" ); - const activationLifecycleEnvironment = releaseLifecycleInvocation( - activationLifecycleCommand - ); const guardedLifecycleEnvironment = releaseLifecycleInvocation( guardedLifecycleCommand ); + const snapshotCommand = `${guardedLifecycleEnvironment} snapshot-database ${shellQuote(databaseSnapshotId)}`; + const restoreDatabaseCommand = `${guardedLifecycleEnvironment} restore-database ${shellQuote(databaseSnapshotId)}`; + const discardSnapshotCommand = `${guardedLifecycleEnvironment} discard-database-snapshot ${shellQuote(databaseSnapshotId)}`; + const waitForHandoffCommand = deploymentCutoverHandoffCommand( + job.id, + databaseSnapshotId + ); const restoreCommand = isNewActivation ? [ guardedLifecycleEnvironment, @@ -2108,28 +2188,69 @@ async function scheduleReleaseCutover( ...job, status: "failed", updatedAt: dateToISOString(new Date()), - note: "Release activation failed before restart; guardian left current unchanged", + note: "Release activation failed before restart; the exact pre-cutover database and original release were restored", + }; + const snapshotFailedJob: DeploymentJob = { + ...job, + status: "failed", + updatedAt: dateToISOString(new Date()), + note: "Release activation stopped before restart because the guarded database snapshot failed; original services were restored", + }; + const cutoverStartFailedJob: DeploymentJob = { + ...job, + status: "failed", + updatedAt: dateToISOString(new Date()), + note: "Release activation could not stop every Dashboard service safely; original services were restored", + }; + const handoffFailedJob: DeploymentJob = { + ...job, + status: "failed", + updatedAt: dateToISOString(new Date()), + note: "Release activation stopped before service shutdown because the worker handoff did not become durable", }; const script = [ - "sleep 2", ...releaseCutoverShellFunctions(), - `if ${activationLifecycleEnvironment} activate ${shellQuote(candidateCommit)}; then`, - ` if restart_services && ready_for_commit ${shellQuote(candidateShort)}; then`, - ` if ${activationLifecycleEnvironment} prune 3; then`, - ` ${deploymentJobUpdateCommand(okJob)}`, + `${waitForHandoffCommand} || { ${deploymentJobUpdateCommand(handoffFailedJob)}; exit 1; }`, + "if stop_services; then", + ` if ${snapshotCommand}; then`, + ` if ${guardedLifecycleEnvironment} activate ${shellQuote(candidateCommit)} --coordinated-schema-cutover; then`, + ` if restart_services && ready_for_commit ${shellQuote(candidateShort)}; then`, + ` if ${guardedLifecycleEnvironment} prune 3; then`, + ` ${deploymentJobUpdateCommand(okJob)} || exit 1`, + " else", + ` ${deploymentJobUpdateCommand(okWithRetentionWarningJob)} || exit 1`, + " fi", + ` ${discardSnapshotCommand} >/dev/null 2>&1 || true`, + " else", + ` if stop_services && ${restoreDatabaseCommand} && ${restoreCommand} && restart_services && ready_for_commit ${shellQuote(rollbackShort)}; then`, + ` ${deploymentJobUpdateCommand(rolledBackJob)} || exit 1`, + ` ${discardSnapshotCommand} >/dev/null 2>&1 || true`, + " else", + ` ${deploymentJobUpdateCommand(rollbackFailedJob)}`, + " fi", + " fi", " else", - ` ${deploymentJobUpdateCommand(okWithRetentionWarningJob)}`, + ` if ${restoreDatabaseCommand} && restart_services && ready_for_commit ${shellQuote(preActivationCommit.slice(0, 8))}; then`, + ` ${deploymentJobUpdateCommand(activationFailedJob)} || exit 1`, + ` ${discardSnapshotCommand} >/dev/null 2>&1 || true`, + " else", + ` ${deploymentJobUpdateCommand(rollbackFailedJob)}`, + " fi", " fi", " else", - ` if ${restoreCommand} && restart_services && ready_for_commit ${shellQuote(rollbackShort)}; then`, - ` ${deploymentJobUpdateCommand(rolledBackJob)}`, + ` if restart_services && ready_for_commit ${shellQuote(preActivationCommit.slice(0, 8))}; then`, + ` ${deploymentJobUpdateCommand(snapshotFailedJob)}`, " else", ` ${deploymentJobUpdateCommand(rollbackFailedJob)}`, " fi", " fi", "else", - ` ${deploymentJobUpdateCommand(activationFailedJob)}`, + ` if restart_services && ready_for_commit ${shellQuote(preActivationCommit.slice(0, 8))}; then`, + ` ${deploymentJobUpdateCommand(cutoverStartFailedJob)}`, + " else", + ` ${deploymentJobUpdateCommand(rollbackFailedJob)}`, + " fi", "fi", ].join("\n"); @@ -2277,7 +2398,7 @@ function didScheduleOrphanedReleaseCutoverRecovery( persistedCutover.candidateCommit !== persistedCutover.preActivationCommit; const recoveryMode = willRestoreExactPreActivationSlots ? "restore" - : isRollbackAction + : persistedCutover || isRollbackAction ? "rollback" : "legacy-rollback"; const rolledBackJob: DeploymentJob = { @@ -2306,6 +2427,7 @@ function didScheduleOrphanedReleaseCutoverRecovery( `project_root=${shellQuote(resolveDashboardProjectPaths().projectRoot)}`, `releases_root=${shellQuote(releasesRoot)}`, `candidate_commit=${shellQuote(candidateCommit)}`, + `database_snapshot_id=${shellQuote(persistedCutover?.databaseSnapshotId ?? "")}`, `recovery_mode=${shellQuote(recoveryMode)}`, `expected_rollback_commit=${shellQuote(persistedCutover?.rollbackCommit ?? "")}`, `pre_activation_previous_commit=${shellQuote( @@ -2366,7 +2488,48 @@ function didScheduleOrphanedReleaseCutoverRecovery( " esac", "}", "resolve_trusted_lifecycles || exit 1", - 'if activation_output="$(run_activation_lifecycle activate "$candidate_commit")"; then', + 'if [ -n "$database_snapshot_id" ]; then', + ' if ! run_candidate_lifecycle verify-database-snapshot "$database_snapshot_id" >/dev/null; then', + ' [ "$current_commit" != "$candidate_commit" ] || exit 1', + ' if restart_services && ready_for_commit "${current_commit:0:8}"; then', + ` ${deploymentJobUpdateCommand(activationNotAppliedJob)}`, + " exit 0", + " fi", + " exit 1", + " fi", + " stop_services || exit 1", + ' if activation_output="$(run_candidate_lifecycle activate "$candidate_commit" --coordinated-schema-cutover)"; then', + ' activation_commit="$(printf "%s" "$activation_output" | /usr/bin/jq --raw-output \'.current.commitSha // empty\')"', + ' [ "$activation_commit" = "$candidate_commit" ] || exit 1', + ' if restart_services && ready_for_commit "${candidate_commit:0:8}"; then', + ` ${deploymentJobUpdateCommand(activeCandidateRecoveredJob)} || exit 1`, + ' run_candidate_lifecycle discard-database-snapshot "$database_snapshot_id" >/dev/null 2>&1 || true', + " exit 0", + " fi", + ' rollback_commit="$(printf "%s" "$activation_output" | /usr/bin/jq --raw-output \'.previous.commitSha // empty\')"', + ' [[ "$rollback_commit" =~ ^[0-9a-f]{40}$ ]] || exit 1', + ' [ "$rollback_commit" != "$candidate_commit" ] || exit 1', + ' if stop_services && run_candidate_lifecycle restore-database "$database_snapshot_id" && restore_failed_candidate && restart_services && ready_for_commit "${rollback_commit:0:8}"; then', + ` ${deploymentJobUpdateCommand(rolledBackJob)} || exit 1`, + ' run_candidate_lifecycle discard-database-snapshot "$database_snapshot_id" >/dev/null 2>&1 || true', + " else", + " exit 1", + " fi", + " else", + ' if run_candidate_lifecycle restore-database "$database_snapshot_id"; then', + ' status_output="$(run_candidate_lifecycle status)" || exit 1', + ' current_commit="$(printf "%s" "$status_output" | /usr/bin/jq --raw-output \'.current.commitSha // empty\')"', + ' [[ "$current_commit" =~ ^[0-9a-f]{40}$ ]] || exit 1', + ' [ "$current_commit" != "$candidate_commit" ] || exit 1', + ' if restart_services && ready_for_commit "${current_commit:0:8}"; then', + ` ${deploymentJobUpdateCommand(activationNotAppliedJob)} || exit 1`, + ' run_candidate_lifecycle discard-database-snapshot "$database_snapshot_id" >/dev/null 2>&1 || true', + " exit 0", + " fi", + " fi", + " exit 1", + " fi", + 'elif activation_output="$(run_activation_lifecycle activate "$candidate_commit")"; then', ' activation_commit="$(printf "%s" "$activation_output" | /usr/bin/jq --raw-output \'.current.commitSha // empty\')"', ' [ "$activation_commit" = "$candidate_commit" ] || exit 1', ' if restart_services && ready_for_commit "${candidate_commit:0:8}"; then', @@ -2495,6 +2658,7 @@ async function runDeploymentJob( currentJob = refreshDeploymentHeartbeat(currentJob); const releaseCutover = createDeploymentCutoverContext( expectedCommit, + Bun.randomUUIDv7(), currentState.current.commitSha, rollbackRelease.commitSha, currentState.previous?.commitSha @@ -2507,7 +2671,7 @@ async function runDeploymentJob( updatedAt: dateToISOString(new Date()), commit: candidate.manifest.commitSha, commitTitle: candidate.manifest.commitTitle, - note: `Release published. Activating it, restarting services, then verifying web, worker, deployed commit, and ${DEPLOYMENT_WORKER_STABILITY_SECONDS} seconds of worker stability; automatic rollback is armed`, + note: `Release published. Pausing Dashboard writes, snapshotting SQLite, activating it, then verifying web, worker, deployed commit, and ${DEPLOYMENT_WORKER_STABILITY_SECONDS} seconds of worker stability; code-and-data rollback is armed`, }; writeDeploymentJob(cutoverJob); await scheduleReleaseCutover(cutoverJob, releaseCutover, signal); diff --git a/backend/src/sqliteBackup.ts b/backend/src/sqliteBackup.ts index 680f906ac..5cf8b41b2 100644 --- a/backend/src/sqliteBackup.ts +++ b/backend/src/sqliteBackup.ts @@ -5,7 +5,7 @@ import { Database } from "bun:sqlite"; import { secureDirectory, sqliteBackupDirectory } from "./databaseStorage.ts"; -export type SqliteBackupKind = "pre-deploy" | "pre-migration" | "scheduled"; +export type SqliteBackupKind = "cutover" | "pre-deploy" | "pre-migration" | "scheduled"; export interface SqliteBackupResult { bytes: number; @@ -37,13 +37,18 @@ export interface SqliteBackupInventory { type RestoreValidator = (restoredDatabase: Database) => void; -const BACKUP_FILE_PATTERN = +const STANDARD_BACKUP_FILE_PATTERN = /^mira-dashboard-(pre-deploy|pre-migration|scheduled)-\d{8}T\d{9}Z-\d+-[a-f0-9]{8}\.db$/u; +const CUTOVER_SNAPSHOT_ID_PATTERN = + /^[\da-f]{8}-[\da-f]{4}-7[\da-f]{3}-[89ab][\da-f]{3}-[\da-f]{12}$/u; +const CUTOVER_BACKUP_FILE_PATTERN = + /^mira-dashboard-cutover-([\da-f]{8}-[\da-f]{4}-7[\da-f]{3}-[89ab][\da-f]{3}-[\da-f]{12})\.db$/u; const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000; export const SQLITE_BACKUP_RETENTION: Readonly< Record > = { + cutover: { maxAgeDays: 2, maxCount: 5 }, "pre-deploy": { maxAgeDays: 90, maxCount: 20 }, "pre-migration": { maxAgeDays: 180, maxCount: 20 }, scheduled: { maxAgeDays: 14, maxCount: 14 }, @@ -53,7 +58,10 @@ function timestampForFilename(date: Date): string { return date.toISOString().replaceAll(/[-:.]/gu, ""); } -function backupFilename(kind: SqliteBackupKind, createdAt: Date): string { +function backupFilename( + kind: Exclude, + createdAt: Date +): string { return ( [ "mira-dashboard", @@ -65,6 +73,29 @@ function backupFilename(kind: SqliteBackupKind, createdAt: Date): string { ); } +function cutoverSnapshotFilename(snapshotId: string): string { + if (!CUTOVER_SNAPSHOT_ID_PATTERN.test(snapshotId)) { + throw new TypeError("SQLite cutover snapshot id must be a lowercase UUIDv7"); + } + return `mira-dashboard-cutover-${snapshotId}.db`; +} + +function cutoverSnapshotPath(databasePath: string, snapshotId: string): string { + return path.join( + sqliteBackupDirectory(databasePath), + cutoverSnapshotFilename(snapshotId) + ); +} + +function backupKindFromFilename(name: string): SqliteBackupKind | undefined { + const standardKind = name.match(STANDARD_BACKUP_FILE_PATTERN)?.[1] as + Exclude | undefined; + if (standardKind) { + return standardKind; + } + return CUTOVER_BACKUP_FILE_PATTERN.test(name) ? "cutover" : undefined; +} + function quickCheck(database: Database): void { const rows = database.query("PRAGMA quick_check").all() as Array< Record @@ -118,7 +149,7 @@ function verifyRestoredCopy( export function createVerifiedSqliteBackup( sourceDatabase: Database, databasePath: string, - kind: SqliteBackupKind, + kind: Exclude, options: { createdAt?: Date; exerciseRestore?: RestoreValidator; @@ -130,8 +161,34 @@ export function createVerifiedSqliteBackup( secureDirectory(backupDirectory); const targetPath = path.join(backupDirectory, backupFilename(kind, createdAt)); + return createVerifiedSqliteBackupAtPath( + sourceDatabase, + targetPath, + backupDirectory, + kind, + createdAt, + options + ); +} + +function createVerifiedSqliteBackupAtPath( + sourceDatabase: Database, + targetPath: string, + backupDirectory: string, + kind: SqliteBackupKind, + createdAt: Date, + options: { + exerciseRestore?: RestoreValidator; + validateRestore?: RestoreValidator; + } +): SqliteBackupResult { + let didCreateTarget = false; try { + const descriptor = fs.openSync(targetPath, "wx", 0o600); + didCreateTarget = true; + fs.closeSync(descriptor); sourceDatabase.prepare("VACUUM INTO ?").run(targetPath); + assertRealRegularFile(targetPath, "SQLite backup"); fs.chmodSync(targetPath, 0o600); verifyRestoredCopy( targetPath, @@ -139,6 +196,8 @@ export function createVerifiedSqliteBackup( options.validateRestore, options.exerciseRestore ); + syncFile(targetPath); + syncDirectory(backupDirectory); return { bytes: fs.statSync(targetPath).size, createdAt: createdAt.toISOString(), @@ -146,14 +205,205 @@ export function createVerifiedSqliteBackup( path: targetPath, restoreVerified: true, }; + } catch (error) { + if (didCreateTarget) { + try { + fs.rmSync(targetPath, { force: true }); + syncDirectory(backupDirectory); + } catch { + // Preserve the backup or verification error. + } + } + throw error; + } +} + +/** + * Creates the exact database snapshot associated with one guarded release + * cutover. The caller must keep every Dashboard writer stopped until this + * function returns. + */ +export function createVerifiedSqliteCutoverSnapshot( + sourceDatabase: Database, + databasePath: string, + snapshotId: string, + options: { + createdAt?: Date; + validateRestore?: RestoreValidator; + } = {} +): SqliteBackupResult { + const createdAt = options.createdAt ?? new Date(); + const backupDirectory = sqliteBackupDirectory(databasePath); + secureDirectory(backupDirectory); + const targetPath = cutoverSnapshotPath(databasePath, snapshotId); + if (fs.existsSync(targetPath)) { + throw new Error(`SQLite cutover snapshot already exists: ${snapshotId}`); + } + return createVerifiedSqliteBackupAtPath( + sourceDatabase, + targetPath, + backupDirectory, + "cutover", + createdAt, + options + ); +} + +function assertRealRegularFile(filePath: string, label: string): fs.Stats { + const fileStat = fs.lstatSync(filePath); + if (fileStat.isSymbolicLink() || !fileStat.isFile() || fileStat.nlink !== 1) { + throw new Error(`${label} must be a real single-link regular file`); + } + return fileStat; +} + +function syncFile(filePath: string): void { + const descriptor = fs.openSync(filePath, fs.constants.O_RDONLY); + try { + fs.fsyncSync(descriptor); + } finally { + fs.closeSync(descriptor); + } +} + +function syncDirectory(directoryPath: string): void { + const descriptor = fs.openSync(directoryPath, fs.constants.O_RDONLY); + try { + fs.fsyncSync(descriptor); + } finally { + fs.closeSync(descriptor); + } +} + +function removeSqliteSidecar(filePath: string): void { + try { + assertRealRegularFile(filePath, "SQLite sidecar"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return; + } + throw error; + } + fs.unlinkSync(filePath); +} + +/** + * Atomically replaces a stopped live SQLite database with its exact cutover + * snapshot. The source snapshot remains available until explicitly discarded. + */ +export function restoreVerifiedSqliteCutoverSnapshot( + databasePath: string, + snapshotId: string, + options: { validateRestore?: RestoreValidator } = {} +): SqliteBackupResult { + const snapshot = verifySqliteCutoverSnapshot(databasePath, snapshotId, options); + const snapshotPath = snapshot.path; + const snapshotStat = fs.lstatSync(snapshotPath); + + assertRealRegularFile(databasePath, "Live SQLite database"); + const liveDatabase = new Database(databasePath); + try { + liveDatabase.run("PRAGMA busy_timeout = 5000"); + const checkpoint = liveDatabase + .query("PRAGMA wal_checkpoint(TRUNCATE)") + .get() as { busy?: unknown }; + if (checkpoint.busy !== 0) { + throw new Error("SQLite cutover restore requires every writer to be stopped"); + } + } finally { + liveDatabase.close(); + } + + const databaseDirectory = path.dirname(databasePath); + secureDirectory(databaseDirectory); + const temporaryPath = path.join( + databaseDirectory, + `.mira-dashboard-cutover-restore-${Bun.randomUUIDv7()}.db` + ); + try { + fs.copyFileSync(snapshotPath, temporaryPath, fs.constants.COPYFILE_EXCL); + fs.chmodSync(temporaryPath, 0o600); + const restoredDatabase = new Database(temporaryPath, { readonly: true }); + try { + restoredDatabase.run("PRAGMA query_only = ON"); + quickCheck(restoredDatabase); + options.validateRestore?.(restoredDatabase); + } finally { + restoredDatabase.close(); + } + syncFile(temporaryPath); + removeSqliteSidecar(`${databasePath}-wal`); + removeSqliteSidecar(`${databasePath}-shm`); + fs.renameSync(temporaryPath, databasePath); + syncDirectory(databaseDirectory); + fs.chmodSync(databasePath, 0o600); } catch (error) { try { - fs.rmSync(targetPath, { force: true }); + fs.rmSync(temporaryPath, { force: true }); } catch { - // Preserve the backup or verification error. + // Preserve the restore error. } throw error; } + + return { + bytes: snapshotStat.size, + createdAt: snapshotStat.mtime.toISOString(), + kind: "cutover", + path: snapshotPath, + restoreVerified: true, + }; +} + +/** Revalidates the exact snapshot referenced by a guarded release cutover. */ +export function verifySqliteCutoverSnapshot( + databasePath: string, + snapshotId: string, + options: { validateRestore?: RestoreValidator } = {} +): SqliteBackupResult { + const backupDirectory = sqliteBackupDirectory(databasePath); + const backupDirectoryStat = fs.lstatSync(backupDirectory); + if (backupDirectoryStat.isSymbolicLink() || !backupDirectoryStat.isDirectory()) { + throw new Error("SQLite backup directory must be a real directory"); + } + const snapshotPath = cutoverSnapshotPath(databasePath, snapshotId); + const snapshotStat = assertRealRegularFile(snapshotPath, "SQLite cutover snapshot"); + verifyRestoredCopy(snapshotPath, backupDirectory, options.validateRestore); + + return { + bytes: snapshotStat.size, + createdAt: snapshotStat.mtime.toISOString(), + kind: "cutover", + path: snapshotPath, + restoreVerified: true, + }; +} + +/** Removes only the exact snapshot named by a validated cutover UUID. */ +export function didDiscardSqliteCutoverSnapshot( + databasePath: string, + snapshotId: string +): boolean { + const snapshotPath = cutoverSnapshotPath(databasePath, snapshotId); + let snapshotStat: fs.Stats; + try { + snapshotStat = fs.lstatSync(snapshotPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return false; + } + throw error; + } + if ( + snapshotStat.isSymbolicLink() || + !snapshotStat.isFile() || + snapshotStat.nlink !== 1 + ) { + throw new Error("SQLite cutover snapshot must be a real single-link file"); + } + fs.unlinkSync(snapshotPath); + syncDirectory(path.dirname(snapshotPath)); + return true; } interface RetainedBackup { @@ -170,11 +420,10 @@ function retainedBackupFiles(databasePath: string): RetainedBackup[] { } return fs .readdirSync(backupDirectory, { withFileTypes: true }) - .filter((entry) => entry.isFile() && BACKUP_FILE_PATTERN.test(entry.name)) + .filter((entry) => entry.isFile() && backupKindFromFilename(entry.name)) .map((entry) => { const filePath = path.join(backupDirectory, entry.name); - const kind = entry.name.match(BACKUP_FILE_PATTERN)?.[1] as - SqliteBackupKind | undefined; + const kind = backupKindFromFilename(entry.name); if (!kind) { throw new Error(`Unexpected SQLite backup filename: ${entry.name}`); } diff --git a/backend/test/databaseLifecycle.test.ts b/backend/test/databaseLifecycle.test.ts index c47bf6187..b2f569b35 100644 --- a/backend/test/databaseLifecycle.test.ts +++ b/backend/test/databaseLifecycle.test.ts @@ -28,12 +28,17 @@ import { secureSqliteFilePermissions, sqliteBackupDirectory, } from "../src/databaseStorage.ts"; +import { runReleaseLifecycleCommand } from "../src/releaseLifecycle.ts"; import { RELEASE_READINESS_FAILURE_NOTE_PREFIX } from "../src/services/deploymentRuntimeResults.ts"; import { pruneDatabaseHistory } from "../src/services/sqliteMaintenance.ts"; import { createVerifiedSqliteBackup, + createVerifiedSqliteCutoverSnapshot, + didDiscardSqliteCutoverSnapshot, getSqliteBackupInventory, pruneSqliteBackups, + restoreVerifiedSqliteCutoverSnapshot, + verifySqliteCutoverSnapshot, } from "../src/sqliteBackup.ts"; const temporaryRoots: string[] = []; @@ -642,6 +647,130 @@ describe("Dashboard SQLite lifecycle", () => { } }); + it("restores the exact guarded cutover snapshot before old code restarts", () => { + const root = temporaryRoot("mira-db-cutover-"); + const databasePath = path.join(root, "dashboard.db"); + const snapshotId = "019fa351-e832-7000-ae09-435160fd5ccc"; + const database = openWalDatabase(databasePath); + database.run("CREATE TABLE cutover_values (value TEXT NOT NULL)"); + database + .prepare("INSERT INTO cutover_values (value) VALUES (?)") + .run("before-cutover"); + const snapshot = createVerifiedSqliteCutoverSnapshot( + database, + databasePath, + snapshotId + ); + expect(() => + createVerifiedSqliteCutoverSnapshot(database, databasePath, snapshotId) + ).toThrow("SQLite cutover snapshot already exists"); + database + .prepare("INSERT INTO cutover_values (value) VALUES (?)") + .run("candidate-write"); + database.close(); + + expect(verifySqliteCutoverSnapshot(databasePath, snapshotId)).toMatchObject({ + kind: "cutover", + path: snapshot.path, + restoreVerified: true, + }); + expect(() => + verifySqliteCutoverSnapshot(databasePath, "../not-a-snapshot") + ).toThrow("SQLite cutover snapshot id must be a lowercase UUIDv7"); + expect( + restoreVerifiedSqliteCutoverSnapshot(databasePath, snapshotId) + ).toMatchObject({ + kind: "cutover", + path: snapshot.path, + restoreVerified: true, + }); + const restored = new Database(databasePath, { readonly: true }); + try { + expect( + restored.query("SELECT value FROM cutover_values ORDER BY value").all() + ).toEqual([{ value: "before-cutover" }]); + } finally { + restored.close(); + } + expect(didDiscardSqliteCutoverSnapshot(databasePath, snapshotId)).toBe(true); + expect(didDiscardSqliteCutoverSnapshot(databasePath, snapshotId)).toBe(false); + }); + + it("exposes cutover snapshots through bounded release lifecycle commands", async () => { + const root = temporaryRoot("mira-db-cutover-lifecycle-"); + const databasePath = path.join(root, "dashboard.db"); + const snapshotId = "019fa351-e832-7000-ae09-435160fd5ccd"; + const database = openWalDatabase(databasePath); + applyDatabaseMigrations(database, databasePath); + database.run("CREATE TABLE lifecycle_values (value TEXT NOT NULL)"); + database + .prepare("INSERT INTO lifecycle_values (value) VALUES (?)") + .run("before-cutover"); + database.close(); + + const originalDatabasePath = process.env.MIRA_DASHBOARD_DB_PATH; + process.env.MIRA_DASHBOARD_DB_PATH = databasePath; + try { + await expect( + runReleaseLifecycleCommand(["snapshot-database", snapshotId]) + ).resolves.toMatchObject({ + snapshotId, + }); + await expect( + runReleaseLifecycleCommand(["verify-database-snapshot", snapshotId]) + ).resolves.toEqual({ + bytes: expect.any(Number), + snapshotId, + verified: true, + }); + + const candidateDatabase = openWalDatabase(databasePath); + candidateDatabase + .prepare("INSERT INTO lifecycle_values (value) VALUES (?)") + .run("candidate-write"); + candidateDatabase.close(); + + await expect( + runReleaseLifecycleCommand(["restore-database", snapshotId]) + ).resolves.toEqual({ + bytes: expect.any(Number), + restored: true, + snapshotId, + }); + const restoredDatabase = new Database(databasePath, { readonly: true }); + try { + expect( + restoredDatabase + .query("SELECT value FROM lifecycle_values ORDER BY value") + .all() + ).toEqual([{ value: "before-cutover" }]); + } finally { + restoredDatabase.close(); + } + await expect( + runReleaseLifecycleCommand(["discard-database-snapshot", snapshotId]) + ).resolves.toEqual({ + discarded: true, + snapshotId, + }); + await expect( + runReleaseLifecycleCommand(["discard-database-snapshot", snapshotId]) + ).resolves.toEqual({ + discarded: false, + snapshotId, + }); + await expect( + runReleaseLifecycleCommand(["snapshot-database"]) + ).rejects.toThrow("requires exactly one snapshot id"); + } finally { + if (originalDatabasePath === undefined) { + delete process.env.MIRA_DASHBOARD_DB_PATH; + } else { + process.env.MIRA_DASHBOARD_DB_PATH = originalDatabasePath; + } + } + }); + it("tests pending migrations on a deploy copy without mutating live data", async () => { const root = temporaryRoot("mira-db-preflight-"); const databasePath = path.join(root, "dashboard.db"); diff --git a/backend/test/developmentStack.test.ts b/backend/test/developmentStack.test.ts index 5f39826ca..a9109748d 100644 --- a/backend/test/developmentStack.test.ts +++ b/backend/test/developmentStack.test.ts @@ -540,6 +540,12 @@ describe("development stack", () => { .all() ).toEqual([]); backfilledSnapshot.close(); + rmSync(sourceDatabase); + expect(prepareDevelopmentState(config)).toEqual({ + database: "reused", + releases: "reused", + workspace: "reused", + }); resetDevelopmentState(config); expect(existsSync(stateRoot)).toBe(false); diff --git a/backend/test/healthReadiness.test.ts b/backend/test/healthReadiness.test.ts index 2e037b57f..40be7af12 100644 --- a/backend/test/healthReadiness.test.ts +++ b/backend/test/healthReadiness.test.ts @@ -80,7 +80,7 @@ describe("Dashboard readiness contract", () => { }); }); - it("accepts only database schemas inside the explicit rollback window", () => { + it("accepts only database schemas inside the release runtime window", () => { expect(isDatabaseSchemaCompatible(7, { maximum: 7, minimum: 6 })).toBe(true); expect(isDatabaseSchemaCompatible(5, { maximum: 7, minimum: 6 })).toBe(false); expect(isDatabaseSchemaCompatible(8, { maximum: 7, minimum: 6 })).toBe(false); diff --git a/backend/test/releaseManager.test.ts b/backend/test/releaseManager.test.ts index 28f87e026..01dc04ee8 100644 --- a/backend/test/releaseManager.test.ts +++ b/backend/test/releaseManager.test.ts @@ -755,7 +755,7 @@ describe("Dashboard immutable release manager", () => { ); }); - it("requires an explicit coordinated mode for incompatible schema cutovers", async () => { + it("allows coordinated activation and requires it across incompatible schemas", async () => { const root = temporaryReleasesRoot(); await createManagedRelease(root, FIRST_COMMIT); const candidatePath = await createManagedRelease(root, SECOND_COMMIT); @@ -776,9 +776,9 @@ describe("Dashboard immutable release manager", () => { ...options, schemaCutoverMode: "coordinated", }) - ).rejects.toThrow( - "Coordinated schema cutover mode requires an incompatible schema boundary" - ); + ).resolves.toMatchObject({ + current: { commitSha: FIRST_COMMIT }, + }); await expect( activateDashboardRelease(SECOND_COMMIT, root, options) ).rejects.toThrow("cannot roll back after SQLite schema 8"); diff --git a/backend/test/serverStartupPolicy.test.ts b/backend/test/serverStartupPolicy.test.ts index d7e14afd8..6d35401b4 100644 --- a/backend/test/serverStartupPolicy.test.ts +++ b/backend/test/serverStartupPolicy.test.ts @@ -838,6 +838,7 @@ describe("server start scheduler policy", () => { ); let handleDashboardClientSpy: { mockRestore: () => void } | undefined; let getAuthSessionSpy: { mockRestore: () => void } | undefined; + let deploymentCutoverSpy: { mockRestore: () => void } | undefined; try { const now = new Date().toISOString(); const authModule = await import("../src/auth.ts"); @@ -859,6 +860,11 @@ describe("server start scheduler policy", () => { handleDashboardClientSpy = jest .spyOn(gatewayModule.default, "handleDashboardClient") .mockImplementation(() => {}); + const deploymentCutoverModule = + await import("../src/services/deploymentCutoverState.ts"); + deploymentCutoverSpy = jest + .spyOn(deploymentCutoverModule, "isProductionDeploymentCutoverActive") + .mockReturnValue(false); const { createServer } = await import("../src/server.ts"); const optionsSymbol = Symbol.for("mira.test.options"); const server = createServer(0, "127.0.0.1") as Server & { @@ -949,6 +955,17 @@ describe("server start scheduler policy", () => { ); expect(wsForbidden.status).toBe(403); + deploymentCutoverSpy.mockReturnValue(true); + const wsDuringCutover = await options.fetch( + new Request("https://test.local/ws", { + headers: { Origin: "https://test.local" }, + }), + server + ); + expect(wsDuringCutover.status).toBe(503); + expect(wsDuringCutover.headers.get("retry-after")).toBe("5"); + deploymentCutoverSpy.mockReturnValue(false); + const closeHandler = jest.fn(); const errorHandler = jest.fn(); const messageHandler = jest.fn(); @@ -1033,6 +1050,7 @@ describe("server start scheduler policy", () => { expect(errorHandler).toHaveBeenCalledWith(expect.any(Error)); expect(closeHandler).toHaveBeenCalled(); } finally { + deploymentCutoverSpy?.mockRestore(); getAuthSessionSpy?.mockRestore(); handleDashboardClientSpy?.mockRestore(); serveSpy.mockRestore(); diff --git a/backend/test/serviceBehavior.test.ts b/backend/test/serviceBehavior.test.ts index 4f2d1530e..42685bd26 100644 --- a/backend/test/serviceBehavior.test.ts +++ b/backend/test/serviceBehavior.test.ts @@ -49,13 +49,19 @@ function createTemporaryRoot(prefix: string): string { } async function executeSuccessfulGuardianPath(script: string): Promise { - const firstLifecycleBranch = script.indexOf("\nif MIRA_DASHBOARD_PROJECT_ROOT="); - if (firstLifecycleBranch === -1) { + const lifecycleBranches = [ + script.indexOf("\nif stop_services; then"), + script.indexOf("\nif MIRA_DASHBOARD_PROJECT_ROOT="), + ].filter((index) => index >= 0); + const firstLifecycleBranch = + lifecycleBranches.length > 0 ? Math.min(...lifecycleBranches) : undefined; + if (firstLifecycleBranch === undefined) { throw new Error("Guardian fixture is missing its lifecycle branch"); } const executableScript = [ "restart_services() { return 0; }", "ready_for_commit() { return 0; }", + "stop_services() { return 0; }", script.slice(firstLifecycleBranch + 1), ].join("\n"); const child = Bun.spawn(["/bin/bash", "-lc", executableScript], { @@ -77,6 +83,36 @@ async function executeSuccessfulGuardianPath(script: string): Promise { } } +async function executeSuccessfulGuardianHandoff(script: string): Promise { + const handoffStart = script.indexOf("\nMIRA_DEPLOYMENT_DB="); + const serviceStopBranch = script.indexOf("\nif stop_services; then", handoffStart); + if (handoffStart === -1 || serviceStopBranch === -1) { + throw new Error("Guardian fixture is missing its durable handoff"); + } + const child = Bun.spawn( + [ + "/usr/bin/timeout", + "5", + "/bin/bash", + "-lc", + script.slice(handoffStart + 1, serviceStopBranch), + ], + { + stderr: "pipe", + stdout: "pipe", + } + ); + const [exitCode, stderr] = await Promise.all([ + child.exited, + new Response(child.stderr).text(), + ]); + if (exitCode !== 0) { + throw new Error( + `Guardian handoff fixture failed with exit ${exitCode}: ${stderr.trim()}` + ); + } +} + function readableUtf8Stream(value: string): ReadableStream { return new ReadableStream({ start(controller) { @@ -2602,7 +2638,7 @@ printf 'scheduled\n' expect(row).toEqual({ commit_sha: candidateCommit, commit_title: "Deployable dashboard commit", - note: "Release published. Activating it, restarting services, then verifying web, worker, deployed commit, and 31 seconds of worker stability; automatic rollback is armed", + note: "Release published. Pausing Dashboard writes, snapshotting SQLite, activating it, then verifying web, worker, deployed commit, and 31 seconds of worker stability; code-and-data rollback is armed", status: "verifying", }); const scheduledUpdatedAt = ( @@ -2655,16 +2691,36 @@ printf 'scheduled\n' expect(restartCommand).toContain("updatedAt: new Date().toISOString()"); expect(restartCommand).toContain(".checks.release.backendCommit"); expect(restartCommand).toContain("releaseLifecycle.js"); + expect(restartCommand).toContain("MIRA_DEPLOYMENT_SNAPSHOT_ID="); + expect(restartCommand).toContain('execution?.status === "success"'); expect(restartCommand).toContain( - `${releasesRoot}/releases/${oldCommit}/backend/dist/releaseLifecycle.js` + `${releasesRoot}/releases/${candidateCommit}/backend/dist/releaseLifecycle.js` ); + expect(restartCommand).toContain("if stop_services; then"); + expect(restartCommand).toContain("snapshot-database"); + expect(restartCommand).toContain("restore-database"); + expect(restartCommand).toContain("discard-database-snapshot"); expect(restartCommand).toContain( - `${releasesRoot}/releases/${candidateCommit}/backend/dist/releaseLifecycle.js` + `activate '${candidateCommit}' --coordinated-schema-cutover` ); - expect(restartCommand).toContain(`activate '${candidateCommit}'`); expect(restartCommand.indexOf(`activate '${candidateCommit}'`)).toBeLessThan( restartCommand.indexOf("if restart_services") ); + expect(restartCommand.indexOf("if stop_services; then")).toBeLessThan( + restartCommand.indexOf("snapshot-database") + ); + expect(restartCommand.indexOf("MIRA_DEPLOYMENT_SNAPSHOT_ID=")).toBeLessThan( + restartCommand.indexOf("if stop_services; then") + ); + await executeSuccessfulGuardianHandoff(restartCommand); + expect(restartCommand.indexOf("snapshot-database")).toBeLessThan( + restartCommand.indexOf(`activate '${candidateCommit}'`) + ); + expect( + restartCommand.indexOf( + "Atomic release activated. Web, worker, commit, and 31-second worker stability checks passed" + ) + ).toBeLessThan(restartCommand.indexOf("discard-database-snapshot")); expect(restartCommand).toContain( `restore '${candidateCommit}' '${oldCommit}' '${priorPreviousCommit}'` ); @@ -2675,12 +2731,23 @@ printf 'scheduled\n' `restore '${candidateCommit}' '${oldCommit}' '${priorPreviousCommit}'` ) ); + if (!automaticRollbackLine) { + throw new Error( + "Guardian fixture is missing its automatic rollback line" + ); + } expect(automaticRollbackLine).toContain( `${releasesRoot}/releases/${candidateCommit}/backend/dist/releaseLifecycle.js` ); expect(automaticRollbackLine).not.toContain( `${releasesRoot}/releases/${oldCommit}/backend/dist/releaseLifecycle.js` ); + expect(automaticRollbackLine).toContain("if stop_services &&"); + expect(automaticRollbackLine.indexOf("restore-database")).toBeLessThan( + automaticRollbackLine.indexOf( + `restore '${candidateCommit}' '${oldCommit}' '${priorPreviousCommit}'` + ) + ); expect(restartCommand).toContain("prune 3"); expect(restartCommand).not.toContain("/api/job-executions"); expect(readlinkSync(path.join(releasesRoot, "current"))).toBe( @@ -2708,7 +2775,8 @@ printf 'scheduled\n' deploymentId: job.id, releaseCutover: { candidateCommit, - formatVersion: 1, + databaseSnapshotId: expect.stringMatching(/^[\da-f-]{36}$/u), + formatVersion: 2, preActivationCommit: oldCommit, preActivationPreviousCommit: priorPreviousCommit, rollbackCommit: oldCommit, @@ -2739,7 +2807,7 @@ printf 'scheduled\n' ); expect(recoveryCommand).toContain('activation_release="$candidate_release"'); expect(recoveryCommand).toContain( - 'activation_output="$(run_activation_lifecycle activate "$candidate_commit")"' + 'activation_output="$(run_candidate_lifecycle activate "$candidate_commit" --coordinated-schema-cutover)"' ); expect(recoveryCommand).toContain( '[ "$activation_commit" = "$candidate_commit" ]' @@ -2750,9 +2818,19 @@ printf 'scheduled\n' expect(recoveryCommand).toContain( "Interrupted release cutover recovered; active candidate passed restart, commit-bound readiness, and 31-second worker stability" ); + const recoveredSuccessStatusIndex = recoveryCommand.indexOf( + "Interrupted release cutover recovered; active candidate passed restart, commit-bound readiness, and 31-second worker stability" + ); + expect(recoveredSuccessStatusIndex).toBeGreaterThanOrEqual(0); + expect(recoveredSuccessStatusIndex).toBeLessThan( + recoveryCommand.indexOf( + "run_candidate_lifecycle discard-database-snapshot", + recoveredSuccessStatusIndex + ) + ); expect( recoveryCommand.indexOf( - 'activation_output="$(run_activation_lifecycle activate "$candidate_commit")"' + 'activation_output="$(run_candidate_lifecycle activate "$candidate_commit" --coordinated-schema-cutover)"' ) ).toBeLessThan( recoveryCommand.indexOf( @@ -2762,6 +2840,9 @@ printf 'scheduled\n' expect(recoveryCommand).toContain( 'run_candidate_lifecycle restore "$candidate_commit" "$rollback_commit" "$pre_activation_previous_commit"' ); + expect(recoveryCommand).toContain( + 'run_candidate_lifecycle restore-database "$database_snapshot_id"' + ); expect(recoveryCommand).toContain(`expected_rollback_commit='${oldCommit}'`); expect(recoveryCommand).toContain( `pre_activation_previous_commit='${priorPreviousCommit}'` @@ -2840,7 +2921,8 @@ printf 'scheduled\n' deploymentId: redeploy.id, releaseCutover: { candidateCommit: oldCommit, - formatVersion: 1, + databaseSnapshotId: expect.stringMatching(/^[\da-f-]{36}$/u), + formatVersion: 2, preActivationCommit: oldCommit, preActivationPreviousCommit: priorPreviousCommit, rollbackCommit: priorPreviousCommit, @@ -2943,7 +3025,7 @@ printf 'scheduled\n' .run(deploymentId); } } - }); + }, 10_000); it("reports production checkout readiness through git command output", async () => { rememberEnvironment("PATH"); diff --git a/backend/test/utilityBehavior.test.ts b/backend/test/utilityBehavior.test.ts index 1bca66903..f08722778 100644 --- a/backend/test/utilityBehavior.test.ts +++ b/backend/test/utilityBehavior.test.ts @@ -5,6 +5,7 @@ import path from "node:path"; import type { Server } from "bun"; import { describe, expect, it, jest } from "bun:test"; +import { database } from "../src/database.ts"; import * as databaseMigrationRunnerModule from "../src/databaseMigrationRunner.ts"; import { isAllowedDashboardOrigin, @@ -35,6 +36,7 @@ import { stringFallback, } from "../src/lib/values.ts"; import { + isDeploymentCutoverMutationBlocked, isDevelopmentExternalNotificationSuppressed, isDevelopmentGatewayMethodBlocked, isDevelopmentGatewayProxyEventAllowed, @@ -50,6 +52,7 @@ import { compactHeartbeatData } from "../src/routes/cacheRoutes.ts"; import { isValidAgentId } from "../src/services/agents.ts"; import { listAuditEvents } from "../src/services/auditEvents.ts"; import { mapBackupJob } from "../src/services/backups.ts"; +import { isProductionDeploymentCutoverActive } from "../src/services/deploymentCutoverState.ts"; import * as jobExecutionQueueModule from "../src/services/jobExecutionQueue.ts"; import { dashboardJobProfile } from "../src/services/jobWorker.ts"; import { @@ -68,6 +71,8 @@ function serverWithAddress(address: string): Server { } as unknown as Server; } +const isCutoverActive = () => true; + function canonicalPath(value: string): string { return path.join(realpathSync(path.dirname(value)), path.basename(value)); } @@ -609,6 +614,88 @@ describe("backend service utilities", () => { expect(dashboardJobProfile({ MIRA_DASHBOARD_DEV_SAFE_MODE: "0" })).toBe("full"); }); + it("pauses production mutations while allowing cutover readiness reads", () => { + const environment = { NODE_ENV: "production" }; + expect( + isDeploymentCutoverMutationBlocked( + new Request("https://dashboard.example/api/tasks", { + method: "POST", + }), + { environment, isCutoverActive } + ) + ).toBe(true); + expect( + isDeploymentCutoverMutationBlocked( + new Request("https://dashboard.example/api/health/ready"), + { environment, isCutoverActive } + ) + ).toBe(false); + expect( + isDeploymentCutoverMutationBlocked( + new Request("https://dashboard.example/api/tasks", { + headers: { "x-mira-user-activity": "1" }, + }), + { environment, isCutoverActive } + ) + ).toBe(true); + expect( + isDeploymentCutoverMutationBlocked( + new Request("https://dashboard.example/api/tasks", { + method: "POST", + }), + { environment, isCutoverActive: () => false } + ) + ).toBe(false); + }); + + it("detects and enforces a persisted production deployment cutover", async () => { + const deploymentId = `test-cutover-${Bun.randomUUIDv7()}`; + const timestamp = new Date().toISOString(); + const originalNodeEnvironment = process.env.NODE_ENV; + expect(isProductionDeploymentCutoverActive({ NODE_ENV: "test" })).toBe(false); + try { + database + .prepare( + `INSERT INTO deployment_jobs ( + id, status, started_at, updated_at, commit_sha, + commit_title, note, stdout, stderr + ) VALUES (?, 'verifying', ?, ?, NULL, NULL, NULL, NULL, NULL)` + ) + .run(deploymentId, timestamp, timestamp); + expect(isProductionDeploymentCutoverActive({ NODE_ENV: "production" })).toBe( + true + ); + process.env.NODE_ENV = "production"; + const handler = jest.fn(() => new Response("must not run")); + const routes = withRequestPolicy({ "/api/tasks": handler }); + const response = await callTestRoute( + routes, + "/api/tasks", + serverWithAddress("127.0.0.1"), + { method: "POST" } + ); + expect(response.status).toBe(503); + expect(response.headers.get("retry-after")).toBe("5"); + await expect(response.json()).resolves.toEqual({ + code: "deployment_cutover_in_progress", + error: "Dashboard writes are paused while the release is verified", + }); + expect(handler).not.toHaveBeenCalled(); + } finally { + if (originalNodeEnvironment === undefined) { + delete process.env.NODE_ENV; + } else { + process.env.NODE_ENV = originalNodeEnvironment; + } + database + .prepare("DELETE FROM deployment_jobs WHERE id = ?") + .run(deploymentId); + } + expect(isProductionDeploymentCutoverActive({ NODE_ENV: "production" })).toBe( + false + ); + }); + it("maps operational errors without leaking unknown values", () => { const blankError = new Error(" ".repeat(3)); expect(errorMessage(new Error(" failed "), "fallback")).toBe("failed"); diff --git a/docs/architecture/database.md b/docs/architecture/database.md index 39b8ce7f0..1de9be2c9 100644 --- a/docs/architecture/database.md +++ b/docs/architecture/database.md @@ -140,7 +140,11 @@ validates its recorded migration prefix, creates a `pre-deploy` snapshot with requires `PRAGMA quick_check = ok` plus valid migration history, then applies every pending migration to that disposable copy and validates it again. The retained `pre-deploy` snapshot and live database remain unchanged. Ordinary -builds remain side-effect free. +builds remain side-effect free. Managed activation later stops both Dashboard +writers and creates a separate UUID-bound `cutover` snapshot. User mutations +and worker claims stay paused until readiness succeeds. A failed activation +atomically restores that snapshot before the exact old release slots restart; a +successful activation discards it. The enabled `database.maintenance` worker job runs daily at `02:40`. It creates and restore-verifies a `scheduled` backup before pruning bounded history, @@ -163,6 +167,7 @@ Snapshots live beside the active database below `backups/`. This is | Kind | Maximum age | Maximum count | | --------------- | ----------- | ------------- | +| `cutover` | 2 days | 5 | | `scheduled` | 14 days | 14 | | `pre-deploy` | 90 days | 20 | | `pre-migration` | 180 days | 20 | diff --git a/docs/operations/scheduler-cache-backups.md b/docs/operations/scheduler-cache-backups.md index 023510619..50a7a26dd 100644 --- a/docs/operations/scheduler-cache-backups.md +++ b/docs/operations/scheduler-cache-backups.md @@ -224,9 +224,11 @@ History retention keeps: | chat replay snapshots and events | 30 days and at most 200 snapshots globally; orphan events removed | Active, queued, and running execution/deployment rows are preserved. SQLite -snapshot retention is 14 scheduled/14 days, 20 pre-deploy/90 days, and 20 -pre-migration/180 days. Unread notifications are preserved. The job does not -run automatic `VACUUM`. +snapshot retention is 5 orphan-safety cutover snapshots/2 days, 14 +scheduled/14 days, 20 pre-deploy/90 days, and 20 pre-migration/180 days. Normal +successful or recovered cutovers discard their UUID-bound snapshot immediately; +the retention bound is a crash-recovery fallback. Unread notifications are +preserved. The job does not run automatic `VACUUM`. ## Operational Checks diff --git a/docs/setup/production-deploy.md b/docs/setup/production-deploy.md index 6f1b4d3d8..cbecc312e 100644 --- a/docs/setup/production-deploy.md +++ b/docs/setup/production-deploy.md @@ -47,8 +47,9 @@ Mutable state is deliberately outside both Git and every release: | Dashboard Gateway device identity | `/home/ubuntu/projects/mira-dashboard/production/state/openclaw-client/` | | Log-rotation lock | `/home/ubuntu/projects/mira-dashboard/production/state/log-rotation.lock` | -The backup directory is derived from the production state root, so pre-deploy -and pre-migration snapshots automatically stay under the state root. +The backup directory is derived from the production state root, so cutover, +pre-deploy, and pre-migration snapshots automatically stay under the state +root. Kopia mounts `/home/ubuntu/projects` as its projects source; the separate state directory remains in that backup scope. @@ -86,15 +87,23 @@ The Dashboard worker owns the deployment: every checksummed artifact. 6. Copy only declared artifacts to a hidden directory and atomically publish it as `releases/`. -7. Start a detached cutover guardian, which atomically switches `current` and - retains the old release as `previous`. -8. Restart web and worker from inside that guardian. -9. Require `/api/health/ready` to report the exact expected frontend/backend - commit and a fresh worker heartbeat from that commit. -10. On failure, switch back to `previous`, restart both units, verify the old - commit, and mark the deployment failed. -11. On success, retain `current`, `previous`, and one additional newest - verified release. +7. Persist a unique cutover-snapshot id and start a detached guardian. +8. Require the scheduling execution, snapshot id, deployment row, and release + lock to be durably consistent. Then stop web and worker, create and + restore-verify the exact SQLite cutover snapshot, and atomically switch + `current` while retaining the old release as `previous`. +9. Start web and worker. Unsafe HTTP requests, explicit user-activity touches, + Gateway WebSockets, and worker execution claims remain paused while the + deployment row is `verifying`. +10. Require `/api/health/ready` to report the exact expected frontend/backend + commit and a fresh, stable worker heartbeat from that commit. +11. On failure, stop both units, atomically restore the recorded database + snapshot, restore the exact pre-activation release slots, restart both + units, verify the old commit, and mark the deployment failed. +12. On success, retain `current`, `previous`, and one additional newest + verified release, record the terminal result, then discard the one-cutover + snapshot. Terminalizing first prevents a crash from leaving `verifying` + without its rollback snapshot. The executor fails closed unless both units use the expected project root and run from managed `current/backend`. A deployment never modifies the running @@ -120,12 +129,18 @@ must still return `401`. ## Rollback -Normal activation automatically rolls back on restart or commit-bound readiness -failure. The preferred manual path is **Delivery → Production releases → +Normal activation automatically rolls code and data back on restart or +commit-bound readiness failure before the cutover reaches a terminal state. The +preferred manual path is **Delivery → Production releases → Roll back**, which uses the same exclusive release lock, persistent job, detached guardian, web/worker restart, commit-bound readiness, and automatic restoration of the original release if the rollback target fails. +A later manual rollback is intentionally code-only and remains constrained by +the live schema compatibility window. It never restores a pre-deploy snapshot +after a successful release, because doing so would discard writes accepted +after deployment. + Use the host-local fallback below only when the Dashboard UI is unavailable. First confirm no deployment or rollback action is running: @@ -235,29 +250,36 @@ separate restore-verified `pre-migration` backup before running migration SQL. 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. -Code rollback and data rollback are separate decisions. A code rollback may use -the migrated database only when the older code is schema-compatible. Otherwise -stop both units and restore the matching snapshot using the -[SQLite restore runbook](../operations/runbooks.md#restore-dashboard-sqlite). +Build preflight snapshots prove that migrations are runnable, but managed +activation creates a separate `cutover` snapshot only after both Dashboard +writers are stopped. That exact snapshot id is persisted in the deployment +context so detached recovery cannot guess which backup belongs to the release. +During candidate verification, production mutations and worker execution claims +are paused. Failed activation restores data first and old code second. + +After activation succeeds, code rollback and data rollback are separate +operator decisions. A later code rollback may use the migrated database only +when the older code is schema-compatible. Otherwise use the +[SQLite restore runbook](../operations/runbooks.md#restore-dashboard-sqlite) +with an explicitly selected backup and accept that it is a data-loss recovery. ### Schema Compatibility -Classify every migration before release: +The manifest range describes which live schema versions that release can open; +it does not describe a reversible SQL path. Classify every migration before +release: - **expand/backward-compatible:** `previous` can safely use the migrated schema; - **contract/incompatible:** older code cannot safely use the new schema or data semantics, so automatic code-only rollback is blocked. -Prefer expand/migrate/contract across separate releases. If an incompatible -change cannot be phased, use a coordinated code-and-data cutover: - -1. require an idle execution queue; -2. stop both units; -3. rerun candidate preflight and record its fresh verified snapshot; -4. activate with `--coordinated-schema-cutover`; -5. start both units and require commit/schema readiness; -6. on failure, stop both units, restore the recorded snapshot, switch code back, - and only then restart. +Every managed deployment now uses the coordinated code-and-data cutover above, +including schema-compatible releases. An incompatible migration may therefore +cross the previous release's runtime window safely during initial activation: +failure restores the old schema snapshot before old code starts. Once the +candidate passes readiness, its cutover snapshot is discarded and the old +release is no longer a valid manual rollback target unless it can open the live +schema. The migration runner has no destructive down-migration path. Unknown newer migrations make older code fail closed. From fd30d5b073de1ce91e46d693140109cef8f57c96 Mon Sep 17 00:00:00 2001 From: mira-2026 Date: Tue, 28 Jul 2026 02:00:52 +0200 Subject: [PATCH 7/9] fix: harden release cutover handoff --- backend/src/requestPolicy.ts | 1 + backend/src/services/deploymentCutoverState.ts | 3 +++ backend/src/services/pullRequests.ts | 3 ++- backend/test/serverStartupPolicy.test.ts | 7 ++++--- backend/test/serviceBehavior.test.ts | 1 + 5 files changed, 11 insertions(+), 4 deletions(-) diff --git a/backend/src/requestPolicy.ts b/backend/src/requestPolicy.ts index 686556a18..3b223ba41 100644 --- a/backend/src/requestPolicy.ts +++ b/backend/src/requestPolicy.ts @@ -158,6 +158,7 @@ export function isDeploymentCutoverMutationBlocked( return false; } return ( + // Safe methods still write session activity when this touch header is set. !SAFE_REQUEST_METHODS.has(request.method.toUpperCase()) || request.headers.get("x-mira-user-activity")?.trim() === "1" ); diff --git a/backend/src/services/deploymentCutoverState.ts b/backend/src/services/deploymentCutoverState.ts index 6f4a1d631..702b54b47 100644 --- a/backend/src/services/deploymentCutoverState.ts +++ b/backend/src/services/deploymentCutoverState.ts @@ -7,6 +7,9 @@ export function isProductionDeploymentCutoverActive( if (environment.NODE_ENV !== "production") { return false; } + // The retained deployment history is capped at 500 rows. This bounded, + // fail-closed read deliberately avoids a process-local cache that a detached + // guardian could leave stale while it changes the deployment state. return Boolean( database .query( diff --git a/backend/src/services/pullRequests.ts b/backend/src/services/pullRequests.ts index 33c3f419f..9a0a62f94 100644 --- a/backend/src/services/pullRequests.ts +++ b/backend/src/services/pullRequests.ts @@ -91,7 +91,8 @@ const PUBLIC_PR_FAILURE_CACHE_MS = 30_000; const PUBLIC_GITHUB_API_TIMEOUT_MS = 15_000; const DEPLOYMENT_RESTART_STATUS_POLL_MS = 1000; const DEPLOYMENT_RESTART_CLAIM_PAUSE_TIMEOUT_MS = 2 * 60 * 1000; -const DEPLOYMENT_CUTOVER_HANDOFF_TIMEOUT_MS = 30_000; +// Must exceed scheduled-job interrupted-handler cleanup before final status is durable. +const DEPLOYMENT_CUTOVER_HANDOFF_TIMEOUT_MS = 75_000; const MAX_DEPLOYMENT_CUTOVER_CONTEXT_BYTES = 4096; const DEPLOYMENT_CUTOVER_CONTEXT_FORMAT_VERSION = 2; const DEPLOYMENT_WORKER_STABILITY_SECONDS = diff --git a/backend/test/serverStartupPolicy.test.ts b/backend/test/serverStartupPolicy.test.ts index 6d35401b4..db82b9a99 100644 --- a/backend/test/serverStartupPolicy.test.ts +++ b/backend/test/serverStartupPolicy.test.ts @@ -839,6 +839,7 @@ describe("server start scheduler policy", () => { let handleDashboardClientSpy: { mockRestore: () => void } | undefined; let getAuthSessionSpy: { mockRestore: () => void } | undefined; let deploymentCutoverSpy: { mockRestore: () => void } | undefined; + let isDeploymentCutoverActive = false; try { const now = new Date().toISOString(); const authModule = await import("../src/auth.ts"); @@ -864,7 +865,7 @@ describe("server start scheduler policy", () => { await import("../src/services/deploymentCutoverState.ts"); deploymentCutoverSpy = jest .spyOn(deploymentCutoverModule, "isProductionDeploymentCutoverActive") - .mockReturnValue(false); + .mockImplementation(() => isDeploymentCutoverActive); const { createServer } = await import("../src/server.ts"); const optionsSymbol = Symbol.for("mira.test.options"); const server = createServer(0, "127.0.0.1") as Server & { @@ -955,7 +956,7 @@ describe("server start scheduler policy", () => { ); expect(wsForbidden.status).toBe(403); - deploymentCutoverSpy.mockReturnValue(true); + isDeploymentCutoverActive = true; const wsDuringCutover = await options.fetch( new Request("https://test.local/ws", { headers: { Origin: "https://test.local" }, @@ -964,7 +965,7 @@ describe("server start scheduler policy", () => { ); expect(wsDuringCutover.status).toBe(503); expect(wsDuringCutover.headers.get("retry-after")).toBe("5"); - deploymentCutoverSpy.mockReturnValue(false); + isDeploymentCutoverActive = false; const closeHandler = jest.fn(); const errorHandler = jest.fn(); diff --git a/backend/test/serviceBehavior.test.ts b/backend/test/serviceBehavior.test.ts index 42685bd26..8fd920b08 100644 --- a/backend/test/serviceBehavior.test.ts +++ b/backend/test/serviceBehavior.test.ts @@ -2693,6 +2693,7 @@ printf 'scheduled\n' expect(restartCommand).toContain("releaseLifecycle.js"); expect(restartCommand).toContain("MIRA_DEPLOYMENT_SNAPSHOT_ID="); expect(restartCommand).toContain('execution?.status === "success"'); + expect(restartCommand).toContain("const deadline = Date.now() + 75000"); expect(restartCommand).toContain( `${releasesRoot}/releases/${candidateCommit}/backend/dist/releaseLifecycle.js` ); From 03f6c398483c50e79aab305f917591cdeaa7f8a7 Mon Sep 17 00:00:00 2001 From: mira-2026 Date: Tue, 28 Jul 2026 02:46:52 +0200 Subject: [PATCH 8/9] fix: gate incompatible release rollbacks --- backend/src/releaseManager.ts | 52 +++++--- backend/src/services/pullRequests.ts | 67 +++++++--- backend/test/serviceBehavior.test.ts | 114 +++++++++++++++++- backend/test/support/releaseFixture.ts | 52 +++++++- .../delivery/ProductionReleasesCard.tsx | 4 +- 5 files changed, 244 insertions(+), 45 deletions(-) diff --git a/backend/src/releaseManager.ts b/backend/src/releaseManager.ts index 71bf019b2..fa9f43e6c 100644 --- a/backend/src/releaseManager.ts +++ b/backend/src/releaseManager.ts @@ -801,6 +801,36 @@ export function assertReleaseRollbackCompatible( } } +/** + * Verifies that a managed rollback release can open the live schema and agrees + * with its applied migration history. + */ +export async function assertManagedDashboardReleaseRollbackSchemaCompatible( + activeRelease: ManagedDashboardRelease, + rollbackRelease: ManagedDashboardRelease, + options: DashboardReleaseManagerOptions = {} +): Promise { + const maximumInspectableSchemaVersion = Math.max( + DASHBOARD_DATABASE_SCHEMA_COMPATIBILITY.maximum, + activeRelease.manifest.schema.maximumCompatible, + rollbackRelease.manifest.schema.maximumCompatible + ); + const liveSchemaState = await resolveLiveSchemaState( + options, + maximumInspectableSchemaVersion + ); + assertReleaseRollbackCompatible( + activeRelease.manifest, + rollbackRelease.manifest, + liveSchemaState.version + ); + assertReleaseMigrationHistoryCompatible( + rollbackRelease.manifest, + liveSchemaState, + "Rollback" + ); +} + function assertReleaseCanActivateLiveSchema( release: DashboardReleaseManifest, liveSchemaVersion: number, @@ -1366,24 +1396,10 @@ export async function rollbackDashboardRelease( const activeRelease = state.current; const rollbackRelease = state.previous; assertDashboardReleaseHostRuntimeCompatible(rollbackRelease); - const maximumInspectableSchemaVersion = Math.max( - DASHBOARD_DATABASE_SCHEMA_COMPATIBILITY.maximum, - activeRelease.manifest.schema.maximumCompatible, - rollbackRelease.manifest.schema.maximumCompatible - ); - const liveSchemaState = await resolveLiveSchemaState( - options, - maximumInspectableSchemaVersion - ); - assertReleaseRollbackCompatible( - activeRelease.manifest, - rollbackRelease.manifest, - liveSchemaState.version - ); - assertReleaseMigrationHistoryCompatible( - rollbackRelease.manifest, - liveSchemaState, - "Rollback" + await assertManagedDashboardReleaseRollbackSchemaCompatible( + activeRelease, + rollbackRelease, + options ); const before = releaseLinkStateFromDashboardState(state); diff --git a/backend/src/services/pullRequests.ts b/backend/src/services/pullRequests.ts index 9a0a62f94..4133159ee 100644 --- a/backend/src/services/pullRequests.ts +++ b/backend/src/services/pullRequests.ts @@ -19,6 +19,7 @@ import { } from "../releaseDeployment.ts"; import { assertDashboardReleaseHostRuntimeCompatible, + assertManagedDashboardReleaseRollbackSchemaCompatible, type ManagedDashboardRelease, readDashboardReleaseState, resolveDashboardReleasesRoot, @@ -767,7 +768,7 @@ interface DeploymentRuntimeResultRow { * readiness. Build failures and cancelled jobs do not disqualify an otherwise * verified immutable release. */ -function rollbackIneligibilityReason( +function rollbackRuntimeIneligibilityReason( commitSha: string, excludedJobId?: string ): string | undefined { @@ -801,6 +802,31 @@ function rollbackIneligibilityReason( : undefined; } +async function rollbackIneligibilityReason( + activeRelease: ManagedDashboardRelease, + rollbackRelease: ManagedDashboardRelease, + excludedJobId?: string +): Promise { + const runtimeReason = rollbackRuntimeIneligibilityReason( + rollbackRelease.commitSha, + excludedJobId + ); + if (runtimeReason) return runtimeReason; + + try { + await assertManagedDashboardReleaseRollbackSchemaCompatible( + activeRelease, + rollbackRelease + ); + return undefined; + } catch (error) { + return errorMessage( + error, + "Previous release schema compatibility could not be verified" + ); + } +} + function dashboardReleaseSummary( release: ManagedDashboardRelease ): DashboardReleaseSummary { @@ -826,19 +852,19 @@ export async function getDashboardReleaseStatus(): Promise Promise | void> = []; @@ -1887,6 +1890,79 @@ describe("backend service behavior", () => { } }); + it("hides schema-incompatible rollback targets before queueing work", async () => { + rememberEnvironment("MIRA_DASHBOARD_RELEASES_ROOT"); + const releasesRoot = createTemporaryRoot("mira-release-schema-status-"); + const currentCommit = "c".repeat(40); + const previousCommit = "d".repeat(40); + await ensureDashboardReleaseLayout(releasesRoot); + await createReleaseFixture( + managedReleasePath(releasesRoot, currentCommit), + currentCommit, + { commitTitle: "Schema 7 dashboard release" } + ); + const previousReleasePath = managedReleasePath(releasesRoot, previousCommit); + await createReleaseFixture(previousReleasePath, previousCommit, { + commitTitle: "Schema 6 dashboard release", + }); + await rewriteReleaseFixtureSchemaVersion(previousReleasePath, 6); + symlinkSync( + `releases/${currentCommit}`, + path.join(releasesRoot, "current"), + "dir" + ); + symlinkSync( + `releases/${previousCommit}`, + path.join(releasesRoot, "previous"), + "dir" + ); + process.env.MIRA_DASHBOARD_RELEASES_ROOT = releasesRoot; + + const { getDashboardReleaseStatus, prepareAndStartRollback } = + await import("../src/services/pullRequests.ts"); + const { pullRequestRoutes } = await import("../src/routes/pullRequestRoutes.ts"); + const rollbackExecutionCount = () => + ( + database + .prepare( + `SELECT COUNT(*) AS count + FROM job_executions + WHERE action_key = 'dashboard.rollback'` + ) + .get() as { count: number } + ).count; + const executionCountBefore = rollbackExecutionCount(); + + await expect(getDashboardReleaseStatus()).resolves.toMatchObject({ + current: { + commitSha: currentCommit, + schema: { maximumCompatible: 7, target: 7 }, + }, + previous: { + commitSha: previousCommit, + schema: { maximumCompatible: 6, target: 6 }, + }, + rollback: { + available: false, + reason: "Rollback release cannot open SQLite schema 7", + }, + }); + await expect(prepareAndStartRollback(previousCommit)).rejects.toThrow( + "Previous release is not eligible for rollback: Rollback release cannot open SQLite schema 7" + ); + const response = await pullRequestRoutes[ + "/api/pull-requests/releases/rollback" + ].POST(rollbackRouteRequest(previousCommit)); + expect(response.status).toBe(409); + await expect(response.json()).resolves.toMatchObject({ + error: "Previous release is not eligible for rollback: Rollback release cannot open SQLite schema 7", + }); + expect(rollbackExecutionCount()).toBe(executionCountBefore); + expect( + database.prepare("SELECT job_id FROM deployment_lock WHERE id = 1").get() + ).toBeNull(); + }); + it("rejects malformed and missing rollback worker executions", async () => { const { registerPullRequestExecutionActions } = await import("../src/services/pullRequests.ts"); @@ -2450,11 +2526,13 @@ printf 'scheduled\n' await createReleaseFixture(oldReleasePath, oldCommit, { commitTitle: "Previous dashboard commit", }); - await createReleaseFixture( - managedReleasePath(releasesRoot, priorPreviousCommit), - priorPreviousCommit, - { commitTitle: "Older dashboard commit" } + const priorPreviousReleasePath = managedReleasePath( + releasesRoot, + priorPreviousCommit ); + await createReleaseFixture(priorPreviousReleasePath, priorPreviousCommit, { + commitTitle: "Older dashboard commit", + }); symlinkSync(`releases/${oldCommit}`, path.join(releasesRoot, "current"), "dir"); symlinkSync( `releases/${priorPreviousCommit}`, @@ -2981,6 +3059,32 @@ printf 'scheduled\n' note: "Automatic redeploy fallback is not eligible: Previous release failed its latest runtime readiness check", status: "failed", }); + database + .prepare("DELETE FROM deployment_jobs WHERE id = ?") + .run(failedRuntimeId); + await rewriteReleaseFixtureSchemaVersion(priorPreviousReleasePath, 6); + const schemaBlockedRedeploy = startDeployLatest(); + createdDeploymentIds.push(schemaBlockedRedeploy.id); + const schemaBlockedExecution = database + .prepare( + `SELECT id + FROM job_executions + WHERE action_key = 'dashboard.deploy' + AND json_extract(payload_json, '$.deploymentId') = ?` + ) + .get(schemaBlockedRedeploy.id) as { id: string }; + await waitFor( + () => getJobExecution(schemaBlockedExecution.id)?.status === "failed", + 5000 + ); + expect( + database + .prepare("SELECT status, note FROM deployment_jobs WHERE id = ?") + .get(schemaBlockedRedeploy.id) + ).toEqual({ + note: "Automatic redeploy fallback is not eligible: Rollback release cannot open SQLite schema 7", + status: "failed", + }); await executeSuccessfulGuardianPath(restartCommand); const completedDeployment = database .prepare( diff --git a/backend/test/support/releaseFixture.ts b/backend/test/support/releaseFixture.ts index c1a12f7c1..153b26aa5 100644 --- a/backend/test/support/releaseFixture.ts +++ b/backend/test/support/releaseFixture.ts @@ -1,7 +1,14 @@ import { mkdirSync, writeFileSync } from "node:fs"; import path from "node:path"; -import { writeReleaseManifest } from "../../src/releaseManifest.ts"; +import { databaseMigrations } from "../../src/databaseMigrations/index.ts"; +import { + databaseMigrationInventorySha256, + loadReleaseManifest, + parseReleaseManifest, + RELEASE_MANIFEST_FILE_NAME, + writeReleaseManifest, +} from "../../src/releaseManifest.ts"; interface ReleaseFixtureOptions { builtAt?: Date; @@ -69,3 +76,46 @@ export async function createReleaseFixture( releaseRoot, }); } + +/** Rewrites a release fixture to model an older exact schema compatibility window. */ +export async function rewriteReleaseFixtureSchemaVersion( + releaseRoot: string, + schemaVersion: number +): Promise { + const manifest = await loadReleaseManifest(releaseRoot); + if ( + !Number.isSafeInteger(schemaVersion) || + schemaVersion < 0 || + schemaVersion > manifest.schema.target || + schemaVersion > databaseMigrations.length + ) { + throw new TypeError("Release fixture schema version is invalid"); + } + const migrations = manifest.schema.migrations.slice(0, schemaVersion); + const migrationRegistrySha256 = new Bun.CryptoHasher("sha256") + .update( + databaseMigrations + .slice(0, schemaVersion) + .map( + (migration) => + `${migration.version}\0${migration.name}\0${migration.sql}` + ) + .join("\0") + ) + .digest("hex"); + const rewritten = parseReleaseManifest({ + ...manifest, + schema: { + maximumCompatible: schemaVersion, + migrations, + migrationInventorySha256: databaseMigrationInventorySha256(migrations), + migrationRegistrySha256, + minimumCompatible: schemaVersion, + target: schemaVersion, + }, + }); + writeFileSync( + path.join(releaseRoot, RELEASE_MANIFEST_FILE_NAME), + `${JSON.stringify(rewritten, undefined, 2)}\n` + ); +} diff --git a/src/components/features/delivery/ProductionReleasesCard.tsx b/src/components/features/delivery/ProductionReleasesCard.tsx index 789db2d87..3f6cee0f7 100644 --- a/src/components/features/delivery/ProductionReleasesCard.tsx +++ b/src/components/features/delivery/ProductionReleasesCard.tsx @@ -97,8 +97,8 @@ export function ProductionReleasesCard({ Production releases

Active and previous are immutable release slots. A previous - release is offered as a rollback target only while its latest - runtime result is eligible. + release is offered as a rollback target only while its runtime + result and the live database schema remain compatible.

Date: Tue, 28 Jul 2026 03:27:55 +0200 Subject: [PATCH 9/9] fix: recover guarded release cutovers --- backend/src/releaseManager.ts | 34 ++++++++++------------ backend/src/server.ts | 15 ++++++++++ backend/src/services/pullRequests.ts | 5 +++- backend/test/releaseManager.test.ts | 36 +++++++++++++++++++---- backend/test/serverStartupPolicy.test.ts | 37 +++++++++++++++++++++++- backend/test/serviceBehavior.test.ts | 26 +++++++++++++++++ 6 files changed, 127 insertions(+), 26 deletions(-) diff --git a/backend/src/releaseManager.ts b/backend/src/releaseManager.ts index fa9f43e6c..824c592ba 100644 --- a/backend/src/releaseManager.ts +++ b/backend/src/releaseManager.ts @@ -1462,6 +1462,18 @@ export async function restoreDashboardReleaseAfterFailedActivation( async () => { await recoverInterruptedReleaseTransition(layout); const state = await readDashboardReleaseStateFromLayout(layout); + if ( + state.current?.commitSha === rollbackCommitSha && + state.previous?.commitSha === previousCommitSha + ) { + assertDashboardReleaseHostRuntimeCompatible(state.current); + await assertManagedDashboardReleaseRollbackSchemaCompatible( + state.current, + state.current, + options + ); + return state; + } if ( state.current?.commitSha !== candidateCommitSha || state.previous?.commitSha !== rollbackCommitSha @@ -1477,24 +1489,10 @@ export async function restoreDashboardReleaseAfterFailedActivation( const restoredPreviousRelease = previousCommitSha ? await loadManagedReleaseFromLayout(layout, previousCommitSha) : undefined; - const maximumInspectableSchemaVersion = Math.max( - DASHBOARD_DATABASE_SCHEMA_COMPATIBILITY.maximum, - candidateRelease.manifest.schema.maximumCompatible, - rollbackRelease.manifest.schema.maximumCompatible - ); - const liveSchemaState = await resolveLiveSchemaState( - options, - maximumInspectableSchemaVersion - ); - assertReleaseRollbackCompatible( - candidateRelease.manifest, - rollbackRelease.manifest, - liveSchemaState.version - ); - assertReleaseMigrationHistoryCompatible( - rollbackRelease.manifest, - liveSchemaState, - "Rollback" + await assertManagedDashboardReleaseRollbackSchemaCompatible( + candidateRelease, + rollbackRelease, + options ); const before = releaseLinkStateFromDashboardState(state); diff --git a/backend/src/server.ts b/backend/src/server.ts index b2f5bd04b..19fee468c 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -43,6 +43,8 @@ interface DashboardSocketRequest { } const SERVER_IDLE_TIMEOUT_SECONDS = 240; +const DEPLOYMENT_CUTOVER_SOCKET_CLOSE_CODE = 1012; +const DEPLOYMENT_CUTOVER_SOCKET_CLOSE_REASON = "Dashboard release cutover in progress"; function dashboardSocketRequest(data: string | Buffer): DashboardSocketRequest { try { @@ -58,6 +60,17 @@ function dashboardSocketRequest(data: string | Buffer): DashboardSocketRequest { } } +function didCloseSocketForDeploymentCutover( + ws: ServerWebSocket +): boolean { + if (!isProductionDeploymentCutoverActive()) return false; + ws.close( + DEPLOYMENT_CUTOVER_SOCKET_CLOSE_CODE, + DEPLOYMENT_CUTOVER_SOCKET_CLOSE_REASON + ); + return true; +} + function sendSocketAuthenticationError( ws: ServerWebSocket, request: DashboardSocketRequest, @@ -144,6 +157,7 @@ export function createServer( } }, message(ws: ServerWebSocket, message: string | Buffer) { + if (didCloseSocketForDeploymentCutover(ws)) return; if ( typeof ws.data.sessionToken !== "string" || !Number.isSafeInteger(ws.data.userId) @@ -186,6 +200,7 @@ export function createServer( } }, open(ws: ServerWebSocket) { + if (didCloseSocketForDeploymentCutover(ws)) return; const socket = dashboardSocketFromBun(ws); ws.data.socket = socket; gateway.handleDashboardClient(socket); diff --git a/backend/src/services/pullRequests.ts b/backend/src/services/pullRequests.ts index 4133159ee..54fc91ef1 100644 --- a/backend/src/services/pullRequests.ts +++ b/backend/src/services/pullRequests.ts @@ -2185,6 +2185,9 @@ async function scheduleReleaseCutover( : []), ].join(" ") : `${guardedLifecycleEnvironment} rollback ${shellQuote(candidateCommit)} ${shellQuote(rollbackCommit)}`; + const activationFailureRestoreCommand = isNewActivation + ? `${restoreDatabaseCommand} && ${restoreCommand}` + : restoreDatabaseCommand; const candidateShort = candidateCommit.slice(0, 8); const rollbackShort = rollbackCommit.slice(0, 8); const okJob: DeploymentJob = { @@ -2258,7 +2261,7 @@ async function scheduleReleaseCutover( " fi", " fi", " else", - ` if ${restoreDatabaseCommand} && restart_services && ready_for_commit ${shellQuote(preActivationCommit.slice(0, 8))}; then`, + ` if ${activationFailureRestoreCommand} && restart_services && ready_for_commit ${shellQuote(preActivationCommit.slice(0, 8))}; then`, ` ${deploymentJobUpdateCommand(activationFailedJob)} || exit 1`, ` ${discardSnapshotCommand} >/dev/null 2>&1 || true`, " else", diff --git a/backend/test/releaseManager.test.ts b/backend/test/releaseManager.test.ts index 01dc04ee8..8cf9418d1 100644 --- a/backend/test/releaseManager.test.ts +++ b/backend/test/releaseManager.test.ts @@ -422,7 +422,10 @@ describe("Dashboard immutable release manager", () => { }, root ) - ).rejects.toThrow("slots changed"); + ).resolves.toMatchObject({ + current: { commitSha: SECOND_COMMIT }, + previous: { commitSha: FIRST_COMMIT }, + }); await expect(readDashboardReleaseState(root)).resolves.toMatchObject({ current: { commitSha: SECOND_COMMIT }, previous: { commitSha: FIRST_COMMIT }, @@ -679,6 +682,21 @@ describe("Dashboard immutable release manager", () => { await expect( activateDashboardRelease(SECOND_COMMIT, registryRoot, SCHEMA_6_OPTIONS) ).rejects.toThrow("migration registry changed"); + const restoredRegistryState = await restoreDashboardReleaseAfterFailedActivation( + { + ...SCHEMA_6_OPTIONS, + expected: { + candidateCommitSha: SECOND_COMMIT, + previousCommitSha: undefined, + rollbackCommitSha: FIRST_COMMIT, + }, + }, + registryRoot + ); + expect(restoredRegistryState).toMatchObject({ + current: { commitSha: FIRST_COMMIT }, + }); + expect(restoredRegistryState.previous).toBeUndefined(); const runtimeRoot = temporaryReleasesRoot(); await createManagedRelease(runtimeRoot, FIRST_COMMIT, FIRST_COMMIT, "0.0.0"); @@ -833,7 +851,7 @@ describe("Dashboard immutable release manager", () => { expect(migratedCode.current?.commitSha).toBe(SECOND_COMMIT); }); - it("recovers the prior slots from an interrupted activation journal", async () => { + it("restores the prior slots from an interrupted activation journal", async () => { const root = temporaryReleasesRoot(); await createManagedRelease(root, FIRST_COMMIT); await createManagedRelease(root, SECOND_COMMIT); @@ -865,10 +883,16 @@ describe("Dashboard immutable release manager", () => { ); expect(existsSync(path.join(root, ".release-transition.json"))).toBe(true); - const recovered = await activateDashboardRelease( - SECOND_COMMIT, - root, - SCHEMA_6_OPTIONS + const recovered = await restoreDashboardReleaseAfterFailedActivation( + { + ...SCHEMA_6_OPTIONS, + expected: { + candidateCommitSha: THIRD_COMMIT, + previousCommitSha: FIRST_COMMIT, + rollbackCommitSha: SECOND_COMMIT, + }, + }, + root ); expect(recovered.current?.commitSha).toBe(SECOND_COMMIT); expect(recovered.previous?.commitSha).toBe(FIRST_COMMIT); diff --git a/backend/test/serverStartupPolicy.test.ts b/backend/test/serverStartupPolicy.test.ts index db82b9a99..f922977a7 100644 --- a/backend/test/serverStartupPolicy.test.ts +++ b/backend/test/serverStartupPolicy.test.ts @@ -837,7 +837,8 @@ describe("server start scheduler policy", () => { }) as unknown as Server) as typeof Bun.serve ); let handleDashboardClientSpy: { mockRestore: () => void } | undefined; - let getAuthSessionSpy: { mockRestore: () => void } | undefined; + let getAuthSessionSpy: + { mockClear: () => void; mockRestore: () => void } | undefined; let deploymentCutoverSpy: { mockRestore: () => void } | undefined; let isDeploymentCutoverActive = false; try { @@ -1004,6 +1005,40 @@ describe("server start scheduler policy", () => { expect(closeSpy).toHaveBeenCalledWith(1000, "done"); expect(sendSpy).toHaveBeenCalledWith("state"); + closeSpy.mockClear(); + messageHandler.mockClear(); + isDeploymentCutoverActive = true; + options.websocket.open(ws); + expect(closeSpy).toHaveBeenCalledWith( + 1012, + "Dashboard release cutover in progress" + ); + expect(handleDashboardClientSpy).toHaveBeenCalledTimes(1); + + closeSpy.mockClear(); + getAuthSessionSpy.mockClear(); + ws.data.sessionToken = "dev-session"; + ws.data.userId = 7; + options.websocket.message( + ws, + JSON.stringify({ + id: "cutover-request", + method: "chat.send", + type: "request", + userActivity: true, + }) + ); + expect(closeSpy).toHaveBeenCalledWith( + 1012, + "Dashboard release cutover in progress" + ); + expect(getAuthSessionSpy).not.toHaveBeenCalled(); + expect(messageHandler).not.toHaveBeenCalled(); + isDeploymentCutoverActive = false; + delete ws.data.sessionToken; + delete ws.data.userId; + + closeSpy.mockClear(); options.websocket.message(ws, new TextEncoder().encode("hello")); expect(closeSpy).toHaveBeenCalledWith( 4401, diff --git a/backend/test/serviceBehavior.test.ts b/backend/test/serviceBehavior.test.ts index 6002503fe..ea007f599 100644 --- a/backend/test/serviceBehavior.test.ts +++ b/backend/test/serviceBehavior.test.ts @@ -2827,6 +2827,32 @@ printf 'scheduled\n' `restore '${candidateCommit}' '${oldCommit}' '${priorPreviousCommit}'` ) ); + const exactRestoreLines = restartCommand + .split("\n") + .filter((line) => + line.includes( + `restore '${candidateCommit}' '${oldCommit}' '${priorPreviousCommit}'` + ) + ); + expect(exactRestoreLines).toHaveLength(2); + const activationFailureRestoreLine = exactRestoreLines.find( + (line) => !line.includes("stop_services") + ); + if (!activationFailureRestoreLine) { + throw new Error( + "Guardian fixture is missing activation-failure link recovery" + ); + } + expect(activationFailureRestoreLine.indexOf("restore-database")).toBeLessThan( + activationFailureRestoreLine.indexOf( + `restore '${candidateCommit}' '${oldCommit}' '${priorPreviousCommit}'` + ) + ); + expect( + activationFailureRestoreLine.indexOf( + `restore '${candidateCommit}' '${oldCommit}' '${priorPreviousCommit}'` + ) + ).toBeLessThan(activationFailureRestoreLine.indexOf("restart_services")); expect(restartCommand).toContain("prune 3"); expect(restartCommand).not.toContain("/api/job-executions"); expect(readlinkSync(path.join(releasesRoot, "current"))).toBe(