@bridge_gpt/mcp-server 0.2.66 → 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.
- package/README.md +421 -83
- package/build/agent-capabilities/probes.js +15 -5
- package/build/agent-launchers/claude-executor-adapter.js +70 -16
- package/build/agent-launchers/executor-adapter.js +7 -4
- package/build/agents.generated.js +22 -2
- package/build/bitbucket-workspace-remote.js +136 -0
- package/build/bootstrap-project-identity.js +24 -0
- package/build/bounded-wait.js +67 -10
- package/build/bridge-permissions.js +829 -0
- package/build/claude-settings.js +18 -0
- package/build/cli-help.js +21 -0
- package/build/command-assets-doctor.js +24 -70
- package/build/command-provisioning.js +29 -5
- package/build/commands.generated.js +12 -12
- package/build/conductor/bridge-api-client.js +857 -1
- package/build/conductor/bring-up-facts.js +1 -1
- package/build/conductor/command-drift-production.js +92 -0
- package/build/conductor/command-drift.js +89 -0
- package/build/conductor/doctor.js +72 -29
- package/build/conductor/epic-branch-ci-inspection.js +1375 -0
- package/build/conductor/epic-implementer-cli.js +98 -28
- package/build/conductor/errors.js +4 -3
- package/build/conductor/gate-discovery.js +890 -0
- package/build/conductor/guided-repair.js +6 -2
- package/build/conductor/hook-runtime-qualification.js +1 -1
- package/build/conductor/install-doctor.js +15 -3
- package/build/conductor/json-output.js +63 -0
- package/build/conductor/merge-execution-contract.js +27 -8
- package/build/conductor/merge-execution-eligibility.js +3 -2
- package/build/conductor/pr-ci-producer.js +1 -1
- package/build/conductor/readiness-cli.js +113 -12
- package/build/conductor/readiness-sections.js +410 -9
- package/build/conductor/readiness.js +388 -33
- package/build/conductor/recovery-cli.js +310 -12
- package/build/conductor/recovery-operations.js +167 -3
- package/build/conductor/repair-contract.js +54 -3
- package/build/conductor/repository-workflow-remedy.js +125 -0
- package/build/conductor/tools.js +2 -2
- package/build/conductor-bundle-artifacts.js +8 -0
- package/build/connect-bitbucket-api.js +41 -6
- package/build/connect-bitbucket.js +106 -33
- package/build/credential-store.js +207 -47
- package/build/docs.generated.js +62 -3
- package/build/doctor.js +92 -1
- package/build/drive-epic-key.js +6 -5
- package/build/drive-epic.js +129 -61
- package/build/epic-coherence-input.js +2 -1
- package/build/epic-coherence-input.test-fixtures.js +4 -3
- package/build/epic-implementer-bin.js +1379 -28
- package/build/epic-implementer-bundle-cli.js +2 -2
- package/build/epic-workarounds-path.js +39 -0
- package/build/executor/cli.js +33 -2
- package/build/executor/completion-contract.js +66 -10
- package/build/executor/completion-supervision.js +4 -0
- package/build/executor/deps.js +4 -1
- package/build/executor/env.js +19 -0
- package/build/executor/gate-qualification-job.js +499 -0
- package/build/executor/guarded-fast-forward-push.js +129 -0
- package/build/executor/heartbeat.js +1 -1
- package/build/executor/http-client.js +20 -0
- package/build/executor/integration-job.js +122 -8
- package/build/executor/job-errors.js +33 -1
- package/build/executor/job-runner.js +813 -87
- package/build/executor/likely-tests.js +290 -0
- package/build/executor/live-worker-registry.js +1 -1
- package/build/executor/observation.js +37 -3
- package/build/executor/packaged-recipe.js +106 -0
- package/build/executor/permissions.js +115 -28
- package/build/executor/platform.js +1 -1
- package/build/executor/preflight.js +112 -26
- package/build/executor/process-containment.js +36 -12
- package/build/executor/process.js +15 -7
- package/build/executor/prompt-spec.js +33 -1
- package/build/executor/recently-failed-tests-file.js +71 -0
- package/build/executor/reconcile-overlap-job.js +41 -23
- package/build/executor/required-verification.js +1 -1
- package/build/executor/required-work-ledger.js +111 -18
- package/build/executor/resume-pre-spawn.js +2 -0
- package/build/executor/retirement.js +69 -0
- package/build/executor/runner.js +122 -13
- package/build/executor/service-unit.js +7 -1
- package/build/executor/test-clock.js +15 -0
- package/build/executor/types.js +32 -0
- package/build/executor/viewer-tabs.js +58 -8
- package/build/executor/worker-command.js +23 -7
- package/build/executor/worker-diagnostics.js +37 -0
- package/build/executor/worker-finalization.js +113 -34
- package/build/executor/worker-guard-hook.js +316 -58
- package/build/executor/worker-log.js +4 -3
- package/build/executor/worker-verification-guidance.js +37 -0
- package/build/executor/worktree.js +166 -8
- package/build/first-build-advisory.js +97 -0
- package/build/generated-assets-advisory.js +14 -0
- package/build/generated-assets-catalog.js +66 -0
- package/build/generated-assets-context.js +46 -0
- package/build/generated-assets-dependencies.js +26 -0
- package/build/generated-assets-doctor.js +50 -0
- package/build/generated-assets-health.js +65 -0
- package/build/generated-assets-host.js +17 -0
- package/build/generated-assets-inventory.js +17 -0
- package/build/generated-assets-paths.js +60 -0
- package/build/generated-assets-publisher.js +133 -0
- package/build/generated-assets-remedy.js +25 -0
- package/build/generated-assets-repair.js +72 -0
- package/build/generated-assets-session.js +71 -0
- package/build/generated-assets-test-support.js +35 -0
- package/build/index.js +1224 -1054
- package/build/init.js +54 -177
- package/build/install-bridge-conductor-gate.js +1372 -0
- package/build/install-bridge-conductor.js +296 -41
- package/build/install-bridge.js +547 -40
- package/build/mcp-install-state.js +50 -4
- package/build/merge-workflow-install.js +3 -4
- package/build/phase-context-git-probe.js +68 -0
- package/build/phase-result-artifacts.js +173 -5
- package/build/pipeline-utils.js +6 -2
- package/build/pipelines.generated.js +34 -160
- package/build/plan-execution-ledger.js +2 -32
- package/build/plane/alembic-head.js +12 -3
- package/build/plane/base-worktree-guard.js +147 -0
- package/build/plane/cli.js +217 -17
- package/build/plane/code-snapshot.js +468 -0
- package/build/plane/defaults.js +48 -3
- package/build/plane/endpoint.js +41 -0
- package/build/plane/manifest.js +136 -2
- package/build/plane/member-roster.js +94 -19
- package/build/plane/preflight.js +83 -2
- package/build/plane/process-cwd.js +67 -0
- package/build/plane/restart.js +542 -0
- package/build/plane/shutdown.js +31 -0
- package/build/plane/status.js +143 -8
- package/build/plane/supervisor.js +586 -179
- package/build/plane/types.js +54 -2
- package/build/printed-remedy-commands.js +52 -0
- package/build/readiness-check.js +1 -1
- package/build/readme.generated.js +1 -1
- package/build/repair-assets.js +93 -0
- package/build/review-generation.js +22 -0
- package/build/run-unit-tests-launcher.js +130 -49
- package/build/secret-redaction.js +33 -0
- package/build/setup-epic.js +152 -22
- package/build/sfcc/config.js +9 -0
- package/build/sfcc/credentials.js +100 -38
- package/build/sfcc/hostname.js +67 -0
- package/build/sfcc/permissions.js +17 -13
- package/build/sfcc/register.js +1 -1
- package/build/sfcc/tool-wrapper.js +39 -43
- package/build/sfcc/write-grants.js +17 -11
- package/build/sfcc/write-result.js +4 -3
- package/build/sfcc/writes-site-preference.js +20 -8
- package/build/start-tickets-prereqs.js +70 -16
- package/build/start-tickets.js +208 -36
- package/build/tool-error-envelope.js +61 -0
- package/build/tool-surface-gating.js +36 -0
- package/build/upgrade-cli.js +55 -1
- package/build/version.generated.js +3 -3
- package/build/visual-diff.js +22 -1
- package/build/worker-guard-hook-bin.js +4 -6
- package/build/worker-guard-hook-main.js +47 -0
- package/build/worktree-core.js +145 -6
- package/docs/EPIC-IMPLEMENTER-INSTALL.md +867 -0
- package/docs/EPIC-IMPLEMENTER.md +728 -0
- package/docs/install/mcp-tool-integrations.md +24 -15
- package/docs/install/sfcc-integration.md +12 -4
- package/package.json +3 -3
- package/pipelines/greenfield-setup.json +4 -128
- package/pipelines/implement-ticket.json +3 -2
- package/pipelines/plan-epic.json +0 -5
- package/pipelines/review-ticket.json +1 -1
- package/docs/CONDUCTOR.md +0 -605
- package/pipelines/idea-to-pr.json +0 -49
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
export const PUSH_TIMEOUT_MS = 180_000;
|
|
2
|
+
export const PUSH_AUTH_FAILURE_RE = /authentication failed|could not read username|permission denied|terminal prompts disabled|invalid username or password|returned error: 403|requested url returned error: 401/i;
|
|
3
|
+
const PUSH_LEASE_FAILURE_RE = /stale info|\[rejected\]|non-fast-forward|fetch first/i;
|
|
4
|
+
const SHA = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i;
|
|
5
|
+
export function isSafePublicationBranch(branch) {
|
|
6
|
+
return branch.length > 0 && !branch.startsWith("-") && !branch.startsWith("/") &&
|
|
7
|
+
!branch.endsWith("/") && !branch.endsWith(".") && branch !== "@" &&
|
|
8
|
+
!/[\x00-\x20\x7f~^:?*\[\\+]/.test(branch) && !branch.includes("..") &&
|
|
9
|
+
!branch.includes("@{") && !branch.includes("//") &&
|
|
10
|
+
branch.split("/").every(part => !part.startsWith(".") && !part.endsWith(".lock"));
|
|
11
|
+
}
|
|
12
|
+
/** No command text, remote URL, or environment escapes this closed outcome. */
|
|
13
|
+
export async function guardedFastForwardPush(input) {
|
|
14
|
+
const refuse = (reason) => ({ status: "refused", reason });
|
|
15
|
+
if (!isSafePublicationBranch(input.branch) || !SHA.test(input.expectedOriginSha) ||
|
|
16
|
+
input.trustedExcludedShas?.some(sha => !SHA.test(sha)) ||
|
|
17
|
+
(input.candidateSha !== undefined && (!SHA.test(input.candidateSha) || input.candidateSha === input.expectedOriginSha)))
|
|
18
|
+
return refuse("invalid_input");
|
|
19
|
+
let timedOut = false;
|
|
20
|
+
const git = async (args) => {
|
|
21
|
+
const timeoutMs = Math.min(PUSH_TIMEOUT_MS, input.remainingMs());
|
|
22
|
+
if (timeoutMs <= 0 || (!input.mayMutate() || input.signal?.aborted)) {
|
|
23
|
+
timedOut = true;
|
|
24
|
+
return { exitCode: 124, stdout: "", stderr: "" };
|
|
25
|
+
}
|
|
26
|
+
return input.runCommand("git", args, { cwd: input.worktreePath, timeoutMs, signal: input.signal,
|
|
27
|
+
env: { ...input.env, GIT_TERMINAL_PROMPT: "0" } });
|
|
28
|
+
};
|
|
29
|
+
try {
|
|
30
|
+
if ((await git(["check-ref-format", "--branch", input.branch])).exitCode !== 0)
|
|
31
|
+
return refuse("invalid_input");
|
|
32
|
+
const branch = await git(["symbolic-ref", "--quiet", "--short", "HEAD"]);
|
|
33
|
+
if (branch.exitCode !== 0 || branch.stdout.trim() !== input.branch)
|
|
34
|
+
return refuse("branch_mismatch");
|
|
35
|
+
const head = await git(["rev-parse", "HEAD"]);
|
|
36
|
+
const candidate = head.stdout.trim().toLowerCase();
|
|
37
|
+
if (head.exitCode !== 0 || !SHA.test(candidate))
|
|
38
|
+
return refuse("ancestry_unknown");
|
|
39
|
+
if (candidate === input.expectedOriginSha.toLowerCase() ||
|
|
40
|
+
(input.candidateSha !== undefined && candidate !== input.candidateSha.toLowerCase()))
|
|
41
|
+
return refuse("invalid_input");
|
|
42
|
+
const ancestor = await git(["merge-base", "--is-ancestor", input.expectedOriginSha, "HEAD"]);
|
|
43
|
+
if (ancestor.exitCode === 1)
|
|
44
|
+
return refuse("not_fast_forward");
|
|
45
|
+
if (ancestor.exitCode !== 0)
|
|
46
|
+
return refuse("ancestry_unknown");
|
|
47
|
+
for (const sha of input.trustedExcludedShas ?? []) {
|
|
48
|
+
if ((await git(["cat-file", "-e", `${sha}^{commit}`])).exitCode !== 0 ||
|
|
49
|
+
(await git(["merge-base", "--is-ancestor", sha, "HEAD"])).exitCode !== 0)
|
|
50
|
+
return refuse("range_unreadable");
|
|
51
|
+
}
|
|
52
|
+
const range = await git(["rev-list", "HEAD", `^${input.expectedOriginSha}`, ...(input.trustedExcludedShas ?? []).map(sha => `^${sha}`)]);
|
|
53
|
+
const commits = range.stdout.trim().split(/\s+/);
|
|
54
|
+
if (range.exitCode !== 0 || commits.length === 0 || commits.some(sha => !SHA.test(sha)))
|
|
55
|
+
return refuse("range_unreadable");
|
|
56
|
+
const workflow = (paths) => paths.split("\0").some(path => path.startsWith(".github/workflows/"));
|
|
57
|
+
for (const commit of commits) {
|
|
58
|
+
const parentsResult = await git(["rev-list", "--parents", "-n", "1", commit]);
|
|
59
|
+
const ids = parentsResult.stdout.trim().split(/\s+/);
|
|
60
|
+
if (parentsResult.exitCode !== 0 || ids[0] !== commit || ids.some(sha => !SHA.test(sha)))
|
|
61
|
+
return refuse("range_unreadable");
|
|
62
|
+
const parents = ids.slice(1);
|
|
63
|
+
let paths;
|
|
64
|
+
if (parents.length > 1) {
|
|
65
|
+
if (parents.length !== 2)
|
|
66
|
+
return refuse("range_unreadable");
|
|
67
|
+
// Compare the candidate resolution to Git's automatic merge, rather than
|
|
68
|
+
// attributing already-published excluded parent changes to the worker.
|
|
69
|
+
const merge = await git(["merge-tree", "--write-tree", "-z", "--name-only", "--messages", parents[0], parents[1]]);
|
|
70
|
+
const tokens = merge.stdout.split("\0");
|
|
71
|
+
const tree = tokens[0]?.trim() ?? "";
|
|
72
|
+
if (![0, 1].includes(merge.exitCode) || !SHA.test(tree) || !merge.stdout.endsWith("\0"))
|
|
73
|
+
return refuse("range_unreadable");
|
|
74
|
+
const endPaths = tokens.indexOf("", 1);
|
|
75
|
+
if (endPaths < 0)
|
|
76
|
+
return refuse("range_unreadable");
|
|
77
|
+
const conflicts = tokens.slice(1, endPaths);
|
|
78
|
+
if (merge.exitCode === 1 && conflicts.length === 0)
|
|
79
|
+
return refuse("range_unreadable");
|
|
80
|
+
if (conflicts.some(path => path.startsWith(".github/workflows/")))
|
|
81
|
+
return refuse("workflow_files_in_range");
|
|
82
|
+
for (let at = endPaths + 1; at < tokens.length - 1;) {
|
|
83
|
+
if (tokens[at] === "")
|
|
84
|
+
break;
|
|
85
|
+
const count = Number(tokens[at]);
|
|
86
|
+
if (!Number.isInteger(count) || count < 1 || at + count + 2 >= tokens.length - 1)
|
|
87
|
+
return refuse("range_unreadable");
|
|
88
|
+
at += count + 3;
|
|
89
|
+
}
|
|
90
|
+
paths = await git(["diff-tree", "-r", "--no-commit-id", "--name-only", "--no-renames", "-z", tree, commit]);
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
paths = await git(["diff-tree", "--root", "-r", "--no-commit-id", "--name-only", "--no-renames", "-z", commit]);
|
|
94
|
+
}
|
|
95
|
+
if (paths.exitCode !== 0)
|
|
96
|
+
return refuse("range_unreadable");
|
|
97
|
+
if (workflow(paths.stdout))
|
|
98
|
+
return refuse("workflow_files_in_range");
|
|
99
|
+
}
|
|
100
|
+
// Re-check HEAD and ownership immediately before mutation.
|
|
101
|
+
const latest = await git(["rev-parse", "HEAD"]);
|
|
102
|
+
if (latest.exitCode !== 0 || latest.stdout.trim().toLowerCase() !== candidate)
|
|
103
|
+
return refuse("ancestry_unknown");
|
|
104
|
+
if ((!input.mayMutate() || input.signal?.aborted) || input.remainingMs() <= 0)
|
|
105
|
+
return refuse("timeout");
|
|
106
|
+
const destination = `refs/heads/${input.branch}`;
|
|
107
|
+
const push = await git(["push", "--no-verify", "--porcelain",
|
|
108
|
+
`--force-with-lease=${destination}:${input.expectedOriginSha}`, "origin", `HEAD:${destination}`]);
|
|
109
|
+
const text = `${push.stdout}\n${push.stderr}`;
|
|
110
|
+
if (timedOut || push.exitCode === 124 || /timed out|timeout/i.test(text))
|
|
111
|
+
return refuse("timeout");
|
|
112
|
+
if (PUSH_AUTH_FAILURE_RE.test(text))
|
|
113
|
+
return refuse("auth_failed");
|
|
114
|
+
if (PUSH_LEASE_FAILURE_RE.test(text))
|
|
115
|
+
return refuse("lease_rejected");
|
|
116
|
+
if (push.exitCode !== 0)
|
|
117
|
+
return refuse("remote_rejected");
|
|
118
|
+
const update = push.stdout.split("\n").some(line => {
|
|
119
|
+
const parts = line.split("\t");
|
|
120
|
+
return parts[0] === " " && parts[1] === `HEAD:${destination}`;
|
|
121
|
+
});
|
|
122
|
+
if (!update)
|
|
123
|
+
return refuse("lease_rejected");
|
|
124
|
+
return { status: "published", from_sha: input.expectedOriginSha.toLowerCase(), to_sha: candidate };
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
return refuse(timedOut || input.remainingMs() <= 0 ? "timeout" : "remote_rejected");
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
* interval. This is TELEMETRY ONLY (R14): the marker is never persisted, never
|
|
23
23
|
* reported as `last_commit_sha`, and never drives an executor decision — the
|
|
24
24
|
* reconciler remains the sole consumer and the sole transition authority
|
|
25
|
-
* (`docs/
|
|
25
|
+
* (`docs/epic-implementer/standing-rules.md`).
|
|
26
26
|
*
|
|
27
27
|
* - SERVER STOP (WS-B executor half). A heartbeat response carrying the
|
|
28
28
|
* server's stop request kills the owned worker like `stale_claim` does, but
|
|
@@ -76,6 +76,22 @@ export function normalizeRequiredCapabilities(value) {
|
|
|
76
76
|
return [];
|
|
77
77
|
return value.every((entry) => typeof entry === "string") ? [...value] : [];
|
|
78
78
|
}
|
|
79
|
+
const FULL_SHA_PATTERN = /^[0-9a-fA-F]{40}$/;
|
|
80
|
+
/**
|
|
81
|
+
* BAPI-1367 — normalize a claim response's `prior_attempt_head_sha`. Absent or
|
|
82
|
+
* explicit null reads as null silently (an older server, or no prior head). A
|
|
83
|
+
* present value that is not a 40-hex string also reads as null, and `onMalformed`
|
|
84
|
+
* is told once so the caller can warn. The raw value is never passed on, so it
|
|
85
|
+
* can never reach a log line.
|
|
86
|
+
*/
|
|
87
|
+
export function normalizePriorAttemptHeadSha(value, onMalformed) {
|
|
88
|
+
if (value === undefined || value === null)
|
|
89
|
+
return null;
|
|
90
|
+
if (typeof value === "string" && FULL_SHA_PATTERN.test(value))
|
|
91
|
+
return value;
|
|
92
|
+
onMalformed();
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
79
95
|
/** Bound a server-provided diagnostic snippet included in error strings. */
|
|
80
96
|
function boundedDetail(text) {
|
|
81
97
|
const trimmed = (text ?? "").trim();
|
|
@@ -271,6 +287,10 @@ export function createExecutorHttpClient(config) {
|
|
|
271
287
|
if ("required_capabilities" in job) {
|
|
272
288
|
job.required_capabilities = normalizeRequiredCapabilities(job.required_capabilities);
|
|
273
289
|
}
|
|
290
|
+
// BAPI-1367: a malformed prior head fails closed to null with one
|
|
291
|
+
// bounded warning naming only the job, never the raw value or a SHA.
|
|
292
|
+
job.prior_attempt_head_sha = normalizePriorAttemptHeadSha(job.prior_attempt_head_sha, () => (config.warn ?? ((message) => console.error(message)))(`executor: ignoring malformed prior attempt head in the claim for job ${String(job.id)}; ` +
|
|
293
|
+
`treating it as a first dispatch.`));
|
|
274
294
|
return { kind: "claimed", job, reconcilerLiveness };
|
|
275
295
|
}
|
|
276
296
|
catch {
|