@bridge_gpt/mcp-server 0.2.67 → 0.2.68

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.
Files changed (114) hide show
  1. package/README.md +178 -15
  2. package/build/agent-launchers/claude-executor-adapter.js +27 -0
  3. package/build/agents.generated.js +22 -2
  4. package/build/bitbucket-workspace-remote.js +136 -0
  5. package/build/bootstrap-project-identity.js +24 -0
  6. package/build/bridge-permissions.js +829 -0
  7. package/build/claude-settings.js +18 -0
  8. package/build/cli-help.js +21 -0
  9. package/build/command-assets-doctor.js +24 -70
  10. package/build/command-provisioning.js +29 -5
  11. package/build/commands.generated.js +8 -8
  12. package/build/conductor/bridge-api-client.js +714 -0
  13. package/build/conductor/command-drift-production.js +92 -0
  14. package/build/conductor/command-drift.js +89 -0
  15. package/build/conductor/doctor.js +69 -26
  16. package/build/conductor/epic-branch-ci-inspection.js +1375 -0
  17. package/build/conductor/epic-implementer-cli.js +76 -22
  18. package/build/conductor/gate-discovery.js +890 -0
  19. package/build/conductor/install-doctor.js +15 -3
  20. package/build/conductor/json-output.js +63 -0
  21. package/build/conductor/readiness-cli.js +95 -10
  22. package/build/conductor/readiness-sections.js +369 -3
  23. package/build/conductor/readiness.js +211 -8
  24. package/build/conductor/recovery-cli.js +110 -4
  25. package/build/conductor/recovery-operations.js +99 -1
  26. package/build/connect-bitbucket-api.js +41 -6
  27. package/build/connect-bitbucket.js +106 -33
  28. package/build/credential-store.js +207 -47
  29. package/build/docs.generated.js +57 -3
  30. package/build/doctor.js +92 -1
  31. package/build/drive-epic.js +45 -8
  32. package/build/epic-coherence-input.test-fixtures.js +4 -3
  33. package/build/epic-implementer-bin.js +1374 -25
  34. package/build/epic-workarounds-path.js +39 -0
  35. package/build/executor/cli.js +30 -0
  36. package/build/executor/completion-contract.js +18 -5
  37. package/build/executor/deps.js +4 -1
  38. package/build/executor/gate-qualification-job.js +499 -0
  39. package/build/executor/guarded-fast-forward-push.js +129 -0
  40. package/build/executor/integration-job.js +122 -8
  41. package/build/executor/job-errors.js +8 -1
  42. package/build/executor/job-runner.js +589 -47
  43. package/build/executor/likely-tests.js +290 -0
  44. package/build/executor/live-worker-registry.js +1 -1
  45. package/build/executor/observation.js +31 -2
  46. package/build/executor/packaged-recipe.js +106 -0
  47. package/build/executor/permissions.js +115 -28
  48. package/build/executor/platform.js +1 -1
  49. package/build/executor/preflight.js +61 -12
  50. package/build/executor/process-containment.js +36 -12
  51. package/build/executor/process.js +15 -7
  52. package/build/executor/prompt-spec.js +33 -1
  53. package/build/executor/recently-failed-tests-file.js +71 -0
  54. package/build/executor/reconcile-overlap-job.js +41 -23
  55. package/build/executor/required-verification.js +1 -1
  56. package/build/executor/required-work-ledger.js +105 -17
  57. package/build/executor/retirement.js +69 -0
  58. package/build/executor/runner.js +114 -11
  59. package/build/executor/test-clock.js +15 -0
  60. package/build/executor/types.js +32 -0
  61. package/build/executor/viewer-tabs.js +58 -8
  62. package/build/executor/worker-command.js +23 -7
  63. package/build/executor/worker-diagnostics.js +37 -0
  64. package/build/executor/worker-finalization.js +31 -5
  65. package/build/executor/worker-guard-hook.js +179 -25
  66. package/build/executor/worker-log.js +4 -3
  67. package/build/executor/worker-verification-guidance.js +37 -0
  68. package/build/executor/worktree.js +88 -11
  69. package/build/first-build-advisory.js +97 -0
  70. package/build/generated-assets-advisory.js +14 -0
  71. package/build/generated-assets-catalog.js +66 -0
  72. package/build/generated-assets-context.js +46 -0
  73. package/build/generated-assets-dependencies.js +26 -0
  74. package/build/generated-assets-doctor.js +50 -0
  75. package/build/generated-assets-health.js +65 -0
  76. package/build/generated-assets-host.js +17 -0
  77. package/build/generated-assets-inventory.js +17 -0
  78. package/build/generated-assets-paths.js +60 -0
  79. package/build/generated-assets-publisher.js +133 -0
  80. package/build/generated-assets-remedy.js +25 -0
  81. package/build/generated-assets-repair.js +72 -0
  82. package/build/generated-assets-session.js +71 -0
  83. package/build/generated-assets-test-support.js +35 -0
  84. package/build/index.js +964 -768
  85. package/build/init.js +54 -177
  86. package/build/install-bridge-conductor-gate.js +1372 -0
  87. package/build/install-bridge-conductor.js +289 -36
  88. package/build/install-bridge.js +544 -37
  89. package/build/mcp-install-state.js +50 -4
  90. package/build/merge-workflow-install.js +3 -4
  91. package/build/pipeline-utils.js +6 -2
  92. package/build/pipelines.generated.js +22 -142
  93. package/build/plan-execution-ledger.js +2 -32
  94. package/build/plane/manifest.js +16 -1
  95. package/build/plane/preflight.js +54 -1
  96. package/build/printed-remedy-commands.js +16 -1
  97. package/build/readme.generated.js +1 -1
  98. package/build/repair-assets.js +93 -0
  99. package/build/run-unit-tests-launcher.js +130 -49
  100. package/build/secret-redaction.js +33 -0
  101. package/build/setup-epic.js +108 -3
  102. package/build/start-tickets-prereqs.js +70 -16
  103. package/build/start-tickets.js +29 -5
  104. package/build/tool-surface-gating.js +36 -0
  105. package/build/upgrade-cli.js +55 -1
  106. package/build/version.generated.js +3 -3
  107. package/build/worker-guard-hook-bin.js +3 -2
  108. package/build/worker-guard-hook-main.js +1 -1
  109. package/build/worktree-core.js +15 -2
  110. package/docs/EPIC-IMPLEMENTER-INSTALL.md +30 -12
  111. package/docs/EPIC-IMPLEMENTER.md +68 -2
  112. package/package.json +3 -3
  113. package/pipelines/greenfield-setup.json +4 -128
  114. package/pipelines/implement-ticket.json +3 -2
@@ -0,0 +1,39 @@
1
+ /**
2
+ * The one TypeScript spelling of the Epic Implementer workaround folder (BAPI-1399).
3
+ *
4
+ * A worker that defers a MINOR requirement behind a guarded workaround commits a
5
+ * record at `.bridge/epic-workarounds/<TICKET>.md` on its ticket branch. While
6
+ * that folder exists on the epic head, the integration pull request stays in
7
+ * draft: the reconciler's coordinator withholds `mark_ready`
8
+ * (`api/library/epic_implementer/integration_completion.py`), and the local actor
9
+ * re-checks the folder before it takes a pull request out of draft
10
+ * (`executor/integration-job.ts`). Nothing about a workaround is stored in the
11
+ * database or in run state; the hold is worked out from the folder at the exact
12
+ * epic head every time.
13
+ *
14
+ * Python declares the same value once, beside its directory-listing helper in
15
+ * `api/library/github/github_webhooks_lib.py`. The static test
16
+ * `tests/pytest/static/test_epic_workarounds_path_pin.py` fails if any consumer
17
+ * spells the path differently or drops it.
18
+ */
19
+ import { TICKET_KEY_PATTERN } from "./ticket-key-utils.js";
20
+ /** The workaround folder, repository-relative, with its trailing slash. */
21
+ export const EPIC_WORKAROUNDS_DIR = ".bridge/epic-workarounds/";
22
+ /** The folder without its trailing slash: the form a contents API path takes. */
23
+ export const EPIC_WORKAROUNDS_DIR_PATH = EPIC_WORKAROUNDS_DIR.slice(0, -1);
24
+ /** A ticket key: the canonical grammar the Epic Implementer accepts everywhere else. */
25
+ const TICKET_KEY_RE = TICKET_KEY_PATTERN;
26
+ /** Upper bound on a ticket key, so a pasted blob is never a path segment. */
27
+ const MAX_TICKET_KEY_CHARS = 64;
28
+ /**
29
+ * The record path for one ticket, `.bridge/epic-workarounds/<TICKET>.md`, or
30
+ * `null` when the key is not a valid ticket key. A key is never sanitized into a
31
+ * path: anything that is not exactly a key is refused.
32
+ */
33
+ export function epicWorkaroundRecordPath(ticketKey) {
34
+ if (typeof ticketKey !== "string")
35
+ return null;
36
+ if (ticketKey.length > MAX_TICKET_KEY_CHARS || !TICKET_KEY_RE.test(ticketKey))
37
+ return null;
38
+ return `${EPIC_WORKAROUNDS_DIR}${ticketKey}.md`;
39
+ }
@@ -22,6 +22,35 @@ import { startExecutorSleepAssertion } from "./platform.js";
22
22
  import { runExecutor } from "./runner.js";
23
23
  import { runExecutorWatchCli } from "./watch-cli.js";
24
24
  import { removeRunExecutorRecordIfOwned } from "../executor-launch.js";
25
+ /** Register an optional parent watch without making the timer a process owner. */
26
+ export function registerExecutorParentWatch(pid, onGone, seams = {
27
+ setInterval: (callback, ms) => setInterval(callback, ms),
28
+ clearInterval: (handle) => clearInterval(handle),
29
+ signalZero: (parentPid) => process.kill(parentPid, 0),
30
+ }) {
31
+ let disposed = false;
32
+ let notified = false;
33
+ const timer = seams.setInterval(() => {
34
+ if (disposed || notified)
35
+ return;
36
+ try {
37
+ seams.signalZero(pid);
38
+ }
39
+ catch (error) {
40
+ if (error.code === "ESRCH") {
41
+ notified = true;
42
+ onGone();
43
+ }
44
+ }
45
+ }, 5_000);
46
+ timer.unref?.();
47
+ return () => {
48
+ if (disposed)
49
+ return;
50
+ disposed = true;
51
+ seams.clearInterval(timer);
52
+ };
53
+ }
25
54
  /** Fixed executor timing/behavior defaults. */
26
55
  const DEFAULT_POLL_INTERVAL_MS = 15_000;
27
56
  const DEFAULT_HEARTBEAT_INTERVAL_MS = 60_000;
@@ -422,6 +451,7 @@ export async function runExecutorCli(argv, overrides = {}) {
422
451
  const createIntegrationClient = overrides.createIntegrationClient ?? createIntegrationActionHttpClient;
423
452
  const runnerSeams = {
424
453
  onSignal: overrides.onSignal ?? defaultSignalRegistrar,
454
+ watchParent: registerExecutorParentWatch,
425
455
  // BAPI-1279 — on a terminal exit, delete the launcher record drive-epic
426
456
  // wrote for this run, but only if it still names THIS instance (its id, and
427
457
  // this process or the relay that started it). Advisory: a failure here
@@ -231,6 +231,10 @@ export const COMPLETION_CAUSE_VALUES = [
231
231
  "cleanup_unconfirmed",
232
232
  "verification_runner_unavailable",
233
233
  ];
234
+ export const END_CAUSE_VALUES = [
235
+ "worker_exit", "completion_window", "job_timeout", "executor_abort",
236
+ "executor_shutdown", "server_stop", "lease_lost",
237
+ ];
234
238
  export const MAX_TASKS = 64;
235
239
  export const MAX_DENIALS = 32;
236
240
  export const MAX_IDENTIFIER_CHARS = 128;
@@ -359,16 +363,25 @@ export function deriveCompletionCause(envelope) {
359
363
  if (blocking.length > 0 && envelope.evidence === "sufficient" && blocking.some((d) => d.result_after_denial)) {
360
364
  return "permission_stop";
361
365
  }
362
- if (envelope.cleanup.verdict !== "confirmed" || envelope.cleanup.survivors !== 0)
363
- return "cleanup_unconfirmed";
364
366
  const unresolved = envelope.tasks.some((t) => t.required && !TASK_RESOLVING_STATES.has(t.state));
365
- if (blocking.length > 0 ||
367
+ const requiredWorkIncomplete = blocking.length > 0 ||
366
368
  envelope.evidence !== "sufficient" ||
367
369
  envelope.execution !== "complete" ||
368
370
  unresolved ||
369
- envelope.verification === "incomplete") {
370
- return "required_work_incomplete";
371
+ envelope.verification === "incomplete";
372
+ // One exception to cleanup precedence: a completed sweep with no survivor and
373
+ // exactly the single reason `ownership_unverifiable` yields to unfinished
374
+ // work. Exact array checks, so a mixed list (e.g. with `not_run`) never does.
375
+ const narrowCleanupUncertainty = envelope.cleanup.verdict === "unconfirmed" &&
376
+ envelope.cleanup.survivors === 0 &&
377
+ envelope.cleanup.reasons.length === 1 &&
378
+ envelope.cleanup.reasons[0] === "ownership_unverifiable";
379
+ if ((envelope.cleanup.verdict !== "confirmed" || envelope.cleanup.survivors !== 0) &&
380
+ !(narrowCleanupUncertainty && requiredWorkIncomplete)) {
381
+ return "cleanup_unconfirmed";
371
382
  }
383
+ if (requiredWorkIncomplete)
384
+ return "required_work_incomplete";
372
385
  if (envelope.verification === "unavailable")
373
386
  return "verification_runner_unavailable";
374
387
  if (envelope.verification === "failed")
@@ -1,3 +1,4 @@
1
+ import { recordProvisionedAssets } from "../generated-assets-inventory.js";
1
2
  /**
2
3
  * Default (production) executor dependencies (BAPI-534, TDD §7).
3
4
  *
@@ -70,6 +71,7 @@ export function createDefaultExecutorDeps() {
70
71
  cwd: options?.cwd,
71
72
  env: options?.env,
72
73
  timeout: options?.timeoutMs,
74
+ signal: options?.signal,
73
75
  maxBuffer: MAX_COMMAND_BUFFER,
74
76
  encoding: "utf8",
75
77
  shell: false,
@@ -78,7 +80,7 @@ export function createDefaultExecutorDeps() {
78
80
  }
79
81
  catch (err) {
80
82
  const e = err;
81
- const exitCode = typeof e.code === "number" ? e.code : 1;
83
+ const exitCode = e.killed || e.code === "ABORT_ERR" ? 124 : typeof e.code === "number" ? e.code : 1;
82
84
  return { stdout: e.stdout ?? "", stderr: e.stderr ?? "", exitCode };
83
85
  }
84
86
  },
@@ -137,6 +139,7 @@ export function createDefaultExecutorDeps() {
137
139
  },
138
140
  };
139
141
  },
142
+ recordPackagedAssets: recordProvisionedAssets,
140
143
  readFile: (filePath) => readFile(filePath, "utf-8"),
141
144
  writeFile: (filePath, data) => writeFile(filePath, data, "utf-8"),
142
145
  appendFile: (filePath, data) => appendFile(filePath, data, "utf-8"),
@@ -0,0 +1,499 @@
1
+ /**
2
+ * Deterministic `gate_qualification` executor job (BAPI-1391).
3
+ *
4
+ * A repository owner confirms a gate profile: the local commands that must pass
5
+ * before an unattended Epic Implementer run may rely on it. Confirming is not
6
+ * proof the commands work. This job is the proof. It runs the confirmed
7
+ * commands ONCE, on a clean, detached checkout of the default branch resolved
8
+ * at claim time, and reports bounded FACTS so the server can record a
9
+ * qualification. Like `ci_rerun` it is run-less and deterministic: no worker,
10
+ * no LLM, no worktrunk worktree, and no fallback to any of those.
11
+ *
12
+ * Five properties are load-bearing:
13
+ *
14
+ * 1. **Validate before any effect.** Every payload rule in the cross-runtime
15
+ * contract — shape, bounds, every command digest and identity, the
16
+ * `command_digests` list, and the envelope digest — is recomputed here
17
+ * BEFORE any git or command runs. A job that cannot prove exactly which
18
+ * commands it was asked to run must not run anything and discover the
19
+ * problem afterwards. Any failure is a contract refusal.
20
+ *
21
+ * 2. **What is tested is what is reported.** The default branch is resolved
22
+ * from the remote at claim time, fetched, pinned to a full SHA, and checked
23
+ * out DETACHED into a fresh temporary directory outside the owner's working
24
+ * tree. The checkout must sit exactly at that SHA with a clean tracked tree,
25
+ * or the job reports an inability instead of a result. `tested_head_sha` is
26
+ * that SHA. The owner's own working tree and branch are never touched; the
27
+ * temporary checkout is always removed.
28
+ *
29
+ * 3. **Only the payload commands, in payload order.** They run through the
30
+ * shared required-verification core (argv, never a shell) with an EXPLICIT
31
+ * environment built from the executor's worker base environment plus
32
+ * `CI=1`. `process.env` is never handed to a command.
33
+ *
34
+ * 4. **Facts, never output.** The result carries an identity, a closed outcome
35
+ * and reason, and a duration per command. No name, argv, exit code, output,
36
+ * or path ever reaches the server, a `/fail` message, telemetry, or
37
+ * diagnostics. A command's local output tail is printed only on the owner's
38
+ * own terminal (see {@link GateQualificationDeps.showLocalOutput}).
39
+ *
40
+ * 5. **A failure is a result; an interruption is not.** A command that exits
41
+ * non-zero, times out, or cannot launch is a COMPLETED job with
42
+ * `outcome: "failed"` — that is exactly what qualification exists to learn.
43
+ * A command cut short by shutdown or lost ownership proved nothing about the
44
+ * gate, so it is reported as an inability (`GateQualificationInterrupted`)
45
+ * and never recorded as an unqualified gate.
46
+ */
47
+ import { createHash } from "node:crypto";
48
+ import { mkdtemp, rm } from "node:fs/promises";
49
+ import os from "node:os";
50
+ import path from "node:path";
51
+ import { validateBranchName } from "../base-ref.js";
52
+ import { buildExecutorBaseWorkerEnv } from "./env.js";
53
+ import { ARTIFACT_GIT_TIMEOUT_MS, OWNERSHIP_POLL_MS, canonicalJson, createSupervisedCommandLauncher, preCheckArtifactState, runRequiredVerificationCommands, toCompletionCheckFact, } from "./required-verification.js";
54
+ import { GATE_QUALIFICATION_CAPABILITY, } from "./types.js";
55
+ // ---------------------------------------------------------------------------
56
+ // Contract constants
57
+ // ---------------------------------------------------------------------------
58
+ export const GATE_QUALIFICATION_SCHEMA_VERSION = 1;
59
+ export const GATE_QUALIFICATION_MAX_COMMANDS = 16;
60
+ export const GATE_QUALIFICATION_MAX_NAME_CHARS = 120;
61
+ export const GATE_QUALIFICATION_MAX_ARGV = 32;
62
+ export const GATE_QUALIFICATION_MAX_ARG_CHARS = 1024;
63
+ export const GATE_QUALIFICATION_MAX_COMMAND_TIMEOUT_SECONDS = 1800;
64
+ /** Closed `/fail` error kinds for an inability to run (never carries output). */
65
+ export const GATE_QUALIFICATION_CONTRACT_ERROR = "GateQualificationContractError";
66
+ export const GATE_QUALIFICATION_CHECKOUT_UNAVAILABLE = "GateQualificationCheckoutUnavailable";
67
+ export const GATE_QUALIFICATION_INTERRUPTED = "GateQualificationInterrupted";
68
+ /** The whole-job timeout / no-outcome kind the job runner reports. */
69
+ export const GATE_QUALIFICATION_FAILED = "GateQualificationFailed";
70
+ /** Bound on the remote symref read. */
71
+ export const GATE_QUALIFICATION_LS_REMOTE_TIMEOUT_MS = 60_000;
72
+ /** Bound on fetching the default branch. */
73
+ export const GATE_QUALIFICATION_FETCH_TIMEOUT_MS = 5 * 60 * 1000;
74
+ /** Bound on materializing the detached checkout (large trees take a while). */
75
+ export const GATE_QUALIFICATION_WORKTREE_ADD_TIMEOUT_MS = 5 * 60 * 1000;
76
+ /** Bound on removing the detached checkout. */
77
+ export const GATE_QUALIFICATION_WORKTREE_REMOVE_TIMEOUT_MS = 2 * 60 * 1000;
78
+ /**
79
+ * Time held back from the job timeout for checkout teardown and the terminal
80
+ * report, so the commands' reservation ends before the supervisor's timeout
81
+ * fires and the job can still report a completed result.
82
+ */
83
+ export const GATE_QUALIFICATION_DEADLINE_RESERVE_MS = 60_000;
84
+ const HEX64_RE = /^[0-9a-f]{64}$/;
85
+ const FULL_SHA_RE = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
86
+ const SYMREF_HEAD_RE = /^ref: refs\/heads\/(\S+)\tHEAD$/m;
87
+ /** Fixed `/fail` messages, one per inability. */
88
+ const MESSAGES = {
89
+ contract: "gate_qualification payload failed contract validation",
90
+ capability: "gate_qualification job does not require the gate qualification capability",
91
+ repo: "gate_qualification payload names a different repository than the job",
92
+ defaultBranch: "default branch could not be resolved",
93
+ fetch: "default branch could not be fetched",
94
+ revParse: "default branch commit could not be resolved",
95
+ checkout: "detached checkout could not be created",
96
+ notClean: "detached checkout is not clean at the resolved commit",
97
+ interrupted: "gate qualification was interrupted before every command finished",
98
+ };
99
+ // ---------------------------------------------------------------------------
100
+ // Payload validation
101
+ // ---------------------------------------------------------------------------
102
+ function sha256Hex(text) {
103
+ return createHash("sha256").update(text, "utf8").digest("hex");
104
+ }
105
+ /** Python's `f"{position:02d}"` for a non-negative integer. */
106
+ function pad2(position) {
107
+ return position < 10 ? `0${position}` : String(position);
108
+ }
109
+ /** Code-point length, matching Python's `len()`. */
110
+ function codePointLength(text) {
111
+ return [...text].length;
112
+ }
113
+ function isSafePositiveInt(value) {
114
+ return typeof value === "number" && Number.isSafeInteger(value) && value > 0;
115
+ }
116
+ function isPlainObject(value) {
117
+ return typeof value === "object" && value !== null && !Array.isArray(value);
118
+ }
119
+ /** The command digest the server computed: sha256 of the canonical declaration. */
120
+ export function gateCommandDigest(command) {
121
+ return sha256Hex(canonicalJson({ name: command.name, argv: [...command.argv], timeout_seconds: command.timeout_seconds }));
122
+ }
123
+ /** The command identity the server derived from its position and digest. */
124
+ export function gateCommandIdentity(position, digest) {
125
+ return `cmd-${pad2(position)}-${digest.slice(0, 16)}`;
126
+ }
127
+ function parseCommand(raw, index) {
128
+ if (!isPlainObject(raw))
129
+ return null;
130
+ const { identity, position, name, argv, timeout_seconds: timeoutSeconds, digest } = raw;
131
+ if (position !== index)
132
+ return null;
133
+ if (typeof name !== "string" || name.trim().length === 0)
134
+ return null;
135
+ if (codePointLength(name) > GATE_QUALIFICATION_MAX_NAME_CHARS)
136
+ return null;
137
+ if (!Array.isArray(argv) || argv.length < 1 || argv.length > GATE_QUALIFICATION_MAX_ARGV)
138
+ return null;
139
+ for (const arg of argv) {
140
+ if (typeof arg !== "string" || arg.trim().length === 0)
141
+ return null;
142
+ if (codePointLength(arg) > GATE_QUALIFICATION_MAX_ARG_CHARS)
143
+ return null;
144
+ if (arg.includes("\u0000"))
145
+ return null;
146
+ }
147
+ if (typeof timeoutSeconds !== "number" ||
148
+ !Number.isInteger(timeoutSeconds) ||
149
+ timeoutSeconds < 1 ||
150
+ timeoutSeconds > GATE_QUALIFICATION_MAX_COMMAND_TIMEOUT_SECONDS) {
151
+ return null;
152
+ }
153
+ if (typeof digest !== "string" || !HEX64_RE.test(digest))
154
+ return null;
155
+ const typedArgv = argv;
156
+ if (gateCommandDigest({ name, argv: typedArgv, timeout_seconds: timeoutSeconds }) !== digest)
157
+ return null;
158
+ if (typeof identity !== "string" || identity !== gateCommandIdentity(index, digest))
159
+ return null;
160
+ return { identity, position: index, name, argv: [...typedArgv], timeoutSeconds, digest };
161
+ }
162
+ /**
163
+ * Validate `job.payload.gate_qualification` against the cross-runtime contract,
164
+ * SYNCHRONOUSLY and before any git or command effect.
165
+ *
166
+ * Also checks the claimed row: it must require the gate qualification
167
+ * capability when the server reported its requirements (an older shape that
168
+ * carries none is tolerated, because the server's capability filter already
169
+ * decided who could claim it), and the payload must name the job's repository.
170
+ * Fails closed with a fixed message on the first broken rule.
171
+ */
172
+ export function parseGateQualificationPayload(job) {
173
+ const refuse = (message) => ({
174
+ ok: false,
175
+ failure: { errorKind: GATE_QUALIFICATION_CONTRACT_ERROR, message },
176
+ });
177
+ if (Array.isArray(job.required_capabilities) && !job.required_capabilities.includes(GATE_QUALIFICATION_CAPABILITY)) {
178
+ return refuse(MESSAGES.capability);
179
+ }
180
+ const outer = job.payload;
181
+ if (!isPlainObject(outer))
182
+ return refuse(MESSAGES.contract);
183
+ const envelope = outer.gate_qualification;
184
+ if (!isPlainObject(envelope))
185
+ return refuse(MESSAGES.contract);
186
+ if (envelope.schema_version !== GATE_QUALIFICATION_SCHEMA_VERSION)
187
+ return refuse(MESSAGES.contract);
188
+ if (!isSafePositiveInt(envelope.revision))
189
+ return refuse(MESSAGES.contract);
190
+ if (typeof envelope.repo_name !== "string")
191
+ return refuse(MESSAGES.contract);
192
+ if (envelope.repo_name !== job.repo_name)
193
+ return refuse(MESSAGES.repo);
194
+ const rawCommands = envelope.commands;
195
+ if (!Array.isArray(rawCommands) ||
196
+ rawCommands.length < 1 ||
197
+ rawCommands.length > GATE_QUALIFICATION_MAX_COMMANDS) {
198
+ return refuse(MESSAGES.contract);
199
+ }
200
+ const commands = [];
201
+ for (let index = 0; index < rawCommands.length; index += 1) {
202
+ const command = parseCommand(rawCommands[index], index);
203
+ if (command === null)
204
+ return refuse(MESSAGES.contract);
205
+ commands.push(command);
206
+ }
207
+ const digests = envelope.command_digests;
208
+ if (!Array.isArray(digests) ||
209
+ digests.length !== commands.length ||
210
+ digests.some((value, index) => value !== commands[index].digest)) {
211
+ return refuse(MESSAGES.contract);
212
+ }
213
+ // The envelope digest covers the object exactly as received, minus its own
214
+ // digest key, so any field the server signed is checked byte for byte.
215
+ const { digest, ...signed } = envelope;
216
+ if (typeof digest !== "string" || !HEX64_RE.test(digest))
217
+ return refuse(MESSAGES.contract);
218
+ if (sha256Hex(canonicalJson(signed)) !== digest)
219
+ return refuse(MESSAGES.contract);
220
+ return {
221
+ ok: true,
222
+ payload: { repoName: envelope.repo_name, revision: envelope.revision, commands, digest },
223
+ };
224
+ }
225
+ // ---------------------------------------------------------------------------
226
+ // Default branch and the detached checkout
227
+ // ---------------------------------------------------------------------------
228
+ /** The branch named by `git ls-remote --symref origin HEAD`, or null when none is reported. */
229
+ export function parseSymrefHead(stdout) {
230
+ const match = SYMREF_HEAD_RE.exec(stdout);
231
+ return match ? match[1] : null;
232
+ }
233
+ /**
234
+ * Read full HEAD and the tracked tree/index of the checkout.
235
+ *
236
+ * The same two reads as `observeFinalArtifact`, but accepting a SHA-256
237
+ * object name as well: the contract allows a 64-hex `tested_head_sha`, and the
238
+ * shared observer's 40-hex rule would report such a checkout as unreadable.
239
+ */
240
+ async function observeCheckout(runCommand, dir) {
241
+ let head = null;
242
+ let trackedStatus = null;
243
+ try {
244
+ const r = await runCommand("git", ["rev-parse", "--verify", "HEAD"], {
245
+ cwd: dir,
246
+ timeoutMs: ARTIFACT_GIT_TIMEOUT_MS,
247
+ });
248
+ const value = r.stdout.trim();
249
+ if (r.exitCode === 0 && FULL_SHA_RE.test(value))
250
+ head = value;
251
+ }
252
+ catch {
253
+ head = null;
254
+ }
255
+ try {
256
+ const r = await runCommand("git", ["status", "--porcelain=v1", "--untracked-files=no"], {
257
+ cwd: dir,
258
+ timeoutMs: ARTIFACT_GIT_TIMEOUT_MS,
259
+ });
260
+ if (r.exitCode === 0)
261
+ trackedStatus = r.stdout.trim();
262
+ }
263
+ catch {
264
+ trackedStatus = null;
265
+ }
266
+ return { head, trackedStatus };
267
+ }
268
+ /** Run git in the owner's repository root, mapping a thrown runner to a failed result. */
269
+ async function git(deps, args, timeoutMs) {
270
+ try {
271
+ const r = await deps.runCommand("git", args, { cwd: deps.repoRoot, timeoutMs });
272
+ return { ok: r.exitCode === 0, stdout: r.stdout };
273
+ }
274
+ catch {
275
+ return { ok: false, stdout: "" };
276
+ }
277
+ }
278
+ /** Best-effort removal of the detached checkout. Never throws. */
279
+ async function removeCheckout(deps, dir) {
280
+ await git(deps, ["worktree", "remove", "--force", dir], GATE_QUALIFICATION_WORKTREE_REMOVE_TIMEOUT_MS);
281
+ try {
282
+ await deps.removeDir(dir);
283
+ }
284
+ catch {
285
+ // Best effort: a leftover temporary directory costs disk, never correctness.
286
+ }
287
+ }
288
+ // ---------------------------------------------------------------------------
289
+ // The core
290
+ // ---------------------------------------------------------------------------
291
+ function toResultFact(fact) {
292
+ // The shared server-bound view, minus `position`: the result envelope keys
293
+ // each fact by identity only.
294
+ const bounded = toCompletionCheckFact(fact);
295
+ return {
296
+ identity: bounded.identity,
297
+ outcome: bounded.outcome,
298
+ reason: bounded.reason,
299
+ duration_ms: bounded.duration_ms,
300
+ };
301
+ }
302
+ /** A fact that says the run was cut short by shutdown or lost ownership. */
303
+ function isInterruptionFact(fact) {
304
+ return (fact.outcome === "cancelled" ||
305
+ fact.reason === "executor_cancelled" ||
306
+ fact.reason === "ownership_lost");
307
+ }
308
+ /**
309
+ * Run one `gate_qualification` job.
310
+ *
311
+ * Returns the bounded result envelope, or a fixed-text inability. Never throws
312
+ * for a git, process, or payload condition, and never returns command output.
313
+ */
314
+ export async function runGateQualificationJob(job, input, deps) {
315
+ const startedAt = deps.now();
316
+ const inability = (errorKind, message) => {
317
+ deps.errorLog(`[gate_qualification] ${message}`);
318
+ return { ok: false, failure: { errorKind, message } };
319
+ };
320
+ // 1. The contract, before any effect.
321
+ const parsed = parseGateQualificationPayload(job);
322
+ if (!parsed.ok)
323
+ return inability(parsed.failure.errorKind, parsed.failure.message);
324
+ const payload = parsed.payload;
325
+ // 2. The default branch as the remote names it NOW, at claim time. A remote
326
+ // that reports no symref (some hosts omit it) falls back to the executor's
327
+ // configured base branch; a remote that cannot be read is an inability.
328
+ const lsRemote = await git(deps, ["ls-remote", "--symref", "origin", "HEAD"], GATE_QUALIFICATION_LS_REMOTE_TIMEOUT_MS);
329
+ if (!lsRemote.ok)
330
+ return inability(GATE_QUALIFICATION_CHECKOUT_UNAVAILABLE, MESSAGES.defaultBranch);
331
+ const branch = parseSymrefHead(lsRemote.stdout) ?? deps.fallbackBaseBranch;
332
+ if (validateBranchName(branch) !== null) {
333
+ return inability(GATE_QUALIFICATION_CHECKOUT_UNAVAILABLE, MESSAGES.defaultBranch);
334
+ }
335
+ const remoteRef = `refs/remotes/origin/${branch}`;
336
+ const fetched = await git(deps, ["fetch", "--no-tags", "origin", `+refs/heads/${branch}:${remoteRef}`], GATE_QUALIFICATION_FETCH_TIMEOUT_MS);
337
+ if (!fetched.ok)
338
+ return inability(GATE_QUALIFICATION_CHECKOUT_UNAVAILABLE, MESSAGES.fetch);
339
+ const revParse = await git(deps, ["rev-parse", "--verify", `${remoteRef}^{commit}`], ARTIFACT_GIT_TIMEOUT_MS);
340
+ const sha = revParse.stdout.trim();
341
+ if (!revParse.ok || !FULL_SHA_RE.test(sha)) {
342
+ return inability(GATE_QUALIFICATION_CHECKOUT_UNAVAILABLE, MESSAGES.revParse);
343
+ }
344
+ if (input.signal.aborted)
345
+ return inability(GATE_QUALIFICATION_INTERRUPTED, MESSAGES.interrupted);
346
+ // 3. A DETACHED checkout in a fresh temporary directory, outside the owner's
347
+ // working tree. Removed in `finally` whatever happens next.
348
+ let dir;
349
+ try {
350
+ dir = await deps.makeTempDir();
351
+ }
352
+ catch {
353
+ return inability(GATE_QUALIFICATION_CHECKOUT_UNAVAILABLE, MESSAGES.checkout);
354
+ }
355
+ try {
356
+ const added = await git(deps, ["worktree", "add", "--detach", dir, sha], GATE_QUALIFICATION_WORKTREE_ADD_TIMEOUT_MS);
357
+ if (!added.ok)
358
+ return inability(GATE_QUALIFICATION_CHECKOUT_UNAVAILABLE, MESSAGES.checkout);
359
+ const before = await observeCheckout(deps.runCommand, dir);
360
+ const preState = preCheckArtifactState(before, sha);
361
+ if (preState !== null)
362
+ return inability(GATE_QUALIFICATION_CHECKOUT_UNAVAILABLE, MESSAGES.notClean);
363
+ // 4. Only the payload commands, in payload order. The reservation ends a
364
+ // little before the job timeout so a slow last command still leaves time to
365
+ // tear the checkout down and report a completed result.
366
+ const timeoutMs = input.timeoutSeconds * 1000;
367
+ const deadlineAtMs = startedAt + Math.max(0, timeoutMs - GATE_QUALIFICATION_DEADLINE_RESERVE_MS);
368
+ const ownershipHeld = deps.ownershipHeld ?? (() => true);
369
+ // Ownership is re-checked WHILE a command runs, not only between commands,
370
+ // exactly as the covered lane does: a lost claim terminates the running
371
+ // command. The supervisor's own abort (timeout, stop) is chained in too.
372
+ const commandAbort = new AbortController();
373
+ const onOuterAbort = () => commandAbort.abort();
374
+ if (input.signal.aborted)
375
+ commandAbort.abort();
376
+ else
377
+ input.signal.addEventListener("abort", onOuterAbort, { once: true });
378
+ let watchTimer;
379
+ const watchOwnership = () => {
380
+ watchTimer = deps.setTimer(() => {
381
+ watchTimer = undefined;
382
+ if (!ownershipHeld())
383
+ commandAbort.abort();
384
+ else
385
+ watchOwnership();
386
+ }, OWNERSHIP_POLL_MS);
387
+ };
388
+ watchOwnership();
389
+ const commands = payload.commands.map((command) => ({
390
+ identity: command.identity,
391
+ position: command.position,
392
+ name: command.name,
393
+ argv: command.argv,
394
+ timeoutSeconds: command.timeoutSeconds,
395
+ }));
396
+ let facts;
397
+ try {
398
+ const run = await runRequiredVerificationCommands({
399
+ commands,
400
+ worktreePath: dir,
401
+ env: deps.commandEnv,
402
+ launch: deps.launch,
403
+ now: deps.now,
404
+ deadlineAtMs,
405
+ signal: commandAbort.signal,
406
+ ownershipHeld,
407
+ onInterruption: "stop",
408
+ });
409
+ facts = run.facts;
410
+ }
411
+ finally {
412
+ if (watchTimer !== undefined)
413
+ deps.clearTimer(watchTimer);
414
+ input.signal.removeEventListener("abort", onOuterAbort);
415
+ }
416
+ // Names and closed tokens only: no argv, no output, no path.
417
+ for (const fact of facts) {
418
+ deps.log(`[gate_qualification] command ${fact.position} (${fact.name.slice(0, 60)}): ${fact.outcome}${fact.reason ? ` (${fact.reason})` : ""}`);
419
+ if (deps.showLocalOutput && fact.outcome !== "exited_zero" && fact.output.trim().length > 0) {
420
+ deps.errorLog(`[gate_qualification] output tail for ${fact.name.slice(0, 60)}:\n${fact.output}`);
421
+ }
422
+ }
423
+ // 5. An interruption proved nothing about the gate: an inability, never a
424
+ // completed "failed" that the server would record as unqualified.
425
+ if (facts.some(isInterruptionFact)) {
426
+ return inability(GATE_QUALIFICATION_INTERRUPTED, MESSAGES.interrupted);
427
+ }
428
+ const resultFacts = facts.map(toResultFact);
429
+ const outcome = resultFacts.every((fact) => fact.outcome === "exited_zero") ? "passed" : "failed";
430
+ return {
431
+ ok: true,
432
+ result: {
433
+ schema_version: 1,
434
+ revision: payload.revision,
435
+ tested_head_sha: sha,
436
+ outcome,
437
+ commands: resultFacts,
438
+ },
439
+ };
440
+ }
441
+ finally {
442
+ await removeCheckout(deps, dir);
443
+ }
444
+ }
445
+ // ---------------------------------------------------------------------------
446
+ // Production wiring
447
+ // ---------------------------------------------------------------------------
448
+ /**
449
+ * The explicit command environment: the executor's worker base environment
450
+ * (allowlisted keys, parent credentials stripped, sentinel database values,
451
+ * `BRIDGE_SKIP_PREPUSH=1`) plus `CI=1`, so test tooling runs non-interactively.
452
+ */
453
+ export function buildGateQualificationEnv(parentEnv) {
454
+ return { ...buildExecutorBaseWorkerEnv(parentEnv), CI: "1" };
455
+ }
456
+ /**
457
+ * Production dependencies built from the executor's own boundary.
458
+ *
459
+ * Git runs in `deps.cwd` — the owner's repository checkout every other
460
+ * executor job works from. Commands launch through the shared supervised
461
+ * launcher and register with the executor's live-process registry, so executor
462
+ * shutdown reaches them exactly as it reaches a worker.
463
+ */
464
+ export function createProductionGateQualificationDeps(input) {
465
+ const { deps, options } = input;
466
+ const makeTempDir = async () => {
467
+ const root = deps.tmpdir ? deps.tmpdir() : os.tmpdir();
468
+ const prefix = path.join(root, "bridge-gate-qualification-");
469
+ return deps.mkdtemp ? deps.mkdtemp(prefix) : mkdtemp(prefix);
470
+ };
471
+ const removeDir = async (dirPath) => {
472
+ if (deps.rmRecursive)
473
+ await deps.rmRecursive(dirPath);
474
+ else
475
+ await rm(dirPath, { recursive: true, force: true });
476
+ };
477
+ return {
478
+ runCommand: deps.runCommand,
479
+ launch: createSupervisedCommandLauncher({
480
+ deps,
481
+ ...(input.registerLive ? { registerLive: input.registerLive } : {}),
482
+ }),
483
+ commandEnv: buildGateQualificationEnv(deps.env),
484
+ repoRoot: deps.cwd,
485
+ fallbackBaseBranch: options.baseBranch,
486
+ makeTempDir,
487
+ removeDir,
488
+ now: deps.now,
489
+ setTimer: deps.setTimer,
490
+ clearTimer: deps.clearTimer,
491
+ log: deps.log,
492
+ errorLog: deps.errorLog,
493
+ // A job-type filter is set only by the short-lived owner-terminal
494
+ // qualifier; a durable service executor never sets one. That executor's
495
+ // stderr is the owner's own terminal, so it alone may show output tails.
496
+ showLocalOutput: options.jobTypes !== undefined && options.jobTypes.length > 0,
497
+ ownershipHeld: input.ownershipHeld,
498
+ };
499
+ }