+ {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.