fix(server): enforce agent secret binding sync across lifecycle flows (#8307)
## Thinking Path > - Paperclip is the control plane people use to create, configure, and run AI agents for work. > - This change sits in the server-side agent lifecycle and secret-binding subsystem, where adapter config `env` entries can reference company secrets. > - An incident (while trying to configure a Novita sandbox) showed that an agent can reach a broken runtime state if `adapterConfig.env` contains `secret_ref` entries but the matching `company_secret_bindings` rows are missing. > - The immediate run-path guard and error-surfacing work made the failure diagnosable, but they did not fully prevent new broken agents from being created. > - The risk came from create and approval flows being responsible for remembering to sync bindings at each call site, which is easy to miss as new flows are added. > - This pull request moves the invariant into `agentService` create/update/activate paths, keeps the existing hire-flow fix, and adds regression coverage for create, update, and legacy pending-approval recovery. > - The benefit is that agent secret binding integrity is enforced closer to the data mutation point, so future callers inherit the protection automatically. ## Linked Issues or Issue Description Refs #8309 ### What happened? A Paperclip agent could persist `adapterConfig.env` `secret_ref` entries without matching agent-scoped `company_secret_bindings` rows. When that happened, the config UI could still look configured, but the real run path failed pre-dispatch because the secret was not actually bound to that agent. ### Expected behavior Every normal agent create, config-update, and pending-approval activation flow should leave the agent with secret bindings that match its persisted secret-ref env config. ### Steps to reproduce 1. Create or activate an agent through a flow that persists `adapterConfig.env` secret refs without synchronizing `company_secret_bindings`. 2. Observe that the config state can still appear populated. 3. Start a run for that agent. 4. Observe that pre-dispatch binding validation fails because the secret reference exists but the agent binding does not. ### Deployment mode Local dev (`pnpm dev`) ### Installation method Built from source (`pnpm dev` / `pnpm build`) ### Agent adapter(s) involved - Claude Code - Not adapter-specific (core bug) ### Database mode Embedded PGlite / embedded local dev database flow ### Access context Board (human operator) created or approved the agent; agent runtime later consumed the config. ### Additional context This PR focuses on preventing new broken states from normal service flows and on backfilling the covered legacy pending-approval activation path. ## What Changed - Kept the existing branch-local hire-flow fix that synchronized bindings for route and approval paths. - Moved the binding integrity invariant into `agentService.create()`, `agentService.update()` when `adapterConfig` changes, and `agentService.activatePendingApproval()`. - Added `server/src/__tests__/agents-service-secret-bindings.test.ts` covering create-time sync, update-time resync, and backfill for legacy pending-approval agents. - Removed now-redundant route-layer and approval-layer binding sync calls once the service layer became authoritative. - Simplified the affected unit tests so route/approval tests no longer assert service-owned binding writes directly. ## Verification - `pnpm --filter @paperclipai/server typecheck` - `pnpm exec vitest run server/src/__tests__/agents-service-secret-bindings.test.ts server/src/__tests__/approvals-service.test.ts server/src/__tests__/agent-skills-routes.test.ts` ## Risks - Low to medium risk. - This changes where secret-binding synchronization is enforced, so any unexpected caller that relied on upper-layer manual sync behavior could behave differently. - Agent create/update/activation flows now perform binding synchronization consistently, which adds binding-table writes at those mutation points. - This PR does not retroactively scan and heal every already-broken historical agent row; it prevents and backfills through the covered service flows. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex / GPT-5 Codex class model via `codex_local` - Session model family: GPT-5 Codex - Tool-assisted coding with shell, git, HTTP, and local test execution - Reasoning mode: medium interactive tool-use workflow ## 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 #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [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>
This commit is contained in:
@@ -71,7 +71,7 @@ import { trackAgentFirstHeartbeat } from "@paperclipai/shared/telemetry";
|
||||
import { getTelemetryClient } from "../telemetry.js";
|
||||
import { companySkillService } from "./company-skills.js";
|
||||
import { budgetService, type BudgetEnforcementScope } from "./budgets.js";
|
||||
import { secretService } from "./secrets.js";
|
||||
import { secretService, type MissingRuntimeBinding } from "./secrets.js";
|
||||
import { resolveDefaultAgentWorkspaceDir, resolveManagedProjectWorkspaceDir } from "../home-paths.js";
|
||||
import {
|
||||
buildHeartbeatRunIssueComment,
|
||||
@@ -254,6 +254,8 @@ const BOUNDED_TRANSIENT_HEARTBEAT_RETRY_WAKE_REASON = "transient_failure_retry";
|
||||
const BOUNDED_TRANSIENT_HEARTBEAT_RETRY_MAX_ATTEMPTS = BOUNDED_TRANSIENT_HEARTBEAT_RETRY_DELAYS_MS.length;
|
||||
const WORKSPACE_VALIDATION_FAILURE_CODE = "workspace_validation_failed";
|
||||
const WORKSPACE_VALIDATION_RECOVERY_CAUSE = "workspace_validation_failed";
|
||||
const CONFIGURATION_INCOMPLETE_FAILURE_CODE = "configuration_incomplete";
|
||||
const CONFIGURATION_INCOMPLETE_RECOVERY_CAUSE = "configuration_incomplete";
|
||||
// Keep this in sync with local adapters that require a git workspace before launch.
|
||||
const GIT_SENSITIVE_LOCAL_ADAPTER_TYPES = new Set([
|
||||
"acpx_local",
|
||||
@@ -296,6 +298,20 @@ export class WorkspaceValidationFailure extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-dispatch gate outcome: required secret/env bindings are missing, so the
|
||||
// run must not be dispatched. Surfaced as a configuration-incomplete blocker
|
||||
// routed to a human owner instead of N opaque dispatched-then-failed runs.
|
||||
export class ConfigurationIncompleteFailure extends Error {
|
||||
code = CONFIGURATION_INCOMPLETE_FAILURE_CODE;
|
||||
resultJson: Record<string, unknown>;
|
||||
|
||||
constructor(message: string, resultJson: Record<string, unknown>) {
|
||||
super(message);
|
||||
this.name = "ConfigurationIncompleteFailure";
|
||||
this.resultJson = resultJson;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveCodexTransientFallbackMode(attempt: number): CodexTransientFallbackMode {
|
||||
if (attempt <= 1) return "same_session";
|
||||
if (attempt === 2) return "safer_invocation";
|
||||
@@ -381,9 +397,16 @@ const INLINE_BASE64_IMAGE_DATA_RE = /("type":"image","source":\{"type":"base64",
|
||||
|
||||
type RuntimeConfigSecretResolver = Pick<
|
||||
ReturnType<typeof secretService>,
|
||||
"resolveAdapterConfigForRuntime" | "resolveEnvBindings"
|
||||
"resolveAdapterConfigForRuntime" | "resolveEnvBindings" | "collectMissingRuntimeBindings"
|
||||
>;
|
||||
|
||||
function formatMissingBindingForOperator(missing: MissingRuntimeBinding): string {
|
||||
const secretLabel = missing.secretName
|
||||
? `"${missing.secretName}"`
|
||||
: missing.secretId;
|
||||
return `secret ${secretLabel} not bound at ${missing.consumerType} ${missing.configPath}`;
|
||||
}
|
||||
|
||||
const LOW_TRUST_SENSITIVE_ENV_KEY_RE =
|
||||
/(api[-_]?key|access[-_]?token|auth(?:_?token)?|authorization|bearer|secret|passwd|password|credential|jwt|private[-_]?key|cookie|connectionstring)/i;
|
||||
|
||||
@@ -449,6 +472,54 @@ export async function resolveExecutionRunAdapterConfig(input: {
|
||||
assertLowTrustEnvConfigAllowed(projectEnv, "project.env");
|
||||
assertLowTrustEnvConfigAllowed(routineEnv, "routine.env");
|
||||
}
|
||||
// Pre-dispatch binding-validation gate: detect declared secret refs that have
|
||||
// no binding before resolving any secret value. Missing bindings short-circuit
|
||||
// to a configuration-incomplete blocker routed to a human owner instead of a
|
||||
// dispatched-then-failed run (which previously surfaced as opaque setup_failed).
|
||||
if (typeof input.secretsSvc.collectMissingRuntimeBindings === "function") {
|
||||
const missingBindings: MissingRuntimeBinding[] = [];
|
||||
if (input.agentId) {
|
||||
missingBindings.push(
|
||||
...(await input.secretsSvc.collectMissingRuntimeBindings(
|
||||
input.companyId,
|
||||
parseObject(executionRunConfig.env),
|
||||
{ consumerType: "agent", consumerId: input.agentId },
|
||||
)),
|
||||
);
|
||||
}
|
||||
if (projectEnv && input.projectId) {
|
||||
missingBindings.push(
|
||||
...(await input.secretsSvc.collectMissingRuntimeBindings(
|
||||
input.companyId,
|
||||
projectEnv,
|
||||
{ consumerType: "project", consumerId: input.projectId },
|
||||
)),
|
||||
);
|
||||
}
|
||||
if (routineEnv && input.routineId) {
|
||||
missingBindings.push(
|
||||
...(await input.secretsSvc.collectMissingRuntimeBindings(
|
||||
input.companyId,
|
||||
routineEnv,
|
||||
{ consumerType: "routine", consumerId: input.routineId },
|
||||
)),
|
||||
);
|
||||
}
|
||||
if (missingBindings.length > 0) {
|
||||
const detail = missingBindings.map(formatMissingBindingForOperator).join("; ");
|
||||
throw new ConfigurationIncompleteFailure(`configuration incomplete: ${detail}`, {
|
||||
configurationIncomplete: {
|
||||
reason: "secret_binding_missing",
|
||||
companyId: input.companyId,
|
||||
agentId: input.agentId ?? null,
|
||||
issueId: input.issueId ?? null,
|
||||
projectId: input.projectId ?? null,
|
||||
routineId: input.routineId ?? null,
|
||||
missingBindings,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
const { config: resolvedConfig, secretKeys, manifest } = await input.secretsSvc.resolveAdapterConfigForRuntime(
|
||||
input.companyId,
|
||||
executionRunConfig,
|
||||
@@ -939,6 +1010,16 @@ function isWorkspaceValidationFailedRun(
|
||||
return run?.errorCode === WORKSPACE_VALIDATION_FAILURE_CODE;
|
||||
}
|
||||
|
||||
function isConfigurationIncompleteFailure(error: unknown): error is ConfigurationIncompleteFailure {
|
||||
return error instanceof ConfigurationIncompleteFailure;
|
||||
}
|
||||
|
||||
function isConfigurationIncompleteFailedRun(
|
||||
run: Pick<typeof heartbeatRuns.$inferSelect, "errorCode"> | null | undefined,
|
||||
) {
|
||||
return run?.errorCode === CONFIGURATION_INCOMPLETE_FAILURE_CODE;
|
||||
}
|
||||
|
||||
async function hasGitMetadata(cwd: string | null | undefined) {
|
||||
const normalized = readNonEmptyString(cwd);
|
||||
if (!normalized) return false;
|
||||
@@ -7199,9 +7280,17 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
return cancelled;
|
||||
}
|
||||
|
||||
function truncateAgentErrorReason(reason: string | null | undefined): string | null {
|
||||
if (!reason) return null;
|
||||
const trimmed = reason.trim();
|
||||
if (!trimmed) return null;
|
||||
return trimmed.length > 500 ? `${trimmed.slice(0, 499)}…` : trimmed;
|
||||
}
|
||||
|
||||
async function finalizeAgentStatus(
|
||||
agentId: string,
|
||||
outcome: "succeeded" | "failed" | "cancelled" | "timed_out",
|
||||
failureReason?: string | null,
|
||||
) {
|
||||
const existing = await getAgent(agentId);
|
||||
if (!existing) return;
|
||||
@@ -7224,6 +7313,10 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
.update(agents)
|
||||
.set({
|
||||
status: nextStatus,
|
||||
// Persist a human-readable reason on the agent record when it enters
|
||||
// error so operators see it on the agent page without digging into run
|
||||
// events; clear it whenever the agent leaves error.
|
||||
errorReason: nextStatus === "error" ? truncateAgentErrorReason(failureReason) : null,
|
||||
lastHeartbeatAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
@@ -7586,7 +7679,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
},
|
||||
});
|
||||
|
||||
await finalizeAgentStatus(run.agentId, "failed");
|
||||
await finalizeAgentStatus(run.agentId, "failed", baseMessage);
|
||||
await startNextQueuedRunForAgent(run.agentId);
|
||||
runningProcesses.delete(run.id);
|
||||
reaped.push(run.id);
|
||||
@@ -9439,14 +9532,20 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
}
|
||||
}
|
||||
}
|
||||
await finalizeAgentStatus(agent.id, outcome);
|
||||
await finalizeAgentStatus(
|
||||
agent.id,
|
||||
outcome,
|
||||
outcome === "succeeded" ? null : (adapterResult.errorMessage ?? null),
|
||||
);
|
||||
} catch (err) {
|
||||
const message = redactCurrentUserText(
|
||||
err instanceof Error ? err.message : "Unknown adapter failure",
|
||||
await getCurrentUserRedactionOptions(),
|
||||
);
|
||||
const workspaceValidationFailure = isWorkspaceValidationFailure(err) ? err : null;
|
||||
const failureErrorCode = workspaceValidationFailure?.code ?? "adapter_failed";
|
||||
const configurationIncompleteFailure = isConfigurationIncompleteFailure(err) ? err : null;
|
||||
const failureErrorCode =
|
||||
workspaceValidationFailure?.code ?? configurationIncompleteFailure?.code ?? "adapter_failed";
|
||||
logger.error({ err, runId }, "heartbeat execution failed");
|
||||
|
||||
let logSummary: { bytes: number; sha256?: string; compressed: boolean } | null = null;
|
||||
@@ -9472,7 +9571,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
resultJson: mergeRunStopMetadataForAgent(agent, "failed", {
|
||||
errorCode: failureErrorCode,
|
||||
errorMessage: message,
|
||||
resultJson: workspaceValidationFailure?.resultJson ?? null,
|
||||
resultJson: workspaceValidationFailure?.resultJson ?? configurationIncompleteFailure?.resultJson ?? null,
|
||||
}),
|
||||
stdoutExcerpt,
|
||||
stderrExcerpt,
|
||||
@@ -9507,7 +9606,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
});
|
||||
const livenessRun = await classifyAndPersistRunLiveness(failedRun) ?? failedRun;
|
||||
await refreshContinuationSummaryForRun(livenessRun, agent);
|
||||
if (!isWorkspaceValidationFailedRun(livenessRun)) {
|
||||
if (!isWorkspaceValidationFailedRun(livenessRun) && !isConfigurationIncompleteFailedRun(livenessRun)) {
|
||||
await finalizeIssueCommentPolicy(livenessRun, agent);
|
||||
}
|
||||
await releaseIssueExecutionAndPromote(livenessRun);
|
||||
@@ -9535,22 +9634,31 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
}
|
||||
}
|
||||
|
||||
await finalizeAgentStatus(agent.id, "failed");
|
||||
await finalizeAgentStatus(agent.id, "failed", message);
|
||||
}
|
||||
} catch (outerErr) {
|
||||
// Setup code before adapter.execute threw (e.g. ensureRuntimeState, resolveWorkspaceForRun).
|
||||
// The inner catch did not fire, so we must record the failure here.
|
||||
const message = outerErr instanceof Error ? outerErr.message : "Unknown setup failure";
|
||||
const message = redactCurrentUserText(
|
||||
outerErr instanceof Error ? outerErr.message : "Unknown setup failure",
|
||||
await getCurrentUserRedactionOptions(),
|
||||
);
|
||||
// A missing secret/env binding is a known pre-dispatch configuration gap,
|
||||
// not an opaque setup crash. Surface it with its own errorCode so the
|
||||
// recovery path routes it to a human owner instead of looping retries.
|
||||
const configurationIncompleteSetupFailure = isConfigurationIncompleteFailure(outerErr) ? outerErr : null;
|
||||
const setupFailureErrorCode = configurationIncompleteSetupFailure?.code ?? "setup_failed";
|
||||
logger.error({ err: outerErr, runId }, "heartbeat execution setup failed");
|
||||
const setupFailureAgent = await getAgent(run.agentId).catch(() => null);
|
||||
const setupFailureWrite = await setRunStatusIfRunning(runId, "failed", {
|
||||
error: message,
|
||||
errorCode: "setup_failed",
|
||||
errorCode: setupFailureErrorCode,
|
||||
finishedAt: new Date(),
|
||||
...(setupFailureAgent ? {
|
||||
resultJson: mergeRunStopMetadataForAgent(setupFailureAgent, "failed", {
|
||||
errorCode: "setup_failed",
|
||||
errorCode: setupFailureErrorCode,
|
||||
errorMessage: message,
|
||||
resultJson: configurationIncompleteSetupFailure?.resultJson ?? null,
|
||||
}),
|
||||
} : {}),
|
||||
}).catch(() => ({ run: null, updated: false as const }));
|
||||
@@ -9583,7 +9691,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
const failedAgent = setupFailureAgent ?? await getAgent(run.agentId).catch(() => null);
|
||||
if (failedAgent) {
|
||||
await refreshContinuationSummaryForRun(livenessRun, failedAgent).catch(() => undefined);
|
||||
if (!isWorkspaceValidationFailedRun(livenessRun)) {
|
||||
if (!isWorkspaceValidationFailedRun(livenessRun) && !isConfigurationIncompleteFailedRun(livenessRun)) {
|
||||
await finalizeIssueCommentPolicy(livenessRun, failedAgent).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
@@ -9593,7 +9701,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
// path owned the terminal transition. If another path already finalized
|
||||
// the run, keep that terminal outcome authoritative.
|
||||
if (setupFailureWrite.updated) {
|
||||
await finalizeAgentStatus(run.agentId, "failed").catch(() => undefined);
|
||||
await finalizeAgentStatus(run.agentId, "failed", message).catch(() => undefined);
|
||||
}
|
||||
} finally {
|
||||
const latestRun = await getRun(run.id).catch(() => null);
|
||||
@@ -9641,6 +9749,17 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
);
|
||||
}
|
||||
|
||||
function buildConfigurationIncompleteRecoveryComment(input: {
|
||||
latestRun: Pick<typeof heartbeatRuns.$inferSelect, "error" | "errorCode"> | null | undefined;
|
||||
}) {
|
||||
const failureSummary = summarizeRunFailureForIssueComment(input.latestRun);
|
||||
return (
|
||||
"Paperclip stopped before dispatching the adapter because required secret/env bindings are missing. " +
|
||||
`Resolving them as a runtime failure would only produce repeated opaque setup failures.${failureSummary ?? ""} ` +
|
||||
"Moving it to `blocked` with a source-scoped recovery action so an operator can bind the missing secret(s) before resuming."
|
||||
);
|
||||
}
|
||||
|
||||
async function releaseIssueExecutionAndPromote(run: typeof heartbeatRuns.$inferSelect) {
|
||||
const runContext = parseObject(run.contextSnapshot);
|
||||
const contextIssueId = readNonEmptyString(runContext.issueId);
|
||||
@@ -9761,17 +9880,22 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
// Sibling lock cleanup is already done above; only the primary issue carries
|
||||
// the recovery surface because the comment is attached to a single issue.
|
||||
if (
|
||||
isWorkspaceValidationFailedRun(run) &&
|
||||
(isWorkspaceValidationFailedRun(run) || isConfigurationIncompleteFailedRun(run)) &&
|
||||
(issue.status === "todo" || issue.status === "in_progress") &&
|
||||
!issue.assigneeUserId &&
|
||||
issue.assigneeAgentId === run.agentId
|
||||
) {
|
||||
const configurationIncomplete = isConfigurationIncompleteFailedRun(run);
|
||||
return {
|
||||
kind: "blocked" as const,
|
||||
issue,
|
||||
previousStatus: issue.status,
|
||||
comment: buildWorkspaceValidationRecoveryComment({ latestRun: run }),
|
||||
recoveryCause: WORKSPACE_VALIDATION_RECOVERY_CAUSE,
|
||||
comment: configurationIncomplete
|
||||
? buildConfigurationIncompleteRecoveryComment({ latestRun: run })
|
||||
: buildWorkspaceValidationRecoveryComment({ latestRun: run }),
|
||||
recoveryCause: configurationIncomplete
|
||||
? CONFIGURATION_INCOMPLETE_RECOVERY_CAUSE
|
||||
: WORKSPACE_VALIDATION_RECOVERY_CAUSE,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10057,21 +10181,29 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
!recoveryAgentInvokable ||
|
||||
!recoveryAgent ||
|
||||
isWorkspaceValidationFailedRun(run) ||
|
||||
isConfigurationIncompleteFailedRun(run) ||
|
||||
didAutomaticRecoveryFail(run, issue.status === "todo" ? "assignment_recovery" : "issue_continuation_needed");
|
||||
if (shouldBlockImmediately) {
|
||||
const workspaceValidationFailure = isWorkspaceValidationFailedRun(run);
|
||||
const configurationIncompleteFailure = isConfigurationIncompleteFailedRun(run);
|
||||
const comment = workspaceValidationFailure
|
||||
? buildWorkspaceValidationRecoveryComment({ latestRun: run })
|
||||
: buildImmediateExecutionPathRecoveryComment({
|
||||
status: issue.status as "todo" | "in_progress",
|
||||
latestRun: run,
|
||||
});
|
||||
: configurationIncompleteFailure
|
||||
? buildConfigurationIncompleteRecoveryComment({ latestRun: run })
|
||||
: buildImmediateExecutionPathRecoveryComment({
|
||||
status: issue.status as "todo" | "in_progress",
|
||||
latestRun: run,
|
||||
});
|
||||
return {
|
||||
kind: "blocked" as const,
|
||||
issue,
|
||||
previousStatus: issue.status,
|
||||
comment,
|
||||
recoveryCause: workspaceValidationFailure ? WORKSPACE_VALIDATION_RECOVERY_CAUSE : undefined,
|
||||
recoveryCause: workspaceValidationFailure
|
||||
? WORKSPACE_VALIDATION_RECOVERY_CAUSE
|
||||
: configurationIncompleteFailure
|
||||
? CONFIGURATION_INCOMPLETE_RECOVERY_CAUSE
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10157,7 +10289,9 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
recoveryCause:
|
||||
promotionResult.recoveryCause === WORKSPACE_VALIDATION_RECOVERY_CAUSE
|
||||
? WORKSPACE_VALIDATION_RECOVERY_CAUSE
|
||||
: undefined,
|
||||
: promotionResult.recoveryCause === CONFIGURATION_INCOMPLETE_RECOVERY_CAUSE
|
||||
? CONFIGURATION_INCOMPLETE_RECOVERY_CAUSE
|
||||
: undefined,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user