mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-14 08:53:19 +01:00
## Thinking Path > - Paperclip manages work performed by AI agents. > - Managed deployments prepare empty applications before an owner claims them. > - Those applications start database pollers even though no company can have work. > - Health probes also query SQL, so idle databases cannot remain suspended. > - This pull request adds an explicit standby marker for empty, unclaimed Cloud apps. > - The existing signed, durable claim resumes normal processing without restarting the app. ## Linked Issues or Issue Description **What happened?** An empty, unclaimed warm application runs recurring chat, email, plugin, heartbeat, cleanup, and reconciliation queries. Its health route also opens the database. This prevents idle database compute from suspending. **Expected behavior** An explicitly marked unclaimed application should keep its HTTP process and sandbox provider plugins ready while leaving the database idle. A successful signed claim should resume normal API behavior and background processing. Claimed and self-hosted instances should keep their current behavior. **Steps to reproduce** Start an empty Cloud-managed application and leave it unclaimed. Observe database activity while repeatedly requesting `/api/health`. Before this change, periodic queries continue without company data. Related: #15153 reduces allocation during chat polling. This change suppresses polling only for explicitly marked, empty, unclaimed Cloud apps. ## What Changed - Add `PAPERCLIP_CLOUD_WARM_STANDBY=1`. Check company emptiness once after restoring the persisted Cloud runtime identity. Missing Cloud configuration, existing data, or a persisted claim leaves normal processing active. - Gate recurring database pollers with an in-memory predicate. Keep startup preparation and sandbox provider plugin loading intact. - Serve unclaimed health probes without session or database reads and report `warmStandby: true`. Serve standby pages/assets directly from the UI router, bypassing session, bearer, tenant, and dynamic handlers. Refuse API requests and all WebSocket upgrades before authentication can query SQL or seed company data. - Exit standby after the existing signed identity assertion commits. Normal timers resume at their next tick; a restart restores the claim even with stale provider variables. - Document the marker, readiness semantics, rollout checks, and rollback. ## Verification - `pnpm -r typecheck` passed. A final server typecheck also passed after adding tests. - `pnpm build` passed. - Focused standby, signed claim, restart, health, static/Vite routing, hostname, HMR, and live-events suites: 72 passed after the review fixes. Includes real HTTP upgrade admission before/after claim. - `pnpm test:run` was attempted locally; both superseded runs were stopped after encountering checkout/platform failures. A clean-checkout rerun eliminated ancestor skill-directory lookup failures. The company-skills/runtime-cache families encounter macOS read-only-directory rename failures (`EACCES`); all three company-skills failures reproduce on unmodified base `bf14f803d5`. The initial full run also reported one native runner API test failure; an isolated comparison on both revisions was blocked by local embedded PostgreSQL startup failures. The full [Linux CI run](https://github.com/paperclipai/paperclip/actions/runs/37423263993) passed on final commit `5016c415ea`, including all server and workspace test shards, browser suites, typecheck, build, and release canary. This is not a claim that the full local suite passed. - Isolated full server with local PostgreSQL: after startup and connection expiry, 70 health probes, 70 page requests carrying valid synthetic tenant credentials, and 70 rejected WebSocket upgrades over 70 seconds observed zero app database connections. The signed claim completed in 62 ms and normal polling resumed (475 database transactions over 12 seconds). Restart with stale provider variables restored the durable claim. The latency is local-only, not a provider wake measurement. - Apex review: **5/5** on `5016c415ea`, both earlier threads resolved, no open recommendations. - No live-provider test or production deployment was performed. An actual database suspension/resume canary remains required before enabling the control-plane switch. ## Risks - Standby health reports HTTP readiness rather than current database connectivity. The signed claim still requires a durable database write; claimed health checks retain the SQL probe and 503 failure behavior. - Pollers resume at their usual intervals. A suspended database may add claim latency. Validate the real provider before enabling the marker. - Startup preparation and sandbox plugins remain loaded. New plugins or background loops must respect the same standby contract. - The marker is off by default. Remove it or set it to `0` and restart to roll back. No schema migration or claimed-workspace inactivity policy changes. ## Model Used OpenAI Codex, based on GPT-6. The exact serving snapshot and configured context-window size are not exposed in this session. Assistance included source review, TypeScript changes, command execution, and PostgreSQL tests. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [ ] I have run tests locally and they pass — targeted tests pass; full local suite limitations are documented above, and full Linux CI is green - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
458 lines
18 KiB
TypeScript
458 lines
18 KiB
TypeScript
import { supportsLocalAiLogin } from "../services/local-ai-login-policy.js";
|
|
import { randomUUID, timingSafeEqual } from "node:crypto";
|
|
import { Router } from "express";
|
|
import type { Db } from "@paperclipai/db";
|
|
import { and, count, eq, gt, inArray, isNull, sql } from "drizzle-orm";
|
|
import { heartbeatRuns, instanceUserRoles, invites } from "@paperclipai/db";
|
|
import type { DeploymentExposure, DeploymentMode } from "@paperclipai/shared";
|
|
import {
|
|
readPersistedDevServerStatus,
|
|
removeDevServerRestartRequest,
|
|
toDevServerHealthStatus,
|
|
writeDevServerRestartRequest,
|
|
} from "../dev-server-status.js";
|
|
import { logger } from "../middleware/logger.js";
|
|
import { getServerInfoSnapshot, type ServerInfoSnapshot } from "../server-info.js";
|
|
import {
|
|
getCloudStackContext,
|
|
isCloudManagedInstance,
|
|
type CloudInstanceEnv,
|
|
} from "../services/cloud-instance.js";
|
|
import { getCloudRuntimeIdentity } from "../services/cloud-runtime-identity.js";
|
|
import { getHiddenSettings } from "../services/settings-visibility.js";
|
|
import {
|
|
inspectDatabaseBackupHealth,
|
|
type DatabaseBackupHealthStatus,
|
|
type DatabaseBackupHealthWarning,
|
|
type InspectDatabaseBackupHealthOptions,
|
|
} from "../services/database-backup-health.js";
|
|
import { instanceSettingsService } from "../services/instance-settings.js";
|
|
import { isManagedWorkspaceInstance, resolveWorkspaceReadiness } from "../services/workspace-readiness.js";
|
|
import {
|
|
resolveWorkspaceReadinessLocalToken,
|
|
WORKSPACE_READINESS_TOKEN_HEADER,
|
|
WORKSPACE_READINESS_USER_EMAIL_HEADER,
|
|
WORKSPACE_READINESS_USER_ID_HEADER,
|
|
} from "../auth/workspace-login-handoff.js";
|
|
import { serverVersion } from "../version.js";
|
|
import { getStartupRecoveryState } from "../startup-recovery-state.js";
|
|
import { nativeRestartRecoverySummary } from "../services/native-runtime/native-restart-recovery.js";
|
|
import {
|
|
removeHotRestartIntent,
|
|
writeHotRestartIntent,
|
|
} from "../services/hot-restart.js";
|
|
|
|
function shouldExposeFullHealthDetails(
|
|
actorType: "none" | "board" | "agent" | null | undefined,
|
|
deploymentMode: DeploymentMode,
|
|
) {
|
|
if (deploymentMode !== "authenticated") return true;
|
|
return actorType === "board" || actorType === "agent";
|
|
}
|
|
|
|
function matchesSharedToken(expectedToken: string | undefined | null, providedToken: string | undefined) {
|
|
const expectedValue = expectedToken?.trim();
|
|
const token = providedToken?.trim();
|
|
if (!expectedValue || !token) return false;
|
|
|
|
const expected = Buffer.from(expectedValue);
|
|
const provided = Buffer.from(token);
|
|
if (expected.length !== provided.length) return false;
|
|
return timingSafeEqual(expected, provided);
|
|
}
|
|
|
|
function hasDevServerStatusToken(providedToken: string | undefined) {
|
|
return matchesSharedToken(process.env.PAPERCLIP_DEV_SERVER_STATUS_TOKEN, providedToken);
|
|
}
|
|
|
|
/**
|
|
* Whether the caller may read this instance's workspace readiness.
|
|
*
|
|
* A managed workspace runs in `authenticated` mode, so its own control plane has
|
|
* no board session against it. The runtime injects a derived probe token into the
|
|
* guest and presents it here — the same shared-secret shape the dev-server
|
|
* supervisor already uses, and never a browser-supplied identity header.
|
|
*/
|
|
function hasWorkspaceReadinessToken(providedToken: string | undefined) {
|
|
return matchesSharedToken(resolveWorkspaceReadinessLocalToken(), providedToken);
|
|
}
|
|
|
|
function redactedDatabaseBackupWarning(warning: DatabaseBackupHealthWarning): DatabaseBackupHealthWarning {
|
|
const messages: Record<DatabaseBackupHealthWarning["code"], string> = {
|
|
database_backup_check_failed: "Database backup health check failed.",
|
|
database_backup_last_failure: "Database backup failure marker is present.",
|
|
database_backup_missing: "No recent database backup was found.",
|
|
database_backup_stale: "Latest database backup is stale.",
|
|
};
|
|
return {
|
|
code: warning.code,
|
|
message: messages[warning.code],
|
|
};
|
|
}
|
|
|
|
function redactedDatabaseBackupHealth(databaseBackup: DatabaseBackupHealthStatus) {
|
|
return {
|
|
enabled: databaseBackup.enabled,
|
|
status: databaseBackup.status,
|
|
warnings: databaseBackup.warnings.map(redactedDatabaseBackupWarning),
|
|
};
|
|
}
|
|
|
|
function getCloudHealthStatus(env: CloudInstanceEnv) {
|
|
const context = getCloudStackContext(env);
|
|
if (!context) return undefined;
|
|
const runtimeIdentity = env === process.env ? getCloudRuntimeIdentity() : null;
|
|
|
|
return {
|
|
managed: true as const,
|
|
managedBy: "paperclip-cloud" as const,
|
|
stackSlug: context.stackSlug,
|
|
cloudBaseUrl: context.cloudOrigin,
|
|
...(runtimeIdentity ? {
|
|
runtimeIdentity: {
|
|
canonicalOrigin: runtimeIdentity.canonicalOrigin,
|
|
stackSlug: runtimeIdentity.stackSlug,
|
|
},
|
|
} : {}),
|
|
};
|
|
}
|
|
|
|
export function healthRoutes(
|
|
db?: Db,
|
|
opts: {
|
|
deploymentMode: DeploymentMode;
|
|
deploymentExposure: DeploymentExposure;
|
|
authReady: boolean;
|
|
companyDeletionEnabled: boolean;
|
|
serverInfo?: ServerInfoSnapshot;
|
|
databaseBackupHealth?: InspectDatabaseBackupHealthOptions;
|
|
runtimeEnv?: CloudInstanceEnv;
|
|
isWarmStandby?: () => boolean;
|
|
} = {
|
|
deploymentMode: "local_trusted",
|
|
deploymentExposure: "private",
|
|
authReady: true,
|
|
companyDeletionEnabled: true,
|
|
},
|
|
) {
|
|
const router = Router();
|
|
|
|
router.post("/dev-server/restart", async (req, res) => {
|
|
const actorType = "actor" in req ? req.actor?.type : null;
|
|
if (opts.deploymentMode === "authenticated" && actorType !== "board") {
|
|
res.status(403).json({ error: "board_access_required" });
|
|
return;
|
|
}
|
|
|
|
const persistedDevServerStatus = readPersistedDevServerStatus();
|
|
if (!persistedDevServerStatus) {
|
|
res.status(404).json({ error: "dev_server_supervisor_unavailable" });
|
|
return;
|
|
}
|
|
|
|
const restartRequired =
|
|
persistedDevServerStatus.dirty ||
|
|
persistedDevServerStatus.changedPathCount > 0 ||
|
|
persistedDevServerStatus.pendingMigrations.length > 0;
|
|
if (!restartRequired) {
|
|
res.status(409).json({ error: "restart_not_required" });
|
|
return;
|
|
}
|
|
|
|
if (!db) {
|
|
res.status(503).json({ error: "database_unavailable" });
|
|
return;
|
|
}
|
|
|
|
const requestId = randomUUID();
|
|
const requestedAt = new Date();
|
|
const serverInfo = opts.serverInfo ?? getServerInfoSnapshot();
|
|
const preflightActiveRunIds = await db
|
|
.select({ id: heartbeatRuns.id })
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.status, "running"))
|
|
.then((rows) => rows.map((row) => row.id));
|
|
let intent: Awaited<ReturnType<typeof writeHotRestartIntent>> | null = null;
|
|
try {
|
|
intent = await writeHotRestartIntent({
|
|
previousServerPid: process.pid,
|
|
previousServerIdentity: serverInfo.processStartedAt,
|
|
previousServerVersion: serverVersion,
|
|
preflightActiveRunIds,
|
|
recoveryRequestId: requestId,
|
|
requestedAt,
|
|
});
|
|
const written = writeDevServerRestartRequest({
|
|
requestedAt: requestedAt.toISOString(),
|
|
reason: "manual_restart_now",
|
|
requestId,
|
|
mode: "hot",
|
|
previousServerIdentity: serverInfo.processStartedAt,
|
|
});
|
|
if (!written) {
|
|
throw new Error("dev_server_supervisor_unavailable");
|
|
}
|
|
} catch (error) {
|
|
try {
|
|
removeDevServerRestartRequest({ requestId });
|
|
} catch (rollbackError) {
|
|
logger.error(
|
|
{ err: rollbackError, requestId },
|
|
"failed to roll back dev-server restart request",
|
|
);
|
|
}
|
|
if (intent) {
|
|
await removeHotRestartIntent(undefined, intent).catch(
|
|
(rollbackError) => {
|
|
logger.error(
|
|
{ err: rollbackError, requestId },
|
|
"failed to roll back hot-restart intent",
|
|
);
|
|
},
|
|
);
|
|
}
|
|
if (
|
|
error instanceof Error &&
|
|
error.message === "dev_server_supervisor_unavailable"
|
|
) {
|
|
res.status(404).json({ error: "dev_server_supervisor_unavailable" });
|
|
return;
|
|
}
|
|
logger.error({ err: error, requestId }, "failed to coordinate hot restart request");
|
|
res.status(500).json({ error: "hot_restart_intent_failed" });
|
|
return;
|
|
}
|
|
|
|
res.status(202).json({
|
|
status: "restart_requested",
|
|
requestId,
|
|
mode: "hot",
|
|
});
|
|
});
|
|
|
|
router.get("/", async (req, res) => {
|
|
const actorType = "actor" in req ? req.actor?.type : null;
|
|
const exposeFullDetails = shouldExposeFullHealthDetails(
|
|
actorType,
|
|
opts.deploymentMode,
|
|
);
|
|
const runtimeEnv = opts.runtimeEnv ?? process.env;
|
|
const startupRecovery = getStartupRecoveryState();
|
|
const healthStatus =
|
|
startupRecovery.phase === "ready" ? "ok" : "starting";
|
|
const cloud = getCloudHealthStatus(runtimeEnv);
|
|
// Operator-hidden settings ride every response (like `cloud`): the list
|
|
// holds UI surface names only, and the settings nav needs it before any
|
|
// fuller-detail fetch. Omitted entirely when nothing is hidden, so
|
|
// deployments without the env var keep today's byte-identical responses.
|
|
const hiddenSettings = [...getHiddenSettings(runtimeEnv)];
|
|
// serverInfo (git SHA + process start) rides on the full-details responses
|
|
// only, so it reaches board/agent actors in authenticated mode or any caller
|
|
// in local_trusted dev — never anonymous authenticated callers. The
|
|
// enableServerInfoDebugView experimental flag gates the UI surface, not this
|
|
// already access-controlled field.
|
|
const serverInfo = opts.serverInfo ?? getServerInfoSnapshot();
|
|
// The build commit is a plain git SHA of a public repository — not a
|
|
// secret — so it is surfaced on every response, including the redacted
|
|
// one, unlike the fuller `serverInfo` block. Deploy tooling (and anyone)
|
|
// can read which commit this server is running without authenticating.
|
|
const commit = serverInfo.git.available ? serverInfo.git.fullSha : null;
|
|
const exposeDevServerDetails =
|
|
exposeFullDetails || hasDevServerStatusToken(req.get("x-paperclip-dev-server-status-token"));
|
|
// Workspace readiness names the instance and execution workspace that
|
|
// answered, so it rides the protected responses only. Public health stays
|
|
// redacted: an anonymous caller still learns liveness and nothing else.
|
|
const exposeWorkspaceReadiness =
|
|
isManagedWorkspaceInstance()
|
|
&& (exposeFullDetails || hasWorkspaceReadinessToken(req.get(WORKSPACE_READINESS_TOKEN_HEADER)));
|
|
const requestedHandoffUserId = req.get(WORKSPACE_READINESS_USER_ID_HEADER)?.trim();
|
|
const requestedHandoffUserEmail = req.get(WORKSPACE_READINESS_USER_EMAIL_HEADER)?.trim();
|
|
const handoffSubject = requestedHandoffUserId && requestedHandoffUserEmail
|
|
? { userId: requestedHandoffUserId, email: requestedHandoffUserEmail }
|
|
: null;
|
|
|
|
if (!db) {
|
|
res.json(
|
|
exposeFullDetails
|
|
? {
|
|
status: healthStatus,
|
|
version: serverVersion,
|
|
serverVersion: serverVersion,
|
|
commit,
|
|
serverInfo,
|
|
...(cloud ? { cloud } : {}),
|
|
...(hiddenSettings.length ? { hiddenSettings } : {}),
|
|
}
|
|
: {
|
|
status: healthStatus,
|
|
deploymentMode: opts.deploymentMode,
|
|
commit,
|
|
...(cloud ? { cloud } : {}),
|
|
...(hiddenSettings.length ? { hiddenSettings } : {}),
|
|
},
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Startup has already validated the empty database. During warm standby,
|
|
// readiness means the HTTP app can accept a signed claim; probing SQL would
|
|
// keep the idle database awake. The claim itself still performs durable SQL,
|
|
// and claimed instances retain the normal live database check below.
|
|
if (opts.isWarmStandby?.()) {
|
|
res.json({
|
|
status: healthStatus,
|
|
deploymentMode: opts.deploymentMode,
|
|
deploymentExposure: opts.deploymentExposure,
|
|
commit,
|
|
warmStandby: true,
|
|
...(cloud ? { cloud } : {}),
|
|
...(hiddenSettings.length ? { hiddenSettings } : {}),
|
|
});
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await db.execute(sql`SELECT 1`);
|
|
} catch (error) {
|
|
logger.warn({ err: error }, "Health check database probe failed");
|
|
// Carry readiness on the unhealthy response too: the seed phase recorded on
|
|
// disk is exactly what tells an operator whether this is a half-finished
|
|
// restore or a database that died after being verified.
|
|
const workspace = exposeWorkspaceReadiness
|
|
? await resolveWorkspaceReadiness({ db, handoffSubject }).catch(() => null)
|
|
: null;
|
|
res.status(503).json({
|
|
status: "unhealthy",
|
|
version: serverVersion,
|
|
serverVersion,
|
|
commit,
|
|
error: "database_unreachable",
|
|
...(exposeFullDetails ? { serverInfo } : {}),
|
|
...(workspace ? { workspace } : {}),
|
|
...(cloud ? { cloud } : {}),
|
|
});
|
|
return;
|
|
}
|
|
|
|
let bootstrapStatus: "ready" | "bootstrap_pending" = "ready";
|
|
let bootstrapInviteActive = false;
|
|
// Cloud-managed instances have no first-admin concept: the control
|
|
// plane owns identity and its trusted-header users are deliberately
|
|
// never instance_admin, so the role-count gate below would report
|
|
// bootstrap_pending forever and lock every managed tenant out at the
|
|
// claim screen. Self-hosted deployments (neither canonical managed signal)
|
|
// are unaffected.
|
|
if (opts.deploymentMode === "authenticated" && !isCloudManagedInstance(runtimeEnv)) {
|
|
const roleCount = await db
|
|
.select({ count: count() })
|
|
.from(instanceUserRoles)
|
|
.where(sql`${instanceUserRoles.role} = 'instance_admin'`)
|
|
.then((rows) => Number(rows[0]?.count ?? 0));
|
|
bootstrapStatus = roleCount > 0 ? "ready" : "bootstrap_pending";
|
|
|
|
if (bootstrapStatus === "bootstrap_pending") {
|
|
const now = new Date();
|
|
const inviteCount = await db
|
|
.select({ count: count() })
|
|
.from(invites)
|
|
.where(
|
|
and(
|
|
eq(invites.inviteType, "bootstrap_ceo"),
|
|
isNull(invites.revokedAt),
|
|
isNull(invites.acceptedAt),
|
|
gt(invites.expiresAt, now),
|
|
),
|
|
)
|
|
.then((rows) => Number(rows[0]?.count ?? 0));
|
|
bootstrapInviteActive = inviteCount > 0;
|
|
}
|
|
}
|
|
|
|
const persistedDevServerStatus = readPersistedDevServerStatus();
|
|
let devServer: ReturnType<typeof toDevServerHealthStatus> | undefined;
|
|
if (exposeDevServerDetails && persistedDevServerStatus && typeof (db as { select?: unknown }).select === "function") {
|
|
const instanceSettings = instanceSettingsService(db);
|
|
const experimentalSettings = await instanceSettings.getExperimental();
|
|
const activeRunCount = await db
|
|
.select({ count: count() })
|
|
.from(heartbeatRuns)
|
|
.where(inArray(heartbeatRuns.status, ["queued", "running"]))
|
|
.then((rows) => Number(rows[0]?.count ?? 0));
|
|
|
|
devServer = toDevServerHealthStatus(persistedDevServerStatus, {
|
|
autoRestartEnabled: experimentalSettings.autoRestartDevServerWhenIdle ?? false,
|
|
activeRunCount,
|
|
});
|
|
}
|
|
|
|
const workspaceReadiness = exposeWorkspaceReadiness
|
|
? await resolveWorkspaceReadiness({ db, handoffSubject }).catch((error) => {
|
|
logger.warn({ err: error }, "workspace readiness probe failed");
|
|
return null;
|
|
})
|
|
: null;
|
|
|
|
const databaseBackup = opts.databaseBackupHealth
|
|
? inspectDatabaseBackupHealth(opts.databaseBackupHealth)
|
|
: undefined;
|
|
const warnings = databaseBackup?.warnings.length ? databaseBackup.warnings : undefined;
|
|
const nativeRecovery = exposeFullDetails
|
|
? await nativeRestartRecoverySummary(db).catch((error) => {
|
|
logger.warn({ err: error }, "native recovery health summary failed");
|
|
return {};
|
|
})
|
|
: undefined;
|
|
|
|
if (!exposeFullDetails) {
|
|
const redactedDatabaseBackup = databaseBackup ? redactedDatabaseBackupHealth(databaseBackup) : undefined;
|
|
const redactedWarnings = redactedDatabaseBackup?.warnings.length ? redactedDatabaseBackup.warnings : undefined;
|
|
res.json({
|
|
status: healthStatus,
|
|
deploymentMode: opts.deploymentMode,
|
|
deploymentExposure: opts.deploymentExposure,
|
|
localAiLoginSupported: supportsLocalAiLogin(opts),
|
|
commit,
|
|
bootstrapStatus,
|
|
bootstrapInviteActive,
|
|
...(redactedDatabaseBackup ? { databaseBackup: redactedDatabaseBackup } : {}),
|
|
...(redactedWarnings ? { warnings: redactedWarnings } : {}),
|
|
...(devServer ? { devServer } : {}),
|
|
// Token-authorized probe on an otherwise redacted response: the control
|
|
// plane needs readiness without a board session, and nothing else about
|
|
// this instance becomes visible.
|
|
...(workspaceReadiness ? { workspace: workspaceReadiness } : {}),
|
|
...(cloud ? { cloud } : {}),
|
|
...(hiddenSettings.length ? { hiddenSettings } : {}),
|
|
});
|
|
return;
|
|
}
|
|
|
|
res.json({
|
|
status: healthStatus,
|
|
version: serverVersion,
|
|
serverVersion,
|
|
commit,
|
|
deploymentMode: opts.deploymentMode,
|
|
deploymentExposure: opts.deploymentExposure,
|
|
localAiLoginSupported: supportsLocalAiLogin(opts),
|
|
authReady: opts.authReady,
|
|
bootstrapStatus,
|
|
bootstrapInviteActive,
|
|
features: {
|
|
companyDeletionEnabled: opts.companyDeletionEnabled,
|
|
},
|
|
serverInfo,
|
|
startupRecovery,
|
|
nativeRecovery,
|
|
...(databaseBackup ? { databaseBackup } : {}),
|
|
...(warnings ? { warnings } : {}),
|
|
...(devServer ? { devServer } : {}),
|
|
...(workspaceReadiness ? { workspace: workspaceReadiness } : {}),
|
|
...(cloud ? { cloud } : {}),
|
|
...(hiddenSettings.length ? { hiddenSettings } : {}),
|
|
});
|
|
});
|
|
|
|
return router;
|
|
}
|