@bridge_gpt/mcp-server 0.2.52 → 0.2.54
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +121 -15
- package/build/agent-launchers/claude.js +3 -3
- package/build/agent-launchers/prompt.js +8 -11
- package/build/base-ref.js +33 -9
- package/build/bounded-wait.js +174 -0
- package/build/commands.generated.js +7 -5
- package/build/conductor/bridge-api-client.js +97 -8
- package/build/conductor/cli.js +23 -0
- package/build/conductor/doctor.js +428 -5
- package/build/conductor/epic-runtime.js +133 -97
- package/build/conductor/install-doctor.js +65 -656
- package/build/conductor/readiness-cli.js +152 -0
- package/build/conductor/readiness-sections.js +666 -0
- package/build/conductor/readiness.js +795 -0
- package/build/conductor/run-branch.js +137 -0
- package/build/conductor/test-run-branch-vectors.js +165 -0
- package/build/conductor/tools.js +56 -3
- package/build/conductor-bin.js +21 -17
- package/build/doctor.js +68 -1
- package/build/drive-epic.js +287 -51
- package/build/executor/claim-scope.js +104 -0
- package/build/executor/cli.js +14 -25
- package/build/executor/env-file-guard.js +82 -3
- package/build/executor/job-runner.js +60 -0
- package/build/index.js +4496 -4697
- package/build/install-doctor.js +154 -2
- package/build/local-artifact-storage.js +130 -0
- package/build/pipelines.generated.js +17 -10
- package/build/plane/alembic-head.js +40 -11
- package/build/plane/build-freshness.js +22 -11
- package/build/plane/cli.js +285 -36
- package/build/plane/manifest.js +209 -1
- package/build/plane/member-roster.js +70 -0
- package/build/plane/preflight.js +363 -48
- package/build/plane/shutdown.js +14 -1
- package/build/plane/status.js +35 -1
- package/build/plane/supervisor.js +546 -164
- package/build/plane/types.js +61 -2
- package/build/polling-policy.js +72 -0
- package/build/readiness-check.js +412 -0
- package/build/readme.generated.js +1 -1
- package/build/review-generation.js +219 -0
- package/build/run-unit-tests-launcher.js +5 -0
- package/build/setup-epic.js +514 -23
- package/build/ticket-key-utils.js +4 -3
- package/build/ticket-review-artifact-gate.js +461 -0
- package/build/upgrade-cli.js +5 -26
- package/build/version.generated.js +3 -3
- package/docs/install/mcp-tool-integrations.md +23 -1
- package/package.json +2 -2
- package/pipelines/{full-automation.json → idea-to-pr.json} +1 -1
- package/pipelines/review-ticket.json +17 -4
|
@@ -0,0 +1,795 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The consolidated ADVISORY conductor readiness gate (BAPI-1055, AC-6/AC-7).
|
|
3
|
+
*
|
|
4
|
+
* One report that answers "is this host and repository ready to conduct an
|
|
5
|
+
* epic", assembled from the five loci that each answered part of it before:
|
|
6
|
+
* `bridge doctor`'s install checklist, `conductor doctor`'s local inspections,
|
|
7
|
+
* `plane` preflight, the server `conductor-readiness` collector, and the
|
|
8
|
+
* install-time doctor that already composed four of them. Every prerequisite
|
|
9
|
+
* comes back as a canonical {@link ReadinessCheck}: a stable id, a
|
|
10
|
+
* pass/warn/fail/skip status, and — on a failure — exactly ONE named
|
|
11
|
+
* remediation.
|
|
12
|
+
*
|
|
13
|
+
* It COMPOSES; it never re-probes. Each collector still owns its own probe, its
|
|
14
|
+
* own timeout, and its own verdict, and this module reads what they returned.
|
|
15
|
+
*
|
|
16
|
+
* ---
|
|
17
|
+
*
|
|
18
|
+
* # Risk boundary for the advisory-only decision (BAPI-1055, R-5)
|
|
19
|
+
*
|
|
20
|
+
* This gate reports and remediates. It never blocks.
|
|
21
|
+
*
|
|
22
|
+
* **Detection.** Every prerequisite that is not `pass` is emitted as a
|
|
23
|
+
* structured check with a named remediation: `warn` for a degraded but usable
|
|
24
|
+
* capability, `fail` for an unmet prerequisite OR one whose state could not be
|
|
25
|
+
* established, `skip` for a genuine dependency or applicability case. Unknown is
|
|
26
|
+
* never reported as healthy — an unreadable liveness read, a failed permission
|
|
27
|
+
* probe, `executor.ready === null`, and a field a newer server would have sent
|
|
28
|
+
* are all failures, not passes.
|
|
29
|
+
*
|
|
30
|
+
* **Containment.** The authoritative refusals are unchanged and live elsewhere:
|
|
31
|
+
* `V2_READINESS_REQUIREMENTS` in `drive-epic.ts` remains the SOLE hard
|
|
32
|
+
* route-selection predicate, and the server-side admission (A1b.1/A1b.2) remains
|
|
33
|
+
* the sole hard refusal. `plane up`'s own blocking preflight still refuses a
|
|
34
|
+
* bring-up on its own terms. This gate contributes to none of those decisions:
|
|
35
|
+
* it computes no overall verdict, changes no exit code, and is imported by no
|
|
36
|
+
* route-selection code. What containment does NOT cover is the gap this gate
|
|
37
|
+
* exists to fill — local and plane configuration problems that the server
|
|
38
|
+
* predicates cannot see at all, and that previously surfaced only as prose
|
|
39
|
+
* buried in a warning.
|
|
40
|
+
*
|
|
41
|
+
* **Escalation.** Findings go to the invoking operator as structured CLI output,
|
|
42
|
+
* with one named remediation each. That remediation is a description of an
|
|
43
|
+
* operator action; nothing here executes one. An ordinary configuration gap
|
|
44
|
+
* raises no Sentry event and pages nobody — it is a normal, expected report. A
|
|
45
|
+
* collector that fails PERSISTENTLY (the same source failing across runs) is the
|
|
46
|
+
* escalation case, and it escalates to whoever owns that host: the operator for
|
|
47
|
+
* a local collector, and the Bridge API owner for a server-side collector that
|
|
48
|
+
* keeps timing out or answering unreadably.
|
|
49
|
+
*
|
|
50
|
+
* ## Deferred retirement: local `gh` (AC-7)
|
|
51
|
+
*
|
|
52
|
+
* **Detection** is `conductor.gh-cli` and `conductor.gh-auth`, carried as two
|
|
53
|
+
* separate checks because installing `gh` and authenticating it are two
|
|
54
|
+
* different operator actions. **Containment** is the existing local-merge
|
|
55
|
+
* execution path, which refuses and emits `merge.failed`/`merge.skipped` rather
|
|
56
|
+
* than merging without auth — a missing `gh` degrades a run, it does not corrupt
|
|
57
|
+
* one. **Escalation** targets the invoking operator first; there is no automated
|
|
58
|
+
* fallback, because the GitHub App token is `contents: read` and structurally
|
|
59
|
+
* cannot merge (see `mcp_server/src/executor/merge-job.ts`). **Retirement** is
|
|
60
|
+
* owned by epic A2, after the App is elevated to `contents: write`. Until A2
|
|
61
|
+
* ships this check is load-bearing and no code here may remove, hide, or
|
|
62
|
+
* downgrade it.
|
|
63
|
+
*
|
|
64
|
+
* ## Deferred retirement: native SQLite ledger (AC-7)
|
|
65
|
+
*
|
|
66
|
+
* **Detection** is `conductor.native-ledger` plus the two Node-environment
|
|
67
|
+
* checks beside it (`conductor.mcp-registration-form`,
|
|
68
|
+
* `conductor.node-engine`). **Containment** is diagnostic degradation only: a
|
|
69
|
+
* ledger whose binding does not load costs local observability, and no code path
|
|
70
|
+
* here installs, rebuilds, or repairs a package (BAPI-526). **Escalation**
|
|
71
|
+
* targets the invoking operator, who reinstalls the optional dependency for
|
|
72
|
+
* their Node runtime. **Retirement** is owned by epic B3, after the parallel
|
|
73
|
+
* ledger is pared away. Until B3 ships this check is load-bearing.
|
|
74
|
+
*
|
|
75
|
+
* ---
|
|
76
|
+
*
|
|
77
|
+
* ## Schema versioning
|
|
78
|
+
*
|
|
79
|
+
* {@link CONDUCTOR_READINESS_GATE_SCHEMA_VERSION} is INDEPENDENT of the install
|
|
80
|
+
* doctor's section vocabulary, the conductor doctor's report, the plane
|
|
81
|
+
* diagnostic shape, and the Python wire response. Each of those evolves for its
|
|
82
|
+
* own reasons; binding this report's version to any of them would make an
|
|
83
|
+
* unrelated additive change look like a breaking one here, or worse, hide a real
|
|
84
|
+
* one.
|
|
85
|
+
*/
|
|
86
|
+
import { mapConductorDoctorReportToReadinessChecks, } from "./doctor.js";
|
|
87
|
+
import { mapInstallStatusChecksToReadinessChecks, } from "../install-doctor.js";
|
|
88
|
+
import { mapPlanePreflightToReadinessChecks, } from "../plane/preflight.js";
|
|
89
|
+
import { runConductorInstallDoctor, } from "./install-doctor.js";
|
|
90
|
+
import { createReadinessCheck, createReadinessCheckSafely, summarizeReadinessChecks, validateReadinessChecks, } from "../readiness-check.js";
|
|
91
|
+
/** This report's own schema version. See the module docstring. */
|
|
92
|
+
export const CONDUCTOR_READINESS_GATE_SCHEMA_VERSION = 1;
|
|
93
|
+
/** Fixed statement carried in every report, so no consumer can mistake it for a gate. */
|
|
94
|
+
export const CONDUCTOR_READINESS_ADVISORY_NOTICE = "Advisory only. This report never blocks bring-up, approval, or a run, and never changes an " +
|
|
95
|
+
"exit code. Remediations are operator actions and are never executed automatically. " +
|
|
96
|
+
"drive-epic's V2_READINESS_REQUIREMENTS remains the only hard route-selection predicate, and " +
|
|
97
|
+
"the server-side admission remains the only hard refusal.";
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
// Server-fact mapper
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
/** The complete server-side prerequisite set, in render order. */
|
|
102
|
+
export const SERVER_READINESS_DESCRIPTORS = [
|
|
103
|
+
{ id: "supervisor-setup", label: "Supervisor setup" },
|
|
104
|
+
{ id: "supervisor-config", label: "Supervisor configuration" },
|
|
105
|
+
{ id: "supervisor-required-checks", label: "Supervisor required CI checks" },
|
|
106
|
+
{ id: "auto-merge", label: "Auto-merge posture" },
|
|
107
|
+
{ id: "merge-approval", label: "Merge approval configuration" },
|
|
108
|
+
{ id: "review-policy", label: "Review policy" },
|
|
109
|
+
{ id: "github-credentials", label: "GitHub App credentials" },
|
|
110
|
+
{ id: "github-actions-permission", label: "GitHub App actions permission" },
|
|
111
|
+
{ id: "reconciler-liveness", label: "Reconciler tick liveness" },
|
|
112
|
+
{ id: "executor-readiness", label: "Executor server observation" },
|
|
113
|
+
{ id: "review-workflow", label: "Review workflow protocol" },
|
|
114
|
+
{ id: "conductor-ci-workflow", label: "Conductor CI migration guard" },
|
|
115
|
+
// BAPI-1102 — the two ONE-TIME unattended prerequisites plus the aggregate the
|
|
116
|
+
// third unattended refusal reads. Noun phrases, like every row above: these
|
|
117
|
+
// render as list items under a source heading, not as sentences.
|
|
118
|
+
{ id: "unattended-consent", label: "Repository unattended consent" },
|
|
119
|
+
{ id: "unattended-notify-default", label: "Verified notify webhook default" },
|
|
120
|
+
{ id: "repository-workflow-readiness", label: "Repository workflow readiness" },
|
|
121
|
+
];
|
|
122
|
+
/** Fixed, secret-free prose per failure kind. Never a status body or header. */
|
|
123
|
+
const SERVER_FAILURE_COPY = {
|
|
124
|
+
"identity-unresolved": {
|
|
125
|
+
detail: "no server facts were read — the repository identity or credential could not be resolved",
|
|
126
|
+
remediation: "run /install-bridge (or set BAPI_REPO_NAME and BAPI_API_KEY) for this repository, then re-run.",
|
|
127
|
+
},
|
|
128
|
+
unauthorized: {
|
|
129
|
+
detail: "the Bridge API rejected the credential for this repository",
|
|
130
|
+
remediation: "re-check the API key and repository name, then re-run; the key may have been rotated.",
|
|
131
|
+
},
|
|
132
|
+
"invalid-response": {
|
|
133
|
+
detail: "the readiness response failed shape validation, so no server fact can be trusted",
|
|
134
|
+
remediation: "re-run; if it persists the Bridge API deploy is inconsistent with this CLI version.",
|
|
135
|
+
},
|
|
136
|
+
unreachable: {
|
|
137
|
+
detail: "the readiness endpoint could not be reached",
|
|
138
|
+
remediation: "check network reachability to the Bridge API and re-run; the local checks above are unaffected.",
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
/** Remediations for the individual server facts, as fixed prose. */
|
|
142
|
+
const SERVER_REMEDIATIONS = {
|
|
143
|
+
supervisorSetup: "run `install conductor` (or `setup-epic`) for this repository to write the project-default supervisor posture.",
|
|
144
|
+
supervisorConfig: "run `install conductor` for this repository to write the project-default supervisor configuration.",
|
|
145
|
+
requiredChecks: "configure a non-empty required-check list in done_gate_config; an empty list makes the done gate pass unconditionally.",
|
|
146
|
+
mergeApproval: "set the merge-approval requirement explicitly in the supervisor configuration rather than leaving it unset.",
|
|
147
|
+
reviewPolicyMissing: "set the repository review-policy default so the merge path and the code_review gate read the same signal.",
|
|
148
|
+
reviewPolicyDivergent: "align the repository review-policy default with the done gate's review_state condition, or confirm the difference is intended.",
|
|
149
|
+
githubCredentials: "connect GitHub from the Bridge setup UI (`install-bridge connect-github`) so owner, repository id, and installation id all resolve.",
|
|
150
|
+
actionsProbeFailed: "re-run once credentials resolve; the permission could not be checked, which is not the same as confirmed-missing.",
|
|
151
|
+
actionsMissing: "grant the GitHub App `actions: write`; without it the conductor's workflow rerun lane fails open on 403.",
|
|
152
|
+
reconcilerUnreadable: "retry once the Bridge API is reachable; reconciler liveness is unknown here, not confirmed stale.",
|
|
153
|
+
reconcilerStale: "start the reconciler for this repository (the `conductor epic-tick` schedule) so ticks resume.",
|
|
154
|
+
executorUnreadable: "retry once the Bridge API is reachable; no executor readiness claim is ever made from local state.",
|
|
155
|
+
executorNeverSeen: "provision and start an executor (`install conductor --executor-id <id>` or `executor install-service`); readiness comes only from a live server observation.",
|
|
156
|
+
executorStale: "restart the executor service; it was observed once and has gone quiet past the server's staleness threshold.",
|
|
157
|
+
reviewWorkflowUnknown: "re-run; the default branch's review workflow could not be evaluated, which is not the same as it being absent.",
|
|
158
|
+
reviewWorkflowIncomplete: "install the claude-review workflow template on the default branch so it emits both the run id and the reviewed SHA the verdict protocol reads.",
|
|
159
|
+
ciWorkflowUnknown: "re-run; the default branch's conductor CI workflow could not be evaluated, which is not the same as the guard being absent.",
|
|
160
|
+
ciWorkflowMissingGuard: "add the migration-guard job to the conductor CI workflow on the default branch so migrations are checked before a merge.",
|
|
161
|
+
olderServer: "upgrade the Bridge API deploy so it reports this fact; its state is unknown here, not healthy.",
|
|
162
|
+
// BAPI-1102. Each names the ONE action that clears it, and none of them
|
|
163
|
+
// mentions a webhook URL, a destination, or any configuration value.
|
|
164
|
+
unattendedConsent: "set `unattended_conductor_allowed` for this repository (docs/claude/account-settings-operator-runbook.md), or run setup with --attended.",
|
|
165
|
+
unattendedNotifyDefault: "supply a --policy-file declaring notify.webhook_url, or run setup with --attended; an unattended run must have a working escalation channel, and notify.local_sink is not one.",
|
|
166
|
+
repositoryWorkflowReadiness: "run `install-bridge conductor` so the review and conductor-CI workflows are confirmed on the default branch, or run setup with --attended.",
|
|
167
|
+
};
|
|
168
|
+
/**
|
|
169
|
+
* Project the validated server readiness response into `server.*` checks.
|
|
170
|
+
*
|
|
171
|
+
* PURE. It re-derives nothing: the server's own `stale` and `ready` conclusions
|
|
172
|
+
* and its own threshold values are reported as given, and
|
|
173
|
+
* `V2_READINESS_REQUIREMENTS` is neither imported nor duplicated here — two
|
|
174
|
+
* implementations of one classification drift, and this one is advisory while
|
|
175
|
+
* that one decides a route.
|
|
176
|
+
*
|
|
177
|
+
* Uncertainty is never healthy. `actions_probe_succeeded === false`, an
|
|
178
|
+
* unreadable liveness read, `executor.ready === null`, and a field an older
|
|
179
|
+
* server never sent each map to `fail` with a remediation for RESTORING the
|
|
180
|
+
* probe — kept deliberately distinct from the confirmed-negative remediation,
|
|
181
|
+
* because "could not be checked" and "checked and absent" need different
|
|
182
|
+
* actions.
|
|
183
|
+
*
|
|
184
|
+
* Passing `null` (with a `failure` kind) yields the full descriptor set as
|
|
185
|
+
* failures, so an unreachable server loses no prerequisite from the report.
|
|
186
|
+
*/
|
|
187
|
+
export function mapServerReadinessToChecks(readiness, failure = "unreachable") {
|
|
188
|
+
if (readiness === null) {
|
|
189
|
+
const copy = SERVER_FAILURE_COPY[failure];
|
|
190
|
+
return SERVER_READINESS_DESCRIPTORS.map(({ id, label }) => createReadinessCheck({
|
|
191
|
+
id: `server.${id}`,
|
|
192
|
+
source: "server",
|
|
193
|
+
label,
|
|
194
|
+
status: "fail",
|
|
195
|
+
detail: copy.detail,
|
|
196
|
+
remediation: copy.remediation,
|
|
197
|
+
}));
|
|
198
|
+
}
|
|
199
|
+
const s = readiness.supervisor;
|
|
200
|
+
const g = readiness.github;
|
|
201
|
+
const r = readiness.reconciler;
|
|
202
|
+
const e = readiness.executor;
|
|
203
|
+
const outcomes = [];
|
|
204
|
+
outcomes.push(s.setup_present
|
|
205
|
+
? { id: "supervisor-setup", status: "pass", detail: `stored (source: ${s.setup_source})` }
|
|
206
|
+
: {
|
|
207
|
+
id: "supervisor-setup",
|
|
208
|
+
status: "fail",
|
|
209
|
+
detail: "no supervisor setup is stored for this repository",
|
|
210
|
+
remediation: SERVER_REMEDIATIONS.supervisorSetup,
|
|
211
|
+
});
|
|
212
|
+
outcomes.push(s.config_present
|
|
213
|
+
? { id: "supervisor-config", status: "pass", detail: `stored (source: ${s.config_source})` }
|
|
214
|
+
: {
|
|
215
|
+
id: "supervisor-config",
|
|
216
|
+
status: "fail",
|
|
217
|
+
detail: "no supervisor configuration is stored for this repository",
|
|
218
|
+
remediation: SERVER_REMEDIATIONS.supervisorConfig,
|
|
219
|
+
});
|
|
220
|
+
// Counts and booleans are safe facts and stay in `detail`; they never become
|
|
221
|
+
// checks of their own, because there is nothing separate to remediate.
|
|
222
|
+
outcomes.push(s.required_checks_empty
|
|
223
|
+
? {
|
|
224
|
+
id: "supervisor-required-checks",
|
|
225
|
+
status: "warn",
|
|
226
|
+
detail: "the required-check list is EMPTY — the done gate would pass unconditionally",
|
|
227
|
+
remediation: SERVER_REMEDIATIONS.requiredChecks,
|
|
228
|
+
}
|
|
229
|
+
: {
|
|
230
|
+
id: "supervisor-required-checks",
|
|
231
|
+
status: "pass",
|
|
232
|
+
detail: `${s.required_checks_count} required check(s) configured`,
|
|
233
|
+
});
|
|
234
|
+
// Auto-merge OFF is a posture, not a gap: an operator may deliberately merge
|
|
235
|
+
// by hand. Reported, never remediated.
|
|
236
|
+
outcomes.push({
|
|
237
|
+
id: "auto-merge",
|
|
238
|
+
status: "pass",
|
|
239
|
+
detail: s.auto_merge_enabled ? "auto-merge enabled" : "auto-merge disabled (operator posture)",
|
|
240
|
+
});
|
|
241
|
+
outcomes.push(s.merge_approval_required_set
|
|
242
|
+
? { id: "merge-approval", status: "pass", detail: "merge-approval requirement is set explicitly" }
|
|
243
|
+
: {
|
|
244
|
+
id: "merge-approval",
|
|
245
|
+
status: "warn",
|
|
246
|
+
detail: "the merge-approval requirement is unset, so the merge path falls back to its default",
|
|
247
|
+
remediation: SERVER_REMEDIATIONS.mergeApproval,
|
|
248
|
+
});
|
|
249
|
+
outcomes.push(reviewPolicyOutcome(readiness));
|
|
250
|
+
outcomes.push(g.credentials_complete
|
|
251
|
+
? {
|
|
252
|
+
id: "github-credentials",
|
|
253
|
+
status: "pass",
|
|
254
|
+
detail: "owner, repository id, and installation id all resolved",
|
|
255
|
+
}
|
|
256
|
+
: {
|
|
257
|
+
id: "github-credentials",
|
|
258
|
+
status: "fail",
|
|
259
|
+
detail: g.credentials_readable
|
|
260
|
+
? `incomplete: ${[
|
|
261
|
+
!g.owner_resolved ? "owner" : null,
|
|
262
|
+
!g.repo_id_resolved ? "repository id" : null,
|
|
263
|
+
!g.installation_id_resolved ? "installation id" : null,
|
|
264
|
+
]
|
|
265
|
+
.filter(Boolean)
|
|
266
|
+
.join(", ")} unresolved`
|
|
267
|
+
: "no GitHub credential row exists for this repository",
|
|
268
|
+
remediation: SERVER_REMEDIATIONS.githubCredentials,
|
|
269
|
+
});
|
|
270
|
+
outcomes.push(githubActionsOutcome(readiness));
|
|
271
|
+
outcomes.push(reconcilerOutcome(readiness));
|
|
272
|
+
outcomes.push(executorOutcome(readiness));
|
|
273
|
+
outcomes.push(reviewWorkflowOutcome(readiness));
|
|
274
|
+
outcomes.push(ciWorkflowOutcome(readiness));
|
|
275
|
+
outcomes.push(...unattendedOutcomes(readiness));
|
|
276
|
+
const byId = new Map(outcomes.map((outcome) => [outcome.id, outcome]));
|
|
277
|
+
return SERVER_READINESS_DESCRIPTORS.map(({ id, label }) => {
|
|
278
|
+
const outcome = byId.get(id);
|
|
279
|
+
if (!outcome) {
|
|
280
|
+
return createReadinessCheck({
|
|
281
|
+
id: `server.${id}`,
|
|
282
|
+
source: "server",
|
|
283
|
+
label,
|
|
284
|
+
status: "fail",
|
|
285
|
+
detail: "this server fact was not reported",
|
|
286
|
+
remediation: SERVER_REMEDIATIONS.olderServer,
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
return createReadinessCheckSafely({
|
|
290
|
+
id: `server.${id}`,
|
|
291
|
+
source: "server",
|
|
292
|
+
label,
|
|
293
|
+
status: outcome.status,
|
|
294
|
+
detail: outcome.detail,
|
|
295
|
+
...(outcome.status === "pass"
|
|
296
|
+
? {}
|
|
297
|
+
: { remediation: outcome.remediation ?? SERVER_REMEDIATIONS.olderServer }),
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* The three BAPI-1102 unattended prerequisite rows.
|
|
303
|
+
*
|
|
304
|
+
* Returned as a group because they share one absence rule: a server older than
|
|
305
|
+
* BAPI-1102 reports no `unattended` block at all, and an unreported fact is a
|
|
306
|
+
* FAILURE with the older-server remediation — never a pass. "Could not be read"
|
|
307
|
+
* and "read and healthy" are the two answers this whole module refuses to
|
|
308
|
+
* conflate, and these are prerequisites for an UNATTENDED merge, which is the
|
|
309
|
+
* worst place to guess.
|
|
310
|
+
*
|
|
311
|
+
* Consent and workflow readiness are `warn`, not `fail`, when confirmed absent:
|
|
312
|
+
* a repository that never runs an unattended epic needs neither, so reporting
|
|
313
|
+
* them as failures would mark a perfectly healthy attended install broken. They
|
|
314
|
+
* become hard refusals only at `setup-epic`, and only for a run whose effective
|
|
315
|
+
* policy is actually unattended.
|
|
316
|
+
*/
|
|
317
|
+
function unattendedOutcomes(readiness) {
|
|
318
|
+
const u = readiness.unattended;
|
|
319
|
+
if (u === null) {
|
|
320
|
+
return [
|
|
321
|
+
"unattended-consent",
|
|
322
|
+
"unattended-notify-default",
|
|
323
|
+
"repository-workflow-readiness",
|
|
324
|
+
].map((id) => ({
|
|
325
|
+
id,
|
|
326
|
+
status: "fail",
|
|
327
|
+
detail: "this server fact was not reported",
|
|
328
|
+
remediation: SERVER_REMEDIATIONS.olderServer,
|
|
329
|
+
}));
|
|
330
|
+
}
|
|
331
|
+
return [
|
|
332
|
+
u.conductor_allowed
|
|
333
|
+
? {
|
|
334
|
+
id: "unattended-consent",
|
|
335
|
+
status: "pass",
|
|
336
|
+
detail: "this repository has consented to unattended conductor runs",
|
|
337
|
+
}
|
|
338
|
+
: {
|
|
339
|
+
id: "unattended-consent",
|
|
340
|
+
status: "warn",
|
|
341
|
+
detail: "unattended_conductor_allowed is NOT set — an unattended run will be refused before it is created",
|
|
342
|
+
remediation: SERVER_REMEDIATIONS.unattendedConsent,
|
|
343
|
+
},
|
|
344
|
+
u.notify_webhook_default_declared
|
|
345
|
+
? {
|
|
346
|
+
id: "unattended-notify-default",
|
|
347
|
+
status: "pass",
|
|
348
|
+
// Presence only. The URL is never reported by the server and is never
|
|
349
|
+
// rendered here.
|
|
350
|
+
detail: u.notify_webhook_default_verified === true
|
|
351
|
+
? "a notify webhook default is declared and verified"
|
|
352
|
+
: "a notify webhook default is declared (verified at approval, not here)",
|
|
353
|
+
}
|
|
354
|
+
: {
|
|
355
|
+
id: "unattended-notify-default",
|
|
356
|
+
status: "warn",
|
|
357
|
+
detail: "no repository notify webhook default is declared — an unattended run needs an escalation channel, and no column supplies a repository default today, so the run must declare notify.webhook_url itself",
|
|
358
|
+
remediation: SERVER_REMEDIATIONS.unattendedNotifyDefault,
|
|
359
|
+
},
|
|
360
|
+
u.repository_readiness_confirmed
|
|
361
|
+
? {
|
|
362
|
+
id: "repository-workflow-readiness",
|
|
363
|
+
status: "pass",
|
|
364
|
+
detail: "the review and conductor-CI workflows are both confirmed",
|
|
365
|
+
}
|
|
366
|
+
: {
|
|
367
|
+
id: "repository-workflow-readiness",
|
|
368
|
+
status: "warn",
|
|
369
|
+
detail: "the review and conductor-CI workflows are not both confirmed — an unattended auto-merging run will be refused",
|
|
370
|
+
remediation: SERVER_REMEDIATIONS.repositoryWorkflowReadiness,
|
|
371
|
+
},
|
|
372
|
+
];
|
|
373
|
+
}
|
|
374
|
+
/** Review-policy presence and alignment, as one check with one fix each. */
|
|
375
|
+
function reviewPolicyOutcome(readiness) {
|
|
376
|
+
const s = readiness.supervisor;
|
|
377
|
+
const alignment = s.review_policy_alignment;
|
|
378
|
+
if (alignment !== null) {
|
|
379
|
+
if (alignment.status === "divergent" || alignment.status === "invalid") {
|
|
380
|
+
return {
|
|
381
|
+
id: "review-policy",
|
|
382
|
+
status: "warn",
|
|
383
|
+
// The server authors one operator-facing sentence for this; it is a
|
|
384
|
+
// constrained, server-owned explanation, not free-form error text.
|
|
385
|
+
detail: alignment.explanation,
|
|
386
|
+
remediation: SERVER_REMEDIATIONS.reviewPolicyDivergent,
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
return { id: "review-policy", status: "pass", detail: alignment.explanation };
|
|
390
|
+
}
|
|
391
|
+
// A pre-alignment server cannot report alignment. Presence is still knowable.
|
|
392
|
+
return s.review_policy_present
|
|
393
|
+
? {
|
|
394
|
+
id: "review-policy",
|
|
395
|
+
status: "pass",
|
|
396
|
+
detail: "a repository review-policy default is stored (alignment not reported by this server)",
|
|
397
|
+
}
|
|
398
|
+
: {
|
|
399
|
+
id: "review-policy",
|
|
400
|
+
status: "warn",
|
|
401
|
+
detail: "no repository review-policy default is stored",
|
|
402
|
+
remediation: SERVER_REMEDIATIONS.reviewPolicyMissing,
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* GitHub Actions permission.
|
|
407
|
+
*
|
|
408
|
+
* A FAILED PROBE and a CONFIRMED insufficient permission are two findings with
|
|
409
|
+
* two fixes — retry the probe, versus grant the permission — and collapsing them
|
|
410
|
+
* would send an operator to change a permission that may already be correct.
|
|
411
|
+
*/
|
|
412
|
+
function githubActionsOutcome(readiness) {
|
|
413
|
+
const g = readiness.github;
|
|
414
|
+
if (g.actions_write) {
|
|
415
|
+
return { id: "github-actions-permission", status: "pass", detail: "actions: write" };
|
|
416
|
+
}
|
|
417
|
+
if (!g.actions_probe_succeeded) {
|
|
418
|
+
return {
|
|
419
|
+
id: "github-actions-permission",
|
|
420
|
+
status: "fail",
|
|
421
|
+
detail: "the actions permission could not be checked (this is not a confirmed absence)",
|
|
422
|
+
remediation: SERVER_REMEDIATIONS.actionsProbeFailed,
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
return {
|
|
426
|
+
id: "github-actions-permission",
|
|
427
|
+
status: "warn",
|
|
428
|
+
detail: `actions: ${g.actions_permission_level}`,
|
|
429
|
+
remediation: SERVER_REMEDIATIONS.actionsMissing,
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
/** Reconciler liveness, preserving the server's own `stale` conclusion. */
|
|
433
|
+
function reconcilerOutcome(readiness) {
|
|
434
|
+
const r = readiness.reconciler;
|
|
435
|
+
if (!r.liveness_readable) {
|
|
436
|
+
return {
|
|
437
|
+
id: "reconciler-liveness",
|
|
438
|
+
status: "fail",
|
|
439
|
+
detail: "reconciler liveness could not be read",
|
|
440
|
+
remediation: SERVER_REMEDIATIONS.reconcilerUnreadable,
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
if (!r.stale) {
|
|
444
|
+
return {
|
|
445
|
+
id: "reconciler-liveness",
|
|
446
|
+
status: "pass",
|
|
447
|
+
detail: `last tick ${r.last_tick_age_seconds}s ago across ${r.active_run_count} active run(s)`,
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
return {
|
|
451
|
+
id: "reconciler-liveness",
|
|
452
|
+
status: "fail",
|
|
453
|
+
detail: r.last_tick_at === null
|
|
454
|
+
? "no reconciler tick has been recorded for this repository"
|
|
455
|
+
: `last tick ${r.last_tick_age_seconds}s ago (server threshold ${readiness.thresholds.reconciler_stale_after_seconds}s)`,
|
|
456
|
+
remediation: SERVER_REMEDIATIONS.reconcilerStale,
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
460
|
+
* Executor readiness — the AUTHORITATIVE executor fact.
|
|
461
|
+
*
|
|
462
|
+
* The three not-ready shapes stay distinct because they mean different things
|
|
463
|
+
* and need different actions: `null` is "never observed", `false` is "observed
|
|
464
|
+
* but stale", and an unreadable payload is "unknown". Collapsing them would let
|
|
465
|
+
* a stale executor read like a fresh install, or the reverse.
|
|
466
|
+
*/
|
|
467
|
+
function executorOutcome(readiness) {
|
|
468
|
+
const e = readiness.executor;
|
|
469
|
+
if (!e.liveness_readable) {
|
|
470
|
+
return {
|
|
471
|
+
id: "executor-readiness",
|
|
472
|
+
status: "fail",
|
|
473
|
+
detail: "executor liveness could not be read",
|
|
474
|
+
remediation: SERVER_REMEDIATIONS.executorUnreadable,
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
if (e.ready === true) {
|
|
478
|
+
return {
|
|
479
|
+
id: "executor-readiness",
|
|
480
|
+
status: "pass",
|
|
481
|
+
detail: `executor ready — last seen ${e.last_seen_age_seconds}s ago`,
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
if (e.ready === false) {
|
|
485
|
+
return {
|
|
486
|
+
id: "executor-readiness",
|
|
487
|
+
status: "fail",
|
|
488
|
+
detail: `observed but not ready — last seen ${e.last_seen_age_seconds}s ago (server threshold ${readiness.thresholds.executor_stale_after_seconds}s)`,
|
|
489
|
+
remediation: SERVER_REMEDIATIONS.executorStale,
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
return {
|
|
493
|
+
id: "executor-readiness",
|
|
494
|
+
status: "fail",
|
|
495
|
+
detail: "never observed by the server",
|
|
496
|
+
remediation: SERVER_REMEDIATIONS.executorNeverSeen,
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
/** Review-workflow protocol facts. `null` means an older server, not health. */
|
|
500
|
+
function reviewWorkflowOutcome(readiness) {
|
|
501
|
+
const w = readiness.review_workflow;
|
|
502
|
+
if (w === null) {
|
|
503
|
+
return {
|
|
504
|
+
id: "review-workflow",
|
|
505
|
+
status: "fail",
|
|
506
|
+
detail: "this Bridge API deploy did not report the review-workflow facts",
|
|
507
|
+
remediation: SERVER_REMEDIATIONS.olderServer,
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
if (!w.probe_succeeded) {
|
|
511
|
+
return {
|
|
512
|
+
id: "review-workflow",
|
|
513
|
+
status: "fail",
|
|
514
|
+
detail: w.workflow_present
|
|
515
|
+
? "the review workflow is present but could not be evaluated"
|
|
516
|
+
: "the review workflow could not be found or read on the default branch",
|
|
517
|
+
remediation: SERVER_REMEDIATIONS.reviewWorkflowUnknown,
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
if (w.emits_run_id && w.emits_reviewed_sha) {
|
|
521
|
+
return {
|
|
522
|
+
id: "review-workflow",
|
|
523
|
+
status: "pass",
|
|
524
|
+
detail: "the default branch's review workflow emits both protocol markers",
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
return {
|
|
528
|
+
id: "review-workflow",
|
|
529
|
+
status: "fail",
|
|
530
|
+
detail: `missing marker(s): ${[
|
|
531
|
+
!w.emits_run_id ? "run id" : null,
|
|
532
|
+
!w.emits_reviewed_sha ? "reviewed SHA" : null,
|
|
533
|
+
]
|
|
534
|
+
.filter(Boolean)
|
|
535
|
+
.join(", ")}`,
|
|
536
|
+
remediation: SERVER_REMEDIATIONS.reviewWorkflowIncomplete,
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
/** Conductor-CI migration-guard facts. `null` means an older server. */
|
|
540
|
+
function ciWorkflowOutcome(readiness) {
|
|
541
|
+
const c = readiness.conductor_ci_workflow;
|
|
542
|
+
if (c === null) {
|
|
543
|
+
return {
|
|
544
|
+
id: "conductor-ci-workflow",
|
|
545
|
+
status: "fail",
|
|
546
|
+
detail: "this Bridge API deploy did not report the conductor-CI workflow facts",
|
|
547
|
+
remediation: SERVER_REMEDIATIONS.olderServer,
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
if (!c.probe_succeeded) {
|
|
551
|
+
return {
|
|
552
|
+
id: "conductor-ci-workflow",
|
|
553
|
+
status: "fail",
|
|
554
|
+
detail: c.workflow_present
|
|
555
|
+
? "the conductor CI workflow is present but could not be evaluated"
|
|
556
|
+
: "the conductor CI workflow could not be found or read on the default branch",
|
|
557
|
+
remediation: SERVER_REMEDIATIONS.ciWorkflowUnknown,
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
return c.migration_guard_present
|
|
561
|
+
? {
|
|
562
|
+
id: "conductor-ci-workflow",
|
|
563
|
+
status: "pass",
|
|
564
|
+
detail: "the default branch's conductor CI workflow runs the migration guard",
|
|
565
|
+
}
|
|
566
|
+
: {
|
|
567
|
+
id: "conductor-ci-workflow",
|
|
568
|
+
status: "warn",
|
|
569
|
+
detail: "the conductor CI workflow does not run the migration guard",
|
|
570
|
+
remediation: SERVER_REMEDIATIONS.ciWorkflowMissingGuard,
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
// ---------------------------------------------------------------------------
|
|
574
|
+
// Install-time section adapter
|
|
575
|
+
// ---------------------------------------------------------------------------
|
|
576
|
+
/**
|
|
577
|
+
* Sections the install-time doctor builds that the OWNING collectors also
|
|
578
|
+
* report through their own structured outcomes.
|
|
579
|
+
*
|
|
580
|
+
* Deduplication prefers the owner: a section is a prose interpretation of a
|
|
581
|
+
* probe, while the owning collector's outcome is the probe's own structured
|
|
582
|
+
* result, and reporting both would show one prerequisite twice with two
|
|
583
|
+
* wordings. The install-side ids that overlap are listed rather than matched by
|
|
584
|
+
* heuristic, so adding a section can never silently start shadowing an owner.
|
|
585
|
+
*/
|
|
586
|
+
const INSTALL_SECTION_IDS_OWNED_ELSEWHERE = new Set([
|
|
587
|
+
// Server facts — owned by `mapServerReadinessToChecks`.
|
|
588
|
+
"supervisor-configuration",
|
|
589
|
+
"supervisor-required-checks",
|
|
590
|
+
"supervisor-review-alignment",
|
|
591
|
+
"github-credentials",
|
|
592
|
+
"github-actions-permission",
|
|
593
|
+
"reconciler-liveness",
|
|
594
|
+
"executor-observation",
|
|
595
|
+
"server-readiness",
|
|
596
|
+
"bridge-access",
|
|
597
|
+
// Local conductor facts — owned by `mapConductorDoctorReportToReadinessChecks`.
|
|
598
|
+
"conductor-ledger",
|
|
599
|
+
"conductor-ledger-loadability",
|
|
600
|
+
// Legacy embedded reports — owned by their own adapters.
|
|
601
|
+
"install-status",
|
|
602
|
+
]);
|
|
603
|
+
/** Map the install doctor's own status vocabulary onto the canonical one. */
|
|
604
|
+
function sectionStatus(status) {
|
|
605
|
+
// `fatal` becomes `fail`, and NOTHING else follows from it here: the exit-code
|
|
606
|
+
// consequence stays with the installer's `conductorInstallDoctorHasFatal`.
|
|
607
|
+
if (status === "fatal")
|
|
608
|
+
return "fail";
|
|
609
|
+
return status === "degraded" ? "warn" : "pass";
|
|
610
|
+
}
|
|
611
|
+
/**
|
|
612
|
+
* Project the install-time doctor's remaining sections into `conductor.*` checks.
|
|
613
|
+
*
|
|
614
|
+
* These are the prerequisites no other collector owns — executor provisioning,
|
|
615
|
+
* local service state, the host profile token, hook targets, and the workflow
|
|
616
|
+
* presence section. Ids are prefixed `install-section-` so they cannot collide
|
|
617
|
+
* with a conductor-doctor outcome that happens to share a name.
|
|
618
|
+
*/
|
|
619
|
+
export function mapInstallSectionsToReadinessChecks(sections) {
|
|
620
|
+
const checks = [];
|
|
621
|
+
const seen = new Set();
|
|
622
|
+
for (const section of sections ?? []) {
|
|
623
|
+
if (INSTALL_SECTION_IDS_OWNED_ELSEWHERE.has(section.id))
|
|
624
|
+
continue;
|
|
625
|
+
if (seen.has(section.id))
|
|
626
|
+
continue;
|
|
627
|
+
seen.add(section.id);
|
|
628
|
+
const status = sectionStatus(section.status);
|
|
629
|
+
checks.push(createReadinessCheckSafely({
|
|
630
|
+
id: `conductor.install-section-${section.id}`,
|
|
631
|
+
source: "conductor",
|
|
632
|
+
label: section.label,
|
|
633
|
+
status,
|
|
634
|
+
detail: section.detail,
|
|
635
|
+
...(status === "pass"
|
|
636
|
+
? {}
|
|
637
|
+
: {
|
|
638
|
+
remediation: section.remediation ??
|
|
639
|
+
"run `install conductor` for this repository to re-provision this capability.",
|
|
640
|
+
}),
|
|
641
|
+
}));
|
|
642
|
+
}
|
|
643
|
+
return checks;
|
|
644
|
+
}
|
|
645
|
+
/**
|
|
646
|
+
* Collect the consolidated advisory readiness report.
|
|
647
|
+
*
|
|
648
|
+
* NEVER THROWS. A collector that fails does not remove its prerequisites from
|
|
649
|
+
* the report — they come back as failed checks with restoring remediations, so a
|
|
650
|
+
* broken source can never make a report look shorter and healthier.
|
|
651
|
+
*
|
|
652
|
+
* The two independent collections (install-time doctor, plane preflight) run
|
|
653
|
+
* concurrently. No retry and no additional timeout is introduced: each collector
|
|
654
|
+
* keeps its own.
|
|
655
|
+
*/
|
|
656
|
+
export async function collectConductorReadinessGate(deps) {
|
|
657
|
+
const [installReport, planeResult] = await Promise.all([
|
|
658
|
+
runInstallDoctorSafely(deps),
|
|
659
|
+
runPlaneSafely(deps),
|
|
660
|
+
]);
|
|
661
|
+
const unavailableSources = [];
|
|
662
|
+
// The install-time doctor is the collector for the install, conductor-section,
|
|
663
|
+
// and server legs; losing it loses all three.
|
|
664
|
+
if (installReport === null) {
|
|
665
|
+
unavailableSources.push("install", "conductor", "server");
|
|
666
|
+
}
|
|
667
|
+
// A plane preflight was requested and could not be run. Not requesting one is
|
|
668
|
+
// not an unavailability — its prerequisites are still reported as unknowns.
|
|
669
|
+
if (deps.runPlanePreflight && planeResult === null)
|
|
670
|
+
unavailableSources.push("plane");
|
|
671
|
+
const checks = [
|
|
672
|
+
...mapInstallStatusChecksToReadinessChecks((installReport?.legacyInstallChecks ?? null), installReport === null),
|
|
673
|
+
...mapConductorDoctorReportToReadinessChecks(installReport?.legacyConductor ?? null),
|
|
674
|
+
...mapInstallSectionsToReadinessChecks(installReport?.sections ?? null),
|
|
675
|
+
...mapPlanePreflightToReadinessChecks(planeResult),
|
|
676
|
+
...mapServerReadinessToChecks(installReport?.readiness ?? null, classifyServerFailure(installReport)),
|
|
677
|
+
];
|
|
678
|
+
// Validate the ASSEMBLED report, not just the pieces: an injected collector or
|
|
679
|
+
// a future adapter could hand over an entry that violates the contract, and a
|
|
680
|
+
// malformed entry becomes a fixed source-scoped failure rather than escaping
|
|
681
|
+
// into output or dropping the locus.
|
|
682
|
+
const validated = validateReadinessChecks(checks);
|
|
683
|
+
return {
|
|
684
|
+
schemaVersion: CONDUCTOR_READINESS_GATE_SCHEMA_VERSION,
|
|
685
|
+
advisory: CONDUCTOR_READINESS_ADVISORY_NOTICE,
|
|
686
|
+
repoName: deps.repoName ?? null,
|
|
687
|
+
unavailableSources,
|
|
688
|
+
checks: validated,
|
|
689
|
+
summary: summarizeReadinessChecks(validated),
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
/** Run the install-time doctor, containing any throw. */
|
|
693
|
+
async function runInstallDoctorSafely(deps) {
|
|
694
|
+
try {
|
|
695
|
+
if (deps.runInstallDoctor)
|
|
696
|
+
return await deps.runInstallDoctor();
|
|
697
|
+
if (!deps.installDoctor)
|
|
698
|
+
return null;
|
|
699
|
+
return await runConductorInstallDoctor(deps.conductorDoctorDeps
|
|
700
|
+
? { ...deps.installDoctor, conductorDoctorDeps: deps.conductorDoctorDeps }
|
|
701
|
+
: deps.installDoctor);
|
|
702
|
+
}
|
|
703
|
+
catch {
|
|
704
|
+
// It is documented never to throw; this contains an injected fake so the
|
|
705
|
+
// whole report is not lost to one collector.
|
|
706
|
+
return null;
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
/** Run the plane preflight, containing any throw. */
|
|
710
|
+
async function runPlaneSafely(deps) {
|
|
711
|
+
if (!deps.runPlanePreflight)
|
|
712
|
+
return null;
|
|
713
|
+
try {
|
|
714
|
+
return await deps.runPlanePreflight();
|
|
715
|
+
}
|
|
716
|
+
catch {
|
|
717
|
+
return null;
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
/**
|
|
721
|
+
* Why the server facts are missing, when they are.
|
|
722
|
+
*
|
|
723
|
+
* Read from the install doctor's OWN fatal/degraded section rather than
|
|
724
|
+
* re-classified here: it already distinguishes an unresolved identity, a
|
|
725
|
+
* rejected credential, a malformed body, and an unreachable endpoint, and a
|
|
726
|
+
* second classification would be a second chance to disagree with the first.
|
|
727
|
+
*/
|
|
728
|
+
function classifyServerFailure(report) {
|
|
729
|
+
if (report === null)
|
|
730
|
+
return "unreachable";
|
|
731
|
+
const sections = report.sections ?? [];
|
|
732
|
+
if (sections.some((section) => section.id === "bridge-access"))
|
|
733
|
+
return "identity-unresolved";
|
|
734
|
+
const serverSection = sections.find((section) => section.id === "server-readiness");
|
|
735
|
+
if (!serverSection)
|
|
736
|
+
return "unreachable";
|
|
737
|
+
if (serverSection.detail.includes("rejected the credential"))
|
|
738
|
+
return "unauthorized";
|
|
739
|
+
if (serverSection.detail.includes("shape validation"))
|
|
740
|
+
return "invalid-response";
|
|
741
|
+
return "unreachable";
|
|
742
|
+
}
|
|
743
|
+
// ---------------------------------------------------------------------------
|
|
744
|
+
// Rendering
|
|
745
|
+
// ---------------------------------------------------------------------------
|
|
746
|
+
/** Status tag, matching the visual hierarchy of the other conductor reports. */
|
|
747
|
+
function readinessTag(status) {
|
|
748
|
+
if (status === "fail")
|
|
749
|
+
return "✗ FAIL";
|
|
750
|
+
if (status === "warn")
|
|
751
|
+
return "! WARN";
|
|
752
|
+
if (status === "skip")
|
|
753
|
+
return "- SKIP";
|
|
754
|
+
return "✓ PASS";
|
|
755
|
+
}
|
|
756
|
+
/**
|
|
757
|
+
* Render the consolidated report for a terminal.
|
|
758
|
+
*
|
|
759
|
+
* Pure formatting. It carries the SAME content the JSON serialization does —
|
|
760
|
+
* every canonical check, its source, its status, its detail, and its
|
|
761
|
+
* remediation — so neither rendering can show an operator a finding the other
|
|
762
|
+
* hides. It computes no overall verdict, because there is none to compute.
|
|
763
|
+
*/
|
|
764
|
+
export function formatConductorReadinessGateReport(report) {
|
|
765
|
+
const lines = [
|
|
766
|
+
"Conductor readiness (advisory)",
|
|
767
|
+
"══════════════════════════════",
|
|
768
|
+
`schema version: ${report.schemaVersion}`,
|
|
769
|
+
`repository: ${report.repoName ?? "unresolved"}`,
|
|
770
|
+
"",
|
|
771
|
+
];
|
|
772
|
+
for (const source of ["install", "conductor", "plane", "server"]) {
|
|
773
|
+
const group = report.checks.filter((check) => check.source === source);
|
|
774
|
+
if (group.length === 0)
|
|
775
|
+
continue;
|
|
776
|
+
lines.push(`[${source}]`);
|
|
777
|
+
for (const check of group) {
|
|
778
|
+
lines.push(` ${readinessTag(check.status)} ${check.label} (${check.id})`);
|
|
779
|
+
if (check.detail)
|
|
780
|
+
lines.push(` ${check.detail}`);
|
|
781
|
+
if (check.remediation)
|
|
782
|
+
lines.push(` → ${check.remediation}`);
|
|
783
|
+
}
|
|
784
|
+
lines.push("");
|
|
785
|
+
}
|
|
786
|
+
const { pass, warn, fail, skip } = report.summary;
|
|
787
|
+
lines.push(`summary: ${pass} pass, ${warn} warn, ${fail} fail, ${skip} skip`);
|
|
788
|
+
if (report.unavailableSources.length > 0) {
|
|
789
|
+
lines.push(`sources that could not be collected: ${report.unavailableSources.join(", ")} ` +
|
|
790
|
+
"(their prerequisites above are reported as failures, not as passes)");
|
|
791
|
+
}
|
|
792
|
+
lines.push("");
|
|
793
|
+
lines.push(report.advisory);
|
|
794
|
+
return lines.join("\n");
|
|
795
|
+
}
|