@loucompanion/forge-bridge 0.1.3-dev.d46a8e73d03d → 0.1.3-dev.e7b62d8fdd33
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 +83 -0
- package/dist/bridge/bridge.base.d.ts +4 -1
- package/dist/bridge/bridge.service.d.ts +55 -3
- package/dist/bridge/bridge.service.js +834 -87
- package/dist/bridge/bridge.types.d.ts +89 -1
- package/dist/bridge/runner-host-capabilities.d.ts +15 -0
- package/dist/bridge/runner-host-capabilities.js +93 -0
- package/dist/cleanup/cleanup.service.d.ts +14 -2
- package/dist/cleanup/cleanup.service.js +496 -3
- package/dist/cleanup/cleanup.types.d.ts +10 -0
- package/dist/cleanup/index.d.ts +1 -1
- package/dist/cleanup/index.js +1 -1
- package/dist/cleanup/internals.d.ts +2 -2
- package/dist/cleanup/internals.js +1 -1
- package/dist/cli/cli.help.js +37 -3
- package/dist/cli/cli.service.d.ts +33 -2
- package/dist/cli/cli.service.js +209 -27
- package/dist/cli/local-config.d.ts +5 -0
- package/dist/cli/local-config.js +45 -1
- package/dist/runtime/aspnet-runtime-prebuild.d.ts +36 -0
- package/dist/runtime/aspnet-runtime-prebuild.js +340 -0
- package/dist/runtime/attempt-node-runtime-launcher.d.ts +17 -0
- package/dist/runtime/attempt-node-runtime-launcher.js +105 -0
- package/dist/runtime/attempt-process-supervisor-registry.d.ts +20 -0
- package/dist/runtime/attempt-process-supervisor-registry.js +64 -0
- package/dist/runtime/attempt-process-supervisor.d.ts +97 -0
- package/dist/runtime/attempt-process-supervisor.js +386 -0
- package/dist/runtime/docker-engine-probe.d.ts +83 -0
- package/dist/runtime/docker-engine-probe.js +300 -0
- package/dist/runtime/generated-output-reset.d.ts +10 -0
- package/dist/runtime/generated-output-reset.js +103 -0
- package/dist/runtime/host-listener-inspector.d.ts +46 -0
- package/dist/runtime/host-listener-inspector.js +349 -0
- package/dist/runtime/host-port-broker.d.ts +76 -0
- package/dist/runtime/host-port-broker.js +333 -0
- package/dist/runtime/host-runtime-contract.d.ts +47 -0
- package/dist/runtime/host-runtime-contract.js +700 -0
- package/dist/runtime/host-runtime-lease.d.ts +197 -0
- package/dist/runtime/host-runtime-lease.js +2203 -0
- package/dist/runtime/host-runtime-promotion.d.ts +23 -0
- package/dist/runtime/host-runtime-promotion.js +159 -0
- package/dist/runtime/memory-capacity.d.ts +68 -0
- package/dist/runtime/memory-capacity.js +121 -0
- package/dist/runtime/node-host-runtime-adapter.d.ts +54 -0
- package/dist/runtime/node-host-runtime-adapter.js +284 -0
- package/dist/runtime/playwright-host-runtime-adapter.d.ts +21 -0
- package/dist/runtime/playwright-host-runtime-adapter.js +231 -0
- package/dist/runtime/runtime-isolation-lease.d.ts +67 -0
- package/dist/runtime/runtime-isolation-lease.js +158 -0
- package/dist/runtime/workspace-resource-estimator.d.ts +17 -0
- package/dist/runtime/workspace-resource-estimator.js +174 -0
- package/dist/sandbox/preflight.d.ts +45 -1
- package/dist/sandbox/preflight.js +299 -34
- package/dist/sandbox/process.js +125 -40
- package/dist/sandbox/repository-lock.js +41 -3
- package/dist/sandbox/runtime.d.ts +66 -3
- package/dist/sandbox/runtime.js +670 -105
- package/dist/sandbox/sandbox.types.d.ts +10 -0
- package/dist/sandbox/smoke.d.ts +76 -0
- package/dist/sandbox/smoke.js +380 -0
- package/dist/sandbox/worker-supervisor.js +1 -1
- package/dist/sandbox/worker.js +2 -5
- package/dist/sandbox/workspace.service.d.ts +1 -0
- package/dist/sandbox/workspace.service.js +28 -5
- package/dist/session/managed-attempt-tools.d.ts +21 -0
- package/dist/session/managed-attempt-tools.js +912 -0
- package/dist/session/managed-build-coordinator.d.ts +5 -0
- package/dist/session/managed-build-coordinator.js +105 -0
- package/dist/session/managed-build-receipt.d.ts +21 -0
- package/dist/session/managed-build-receipt.js +335 -0
- package/dist/session/phase-completion.artifacts.d.ts +17 -0
- package/dist/session/phase-completion.artifacts.js +233 -24
- package/dist/session/phase-completion.d.ts +12 -2
- package/dist/session/phase-completion.helpers.d.ts +5 -0
- package/dist/session/phase-completion.helpers.js +51 -10
- package/dist/session/phase-completion.js +14 -3
- package/dist/session/phase-completion.prompts.d.ts +3 -3
- package/dist/session/phase-completion.prompts.js +24 -8
- package/dist/session/phase-completion.runtime.d.ts +13 -1
- package/dist/session/phase-completion.runtime.js +189 -8
- package/dist/session/phase-completion.schemas.js +117 -4
- package/dist/session/phase-completion.validation.d.ts +4 -1
- package/dist/session/phase-completion.validation.js +153 -22
- package/dist/session/pre-publication-validation.d.ts +46 -0
- package/dist/session/pre-publication-validation.js +208 -0
- package/dist/session/provider-cli/ci-repair-readiness.d.ts +16 -0
- package/dist/session/provider-cli/ci-repair-readiness.js +110 -0
- package/dist/session/provider-cli/provider-cli.artifacts.d.ts +3 -2
- package/dist/session/provider-cli/provider-cli.artifacts.js +65 -7
- package/dist/session/provider-cli/provider-cli.deterministic.js +49 -1
- package/dist/session/provider-cli/provider-cli.process.d.ts +25 -0
- package/dist/session/provider-cli/provider-cli.process.js +463 -34
- package/dist/session/provider-cli/provider-cli.prompts.d.ts +4 -1
- package/dist/session/provider-cli/provider-cli.prompts.js +108 -25
- package/dist/session/provider-cli/provider-cli.qa-sidecars.js +1 -0
- package/dist/session/provider-cli/provider-cli.repair-epoch.d.ts +6 -0
- package/dist/session/provider-cli/provider-cli.repair-epoch.js +11 -0
- package/dist/session/provider-cli/provider-cli.runtime.d.ts +1 -1
- package/dist/session/provider-cli/provider-cli.runtime.js +6 -3
- package/dist/session/provider-cli/provider-cli.service.d.ts +22 -2
- package/dist/session/provider-cli/provider-cli.service.js +524 -100
- package/dist/session/provider-cli/provider-cli.types.d.ts +5 -0
- package/dist/session/provider-cli/provider-output-state.d.ts +2 -0
- package/dist/session/provider-cli/provider-output-state.js +91 -16
- package/dist/session/provider-cli/provider-usage.d.ts +7 -0
- package/dist/session/provider-cli/provider-usage.js +28 -5
- package/dist/session/session-redaction.d.ts +3 -0
- package/dist/session/session-redaction.js +38 -0
- package/dist/session/session.base.d.ts +6 -1
- package/dist/session/session.service.d.ts +8 -2
- package/dist/session/session.service.js +522 -41
- package/dist/session/session.types.d.ts +72 -0
- package/dist/session/session.types.js +74 -3
- package/dist/shared/format/format-bytes.d.ts +1 -0
- package/dist/shared/format/format-bytes.js +9 -0
- package/dist/shared/path/path.d.ts +1 -0
- package/dist/shared/path/path.js +21 -1
- package/dist/shared/process/dotnet-toolchain.d.ts +14 -0
- package/dist/shared/process/dotnet-toolchain.js +179 -0
- package/dist/shared/process/host-process-recovery.d.ts +12 -0
- package/dist/shared/process/host-process-recovery.js +78 -0
- package/dist/shared/process/host-process-supervisor.d.ts +59 -0
- package/dist/shared/process/host-process-supervisor.js +330 -0
- package/dist/shared/process/process.d.ts +1 -0
- package/dist/shared/process/process.js +1 -0
- package/dist/shared/redaction/redaction.js +12 -2
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worktree/artifact-injector.d.ts +3 -2
- package/dist/worktree/artifact-injector.js +43 -4
- package/dist/worktree/dependency-warmer.d.ts +28 -1
- package/dist/worktree/dependency-warmer.js +308 -9
- package/dist/worktree/index.d.ts +2 -1
- package/dist/worktree/index.js +1 -0
- package/dist/worktree/prior-phase-artifact-hydrator.d.ts +2 -0
- package/dist/worktree/prior-phase-artifact-hydrator.js +136 -0
- package/dist/worktree/worktree-paths.d.ts +4 -0
- package/dist/worktree/worktree-paths.js +30 -0
- package/dist/worktree/worktree.base.d.ts +3 -0
- package/dist/worktree/worktree.service.d.ts +13 -2
- package/dist/worktree/worktree.service.js +238 -47
- package/dist/worktree/worktree.types.d.ts +5 -1
- package/package.json +5 -3
- package/runtime/windows-job-supervisor.ps1 +444 -0
- package/runtime/windows-listener-owner.ps1 +33 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { qaBlockerReasons, qaCandidateBugActions, qaFindingScopes, qaFindingSeverities, qaGateToolNames, qaScopeAuthorities, qaScopeConfidence, qaScopeEvidence, qaScopeFields, qaScopeSources, qaScopeSurfaces } from "./phase-completion.js";
|
|
1
|
+
import { qaBlockerReasons, qaCandidateBugActions, qaFindingScopes, qaFindingSeverities, qaGateToolNames, qaScopeAuthorities, qaScopeCapabilities, qaScopeConfidence, qaScopeEvidence, qaScopeFields, qaScopeSources, qaScopeSurfaces } from "./phase-completion.js";
|
|
2
2
|
export function normalizeCompletionStatus(value, phase) {
|
|
3
3
|
const normalized = value.trim().toLowerCase().replace(/-/g, "_");
|
|
4
4
|
if (phase === "qa") {
|
|
@@ -51,6 +51,10 @@ export function validatePayloadShape(phase, payload) {
|
|
|
51
51
|
requireNonPassEvidence(payload);
|
|
52
52
|
return;
|
|
53
53
|
}
|
|
54
|
+
if (phase === "ci_repair") {
|
|
55
|
+
validateCiRepairPayloadShape(payload);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
54
58
|
if (isDebugCompletionPhase(phase)) {
|
|
55
59
|
validateDebugPayloadShape(phase, payload);
|
|
56
60
|
return;
|
|
@@ -97,6 +101,72 @@ export function validatePayloadShape(phase, payload) {
|
|
|
97
101
|
requirePassingCommands(payload);
|
|
98
102
|
requireSatisfiedAcceptanceCriteria(payload);
|
|
99
103
|
}
|
|
104
|
+
export function validateCiRepairPayloadShape(payload) {
|
|
105
|
+
requireArray(payload, "failed_checks");
|
|
106
|
+
requireNonEmptyArray(payload, "failed_checks");
|
|
107
|
+
for (const [index, failedCheck] of payload.failed_checks.entries()) {
|
|
108
|
+
requireEvidenceRecord(failedCheck, `failed_checks[${index}]`);
|
|
109
|
+
const record = failedCheck;
|
|
110
|
+
requireNonEmptyStringRecord(record, "name", `failed_checks[${index}].`);
|
|
111
|
+
requireNonEmptyStringRecord(record, "provider_state", `failed_checks[${index}].`);
|
|
112
|
+
}
|
|
113
|
+
requireObject(payload, "root_cause");
|
|
114
|
+
const rootCause = requiredRecord(payload, "root_cause");
|
|
115
|
+
requireNonEmptyStringRecord(rootCause, "classification", "root_cause.");
|
|
116
|
+
requireNonEmptyStringRecord(rootCause, "summary", "root_cause.");
|
|
117
|
+
requireNonEmptyStringArrayRecord(rootCause, "evidence", "root_cause.");
|
|
118
|
+
requireArray(payload, "changed_files");
|
|
119
|
+
if (arrayLength(payload, "changed_files") === 0) {
|
|
120
|
+
requireObject(payload, "no_code_action");
|
|
121
|
+
const noCodeAction = requiredRecord(payload, "no_code_action");
|
|
122
|
+
requireNonEmptyStringRecord(noCodeAction, "action", "no_code_action.");
|
|
123
|
+
requireNonEmptyStringRecord(noCodeAction, "reason", "no_code_action.");
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
for (const [index, changedFile] of payload.changed_files.entries()) {
|
|
127
|
+
requireEvidenceRecord(changedFile, `changed_files[${index}]`);
|
|
128
|
+
const record = changedFile;
|
|
129
|
+
requireNonEmptyStringRecord(record, "path", `changed_files[${index}].`);
|
|
130
|
+
requireNonEmptyStringRecord(record, "summary", `changed_files[${index}].`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
for (const key of ["narrow_validation", "broader_validation"]) {
|
|
134
|
+
requireArray(payload, key);
|
|
135
|
+
requireNonEmptyArray(payload, key);
|
|
136
|
+
validateQaGatePassingCommands(payload[key]);
|
|
137
|
+
}
|
|
138
|
+
requireArray(payload, "workflow_validation");
|
|
139
|
+
requireNonEmptyArray(payload, "workflow_validation");
|
|
140
|
+
for (const [index, entry] of payload.workflow_validation.entries()) {
|
|
141
|
+
requireEvidenceRecord(entry, `workflow_validation[${index}]`);
|
|
142
|
+
const record = entry;
|
|
143
|
+
requireNonEmptyStringRecord(record, "step", `workflow_validation[${index}].`);
|
|
144
|
+
requireKnownStringRecord(record, "equivalence", new Set(["supported", "substitute", "provider_only"]), `workflow_validation[${index}].`);
|
|
145
|
+
requireNonEmptyStringRecord(record, "status", `workflow_validation[${index}].`);
|
|
146
|
+
const equivalence = normalizedString(optionalString(record, "equivalence"));
|
|
147
|
+
if (equivalence === "provider_only") {
|
|
148
|
+
requireNonEmptyStringRecord(record, "reason", `workflow_validation[${index}].`);
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
requireNonEmptyStringRecord(record, "command", `workflow_validation[${index}].`);
|
|
152
|
+
requireNonEmptyStringRecord(record, "summary", `workflow_validation[${index}].`);
|
|
153
|
+
if (typeof record.exit_code !== "number" || !Number.isFinite(record.exit_code)) {
|
|
154
|
+
throw new Error(`workflow_validation[${index}].exit_code is required for ${equivalence} evidence.`);
|
|
155
|
+
}
|
|
156
|
+
if (record.exit_code !== 0 || normalizedString(optionalString(record, "status")) !== "pass") {
|
|
157
|
+
throw new Error(`workflow_validation[${index}] must pass locally when equivalence is ${equivalence}.`);
|
|
158
|
+
}
|
|
159
|
+
if (equivalence === "substitute") {
|
|
160
|
+
requireNonEmptyStringRecord(record, "substitute", `workflow_validation[${index}].`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
requireArray(payload, "remaining_risks");
|
|
164
|
+
requireAllowedStatus(payload, "provider_state", ["ready_for_rerun", "rerun_requested", "checks_pending"]);
|
|
165
|
+
requireBoolean(payload, "ready_for_push");
|
|
166
|
+
if (payload.ready_for_push !== true) {
|
|
167
|
+
throw new Error("ci_repair pass requires ready_for_push: true.");
|
|
168
|
+
}
|
|
169
|
+
}
|
|
100
170
|
export function validateDebugPayloadShape(phase, payload) {
|
|
101
171
|
if (phase === "debug_repro") {
|
|
102
172
|
requireArray(payload, "repro_steps");
|
|
@@ -169,7 +239,9 @@ export function validateQaGatePayloadShape(gate, payload, status) {
|
|
|
169
239
|
if (commands.length === 0) {
|
|
170
240
|
throw new Error(`${qaGateToolNames[gate]} pass requires commands with at least one entry.`);
|
|
171
241
|
}
|
|
172
|
-
validateQaGatePassingCommands(commands
|
|
242
|
+
validateQaGatePassingCommands(commands, {
|
|
243
|
+
allowClassifiedNonBlockingFailures: gate === "qa_regression"
|
|
244
|
+
});
|
|
173
245
|
}
|
|
174
246
|
if (gate === "qa_manual_web" && status === "pass") {
|
|
175
247
|
const browserChecks = qaArray(payload, "browser_checks");
|
|
@@ -179,23 +251,69 @@ export function validateQaGatePayloadShape(gate, payload, status) {
|
|
|
179
251
|
}
|
|
180
252
|
}
|
|
181
253
|
}
|
|
182
|
-
export function validateQaGatePassingCommands(commands) {
|
|
254
|
+
export function validateQaGatePassingCommands(commands, options = {}) {
|
|
255
|
+
const issues = [];
|
|
183
256
|
for (const [index, command] of commands.entries()) {
|
|
184
|
-
|
|
257
|
+
if (!command || typeof command !== "object" || Array.isArray(command)) {
|
|
258
|
+
issues.push(`commands[${index}] must be an object.`);
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
185
261
|
const record = command;
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
262
|
+
if (!optionalString(record, "command"))
|
|
263
|
+
issues.push(`commands[${index}].command is required.`);
|
|
264
|
+
if (!optionalString(record, "status"))
|
|
265
|
+
issues.push(`commands[${index}].status is required.`);
|
|
266
|
+
if (!optionalString(record, "summary"))
|
|
267
|
+
issues.push(`commands[${index}].summary is required.`);
|
|
189
268
|
if (typeof record.exit_code !== "number" || !Number.isFinite(record.exit_code)) {
|
|
190
|
-
|
|
269
|
+
issues.push(`commands[${index}].exit_code is required.`);
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
const passed = record.exit_code === 0 && normalizedString(record.status) === "pass";
|
|
273
|
+
if (!passed
|
|
274
|
+
&& options.allowClassifiedNonBlockingFailures
|
|
275
|
+
&& optionalString(record, "classification")) {
|
|
276
|
+
try {
|
|
277
|
+
validateClassifiedNonBlockingQaFailure(record, index);
|
|
278
|
+
}
|
|
279
|
+
catch (error) {
|
|
280
|
+
issues.push(error instanceof Error ? error.message : String(error));
|
|
281
|
+
}
|
|
282
|
+
continue;
|
|
191
283
|
}
|
|
192
284
|
if (record.exit_code !== 0) {
|
|
193
|
-
|
|
285
|
+
issues.push(`commands[${index}].exit_code must be 0 when QA gate status is pass.`);
|
|
194
286
|
}
|
|
195
287
|
if (normalizedString(record.status) !== "pass") {
|
|
196
|
-
|
|
288
|
+
issues.push(`commands[${index}].status must be pass when QA gate status is pass.`);
|
|
197
289
|
}
|
|
198
290
|
}
|
|
291
|
+
throwCompletionPayloadIssues(issues);
|
|
292
|
+
}
|
|
293
|
+
function validateClassifiedNonBlockingQaFailure(record, index) {
|
|
294
|
+
const classification = normalizedString(optionalString(record, "classification"));
|
|
295
|
+
if (classification !== "out_of_scope_failure" && classification !== "baseline_failure") {
|
|
296
|
+
throw new Error(`commands[${index}] is non-passing; QA regression pass requires classification out_of_scope_failure or baseline_failure with corroborating evidence.`);
|
|
297
|
+
}
|
|
298
|
+
requireNonEmptyStringRecord(record, "candidate_bug_id", `commands[${index}].`);
|
|
299
|
+
requireNonEmptyStringRecord(record, "scope_evidence", `commands[${index}].`);
|
|
300
|
+
if (record.acceptance_criteria_independent !== true) {
|
|
301
|
+
throw new Error(`commands[${index}].acceptance_criteria_independent must be true for a non-blocking QA regression failure.`);
|
|
302
|
+
}
|
|
303
|
+
if (classification === "out_of_scope_failure") {
|
|
304
|
+
requireNonEmptyStringRecord(record, "repro_command", `commands[${index}].`);
|
|
305
|
+
if (typeof record.repro_exit_code !== "number" || !Number.isFinite(record.repro_exit_code) || record.repro_exit_code === 0) {
|
|
306
|
+
throw new Error(`commands[${index}].repro_exit_code must be a non-zero number for an out-of-scope failure.`);
|
|
307
|
+
}
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
requireNonEmptyStringRecord(record, "baseline_ref", `commands[${index}].`);
|
|
311
|
+
if (typeof record.baseline_exit_code !== "number" || !Number.isFinite(record.baseline_exit_code) || record.baseline_exit_code === 0) {
|
|
312
|
+
throw new Error(`commands[${index}].baseline_exit_code must be a non-zero number for a baseline failure.`);
|
|
313
|
+
}
|
|
314
|
+
if (record.failure_signature_match !== true) {
|
|
315
|
+
throw new Error(`commands[${index}].failure_signature_match must be true for a baseline failure.`);
|
|
316
|
+
}
|
|
199
317
|
}
|
|
200
318
|
export function validateArchitectPayloadShape(payload, status) {
|
|
201
319
|
if (status !== "pass") {
|
|
@@ -214,18 +332,22 @@ export function validateArchitectPayloadShape(payload, status) {
|
|
|
214
332
|
throw new Error("implementation_plan.tasks: expected non-empty array; correction: include at least one task with id and tests.");
|
|
215
333
|
}
|
|
216
334
|
const taskIds = new Set();
|
|
217
|
-
|
|
335
|
+
const taskIndexById = new Map();
|
|
336
|
+
for (const [taskIndex, task] of implementationPlan.tasks.entries()) {
|
|
218
337
|
if (!task || typeof task !== "object" || Array.isArray(task)) {
|
|
219
338
|
throw new Error("implementation_plan.tasks[]: expected object entries; correction: each task must be an object with id and tests.");
|
|
220
339
|
}
|
|
221
340
|
const taskRecord = task;
|
|
222
341
|
requireNonEmptyStringRecord(taskRecord, "id", "implementation_plan.tasks[].");
|
|
223
342
|
const taskId = optionalString(taskRecord, "id") ?? "";
|
|
224
|
-
|
|
225
|
-
|
|
343
|
+
const firstTaskIndex = taskIndexById.get(taskId);
|
|
344
|
+
if (firstTaskIndex !== undefined) {
|
|
345
|
+
throw new Error(`implementation_plan.tasks[${taskIndex}].id duplicates implementation_plan.tasks[${firstTaskIndex}].id '${taskId}'; correction: assign the duplicate a unique stable task id, update every phase/dependency reference to that task, and resubmit forge.complete_architect_plan.`);
|
|
226
346
|
}
|
|
227
347
|
taskIds.add(taskId);
|
|
348
|
+
taskIndexById.set(taskId, taskIndex);
|
|
228
349
|
validateArchitectTaskDevelopScope(taskRecord, taskId);
|
|
350
|
+
requireNonEmptyStringArrayRecord(taskRecord, "acceptance_criteria", `implementation_plan.tasks[${taskId}].`);
|
|
229
351
|
validateArchitectVerificationScope(taskRecord, taskId);
|
|
230
352
|
requireArchitectArray(taskRecord, "tests", payload, { path: `implementation_plan.tasks[${optionalString(taskRecord, "id") ?? "?"}].tests` });
|
|
231
353
|
if (arrayLength(taskRecord, "tests") === 0) {
|
|
@@ -269,6 +391,7 @@ export function validateQaScopeRecord(scope, pathLabel) {
|
|
|
269
391
|
}
|
|
270
392
|
const surface = qaScopeStringArray(scope, "surface", pathLabel, qaScopeSurfaces);
|
|
271
393
|
const requiredEvidence = qaScopeStringArray(scope, "required_evidence", pathLabel, qaScopeEvidence);
|
|
394
|
+
qaScopeStringArray(scope, "required_capabilities", pathLabel, qaScopeCapabilities);
|
|
272
395
|
const notApplicable = qaScopeStringArray(scope, "not_applicable", pathLabel, qaScopeEvidence);
|
|
273
396
|
const requiredSet = new Set(requiredEvidence);
|
|
274
397
|
for (const evidence of notApplicable) {
|
|
@@ -611,22 +734,30 @@ export function validateQaPayloadShape(payload, status) {
|
|
|
611
734
|
throw new Error("QA pass/needs_rework requires at least one command, browser check, or evidence note.");
|
|
612
735
|
}
|
|
613
736
|
}
|
|
737
|
+
const commandIssues = [];
|
|
614
738
|
for (const [index, command] of commands.entries()) {
|
|
615
|
-
|
|
739
|
+
if (!command || typeof command !== "object" || Array.isArray(command)) {
|
|
740
|
+
commandIssues.push(`commands[${index}] must be an object.`);
|
|
741
|
+
continue;
|
|
742
|
+
}
|
|
616
743
|
const record = command;
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
744
|
+
if (!optionalString(record, "command"))
|
|
745
|
+
commandIssues.push(`commands[${index}].command is required.`);
|
|
746
|
+
if (!optionalString(record, "status"))
|
|
747
|
+
commandIssues.push(`commands[${index}].status is required.`);
|
|
748
|
+
if (!optionalString(record, "summary"))
|
|
749
|
+
commandIssues.push(`commands[${index}].summary is required.`);
|
|
620
750
|
if (typeof record.exit_code !== "number" || !Number.isFinite(record.exit_code)) {
|
|
621
|
-
|
|
751
|
+
commandIssues.push(`commands[${index}].exit_code is required.`);
|
|
622
752
|
}
|
|
623
|
-
if (status === "pass" && record.exit_code !== 0) {
|
|
624
|
-
|
|
753
|
+
else if (status === "pass" && record.exit_code !== 0) {
|
|
754
|
+
commandIssues.push(`commands[${index}].exit_code must be 0 when QA status is pass.`);
|
|
625
755
|
}
|
|
626
756
|
if (status === "pass" && normalizedString(record.status) !== "pass") {
|
|
627
|
-
|
|
757
|
+
commandIssues.push(`commands[${index}].status must be pass when QA status is pass.`);
|
|
628
758
|
}
|
|
629
759
|
}
|
|
760
|
+
throwCompletionPayloadIssues(commandIssues);
|
|
630
761
|
for (const [index, browserCheck] of browserChecks.entries()) {
|
|
631
762
|
requireEvidenceRecord(browserCheck, `browser_checks[${index}]`);
|
|
632
763
|
const record = browserCheck;
|
|
@@ -720,5 +851,5 @@ export function validateOptionalWorkItemId(payload, expectedWorkItemId) {
|
|
|
720
851
|
throw new Error(`completion payload work item ${actual} does not match dispatch work item ${expectedWorkItemId}.`);
|
|
721
852
|
}
|
|
722
853
|
}
|
|
723
|
-
import { requireField, requireNonPassEvidence, requireImplementationEvidence, requirePassingCommands, requireArray, requireNonEmptyArray, arrayLength, requireAllowedStatus, requireObject, requiredRecord, requireBoolean, requireNumericOne, requireSatisfiedAcceptanceCriteria, validateResolvedFindings, isDebugCompletionPhase, qaEvidenceSource, qaArray, qaObjectArray, qaPolicyWarnings, qaStringArray, requireEvidenceRecord, requireNonEmptyStringRecord, requireKnownStringRecord, requireNonEmptyStringArrayRecord, requireSubstantiveEvidenceRecord, normalizedString, requiredString, optionalString, stripUndefined, } from "./phase-completion.helpers.js";
|
|
854
|
+
import { requireField, requireNonPassEvidence, requireImplementationEvidence, requirePassingCommands, requireArray, requireNonEmptyArray, arrayLength, requireAllowedStatus, requireObject, requiredRecord, requireBoolean, requireNumericOne, requireSatisfiedAcceptanceCriteria, validateResolvedFindings, isDebugCompletionPhase, qaEvidenceSource, qaArray, qaObjectArray, qaPolicyWarnings, qaStringArray, requireEvidenceRecord, requireNonEmptyStringRecord, requireKnownStringRecord, requireNonEmptyStringArrayRecord, requireSubstantiveEvidenceRecord, normalizedString, requiredString, optionalString, stripUndefined, throwCompletionPayloadIssues, } from "./phase-completion.helpers.js";
|
|
724
855
|
export * from "./phase-completion.helpers.js";
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { ProviderProcessRunner } from "./provider-cli/provider-cli.process.js";
|
|
2
|
+
import type { RunnerDispatchEvent } from "./session.types.js";
|
|
3
|
+
export type PrePublicationProfile = "implementation" | "comment_repair" | "ci_repair" | "conflict_repair";
|
|
4
|
+
interface ValidationStep {
|
|
5
|
+
readonly command: string;
|
|
6
|
+
readonly fallbackCommand?: string;
|
|
7
|
+
readonly args: readonly string[];
|
|
8
|
+
readonly fallbackArgs?: readonly string[];
|
|
9
|
+
readonly assertUnchangedPaths?: readonly string[];
|
|
10
|
+
}
|
|
11
|
+
interface ValidationCheck {
|
|
12
|
+
readonly id: string;
|
|
13
|
+
readonly profiles: readonly string[];
|
|
14
|
+
readonly changedPathPatterns: readonly string[];
|
|
15
|
+
readonly requiredCapability?: string;
|
|
16
|
+
readonly providerOnly?: boolean;
|
|
17
|
+
readonly steps: readonly ValidationStep[];
|
|
18
|
+
}
|
|
19
|
+
interface ValidationContract {
|
|
20
|
+
readonly schemaVersion: number;
|
|
21
|
+
readonly contractId: string;
|
|
22
|
+
readonly checks: readonly ValidationCheck[];
|
|
23
|
+
}
|
|
24
|
+
export interface PrePublicationCommandResult {
|
|
25
|
+
readonly exitCode: number;
|
|
26
|
+
readonly stdout?: string;
|
|
27
|
+
readonly stderr?: string;
|
|
28
|
+
}
|
|
29
|
+
export interface PrePublicationCommandRunner {
|
|
30
|
+
run(command: string, args: readonly string[], cwd: string, signal?: AbortSignal, environment?: Readonly<Record<string, string>>): Promise<PrePublicationCommandResult>;
|
|
31
|
+
}
|
|
32
|
+
export interface PrePublicationExecutionContext {
|
|
33
|
+
readonly environment?: Readonly<Record<string, string>>;
|
|
34
|
+
readonly processRunner?: ProviderProcessRunner;
|
|
35
|
+
}
|
|
36
|
+
export declare class PrePublicationValidator {
|
|
37
|
+
private readonly commands;
|
|
38
|
+
constructor(commands?: PrePublicationCommandRunner);
|
|
39
|
+
validate(worktreePath: string, profile: PrePublicationProfile, capabilities: ReadonlySet<string>, emit?: (event: RunnerDispatchEvent) => Promise<void>, signal?: AbortSignal, execution?: PrePublicationExecutionContext): Promise<readonly string[]>;
|
|
40
|
+
private changedPaths;
|
|
41
|
+
private runStep;
|
|
42
|
+
private runCommand;
|
|
43
|
+
}
|
|
44
|
+
export declare function applicableChecks(contract: ValidationContract, profile: PrePublicationProfile | "github_actions", changedPaths: readonly string[]): readonly ValidationCheck[];
|
|
45
|
+
export declare function loadValidationContract(worktreePath: string): Promise<ValidationContract | null>;
|
|
46
|
+
export {};
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import fs from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
const execFileAsync = promisify(execFile);
|
|
7
|
+
const contractRelativePath = ".forge/validation/ci-parity.v1.json";
|
|
8
|
+
const requiredCheckIds = new Set([
|
|
9
|
+
"file-size-inventory",
|
|
10
|
+
"owned-suite-registration",
|
|
11
|
+
"dotnet-build-and-unit",
|
|
12
|
+
"openapi-and-generated-types",
|
|
13
|
+
"client",
|
|
14
|
+
"bridge",
|
|
15
|
+
"playwright-e2e",
|
|
16
|
+
"github-provider-policy",
|
|
17
|
+
]);
|
|
18
|
+
const approvedContractDigest = "014727b066b2b659b8772987650b85540f1bf70864c678187fde108a9b1d84d3";
|
|
19
|
+
class ProcessPrePublicationCommandRunner {
|
|
20
|
+
async run(command, args, cwd, signal, environment) {
|
|
21
|
+
try {
|
|
22
|
+
const windowsBatch = process.platform === "win32" && (command === "npm" || command === "pnpm");
|
|
23
|
+
const executable = windowsBatch ? (process.env.ComSpec ?? "cmd.exe") : command;
|
|
24
|
+
const commandArgs = windowsBatch
|
|
25
|
+
? ["/d", "/s", "/c", [`${command}.cmd`, ...args].map(quoteWindowsCommandArgument).join(" ")]
|
|
26
|
+
: [...args];
|
|
27
|
+
const result = await execFileAsync(executable, commandArgs, {
|
|
28
|
+
cwd,
|
|
29
|
+
encoding: "utf8",
|
|
30
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
31
|
+
signal,
|
|
32
|
+
env: environment ? { ...process.env, ...environment } : process.env,
|
|
33
|
+
});
|
|
34
|
+
return { exitCode: 0, stdout: result.stdout, stderr: result.stderr };
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
const failure = error;
|
|
38
|
+
if (failure.name === "AbortError")
|
|
39
|
+
throw error;
|
|
40
|
+
if (failure.code === "ENOENT") {
|
|
41
|
+
return { exitCode: 127, stderr: `executable_not_found: ${command}` };
|
|
42
|
+
}
|
|
43
|
+
return {
|
|
44
|
+
exitCode: typeof failure.code === "number" ? failure.code : 1,
|
|
45
|
+
stdout: failure.stdout,
|
|
46
|
+
stderr: failure.stderr ?? failure.message,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function quoteWindowsCommandArgument(value) {
|
|
52
|
+
return /^[A-Za-z0-9_./:@=-]+$/u.test(value) ? value : `"${value.replaceAll('"', '""')}"`;
|
|
53
|
+
}
|
|
54
|
+
export class PrePublicationValidator {
|
|
55
|
+
commands;
|
|
56
|
+
constructor(commands = new ProcessPrePublicationCommandRunner()) {
|
|
57
|
+
this.commands = commands;
|
|
58
|
+
}
|
|
59
|
+
async validate(worktreePath, profile, capabilities, emit, signal, execution) {
|
|
60
|
+
const contract = await loadValidationContract(worktreePath);
|
|
61
|
+
if (contract === null) {
|
|
62
|
+
await emit?.(validationEvent("prepublication_validation_legacy_contract", "legacy-provider-validation", `This checkout predates ${contractRelativePath}; Bridge preserved legacy provider validation without source mutation.`));
|
|
63
|
+
return [];
|
|
64
|
+
}
|
|
65
|
+
const changedPaths = await this.changedPaths(worktreePath, signal, execution);
|
|
66
|
+
const selected = applicableChecks(contract, profile, changedPaths);
|
|
67
|
+
const completed = [];
|
|
68
|
+
for (const check of selected) {
|
|
69
|
+
if (check.providerOnly) {
|
|
70
|
+
await emit?.(validationEvent("prepublication_validation_provider_only", check.id, `Preserved provider-only validation '${check.id}' for the provider check run.`));
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (check.requiredCapability && !capabilities.has(check.requiredCapability)) {
|
|
74
|
+
throw new Error(`prepublication_capability_unavailable: ${check.id} requires ${check.requiredCapability}; publication is holding until a capable runner is available.`);
|
|
75
|
+
}
|
|
76
|
+
await emit?.(validationEvent("prepublication_validation_started", check.id, `Running pre-publication CI parity check '${check.id}'.`));
|
|
77
|
+
for (const step of check.steps) {
|
|
78
|
+
const result = await this.runStep(step, worktreePath, signal, execution);
|
|
79
|
+
if (result.exitCode !== 0) {
|
|
80
|
+
const detail = `${result.stderr ?? result.stdout ?? ""}`.trim().slice(-1600);
|
|
81
|
+
throw new Error(`prepublication_validation_failed: ${check.id} exited ${result.exitCode}${detail ? `: ${detail}` : ""}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
completed.push(check.id);
|
|
85
|
+
await emit?.(validationEvent("prepublication_validation_passed", check.id, `Pre-publication CI parity check '${check.id}' passed.`));
|
|
86
|
+
}
|
|
87
|
+
return completed;
|
|
88
|
+
}
|
|
89
|
+
async changedPaths(worktreePath, signal, execution) {
|
|
90
|
+
const requests = [
|
|
91
|
+
["diff", "--name-only", "HEAD", "--"],
|
|
92
|
+
["diff", "--name-only", "HEAD^", "HEAD", "--"],
|
|
93
|
+
["status", "--porcelain=v1", "--untracked-files=all"],
|
|
94
|
+
];
|
|
95
|
+
const paths = new Set();
|
|
96
|
+
for (const args of requests) {
|
|
97
|
+
const result = await this.runCommand("git", args, worktreePath, signal, execution);
|
|
98
|
+
if (result.exitCode !== 0 && !isMissingParentRevision(args, result)) {
|
|
99
|
+
throw new Error(`prepublication_validation_failed: unable to inventory changed paths: ${result.stderr ?? result.stdout ?? "git failed"}`);
|
|
100
|
+
}
|
|
101
|
+
if (result.exitCode !== 0)
|
|
102
|
+
continue;
|
|
103
|
+
for (const line of `${result.stdout ?? ""}`.split(/\r?\n/u).filter(Boolean)) {
|
|
104
|
+
const raw = args[0] === "status" ? line.slice(3).split(" -> ").at(-1) ?? "" : line;
|
|
105
|
+
const normalized = raw.trim().replaceAll("\\", "/");
|
|
106
|
+
if (normalized)
|
|
107
|
+
paths.add(normalized);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return [...paths];
|
|
111
|
+
}
|
|
112
|
+
async runStep(step, cwd, signal, execution) {
|
|
113
|
+
const before = await readValidationFiles(cwd, step.assertUnchangedPaths ?? []);
|
|
114
|
+
const result = await this.runCommand(step.command, step.args, cwd, signal, execution);
|
|
115
|
+
if (result.exitCode === 127 && step.fallbackCommand) {
|
|
116
|
+
const fallback = await this.runCommand(step.fallbackCommand, step.fallbackArgs ?? step.args, cwd, signal, execution);
|
|
117
|
+
return await assertValidationFilesUnchanged(cwd, before, fallback);
|
|
118
|
+
}
|
|
119
|
+
return await assertValidationFilesUnchanged(cwd, before, result);
|
|
120
|
+
}
|
|
121
|
+
async runCommand(command, args, cwd, signal, execution) {
|
|
122
|
+
if (execution?.processRunner) {
|
|
123
|
+
const result = await execution.processRunner.run({
|
|
124
|
+
command,
|
|
125
|
+
args,
|
|
126
|
+
cwd,
|
|
127
|
+
env: execution.environment,
|
|
128
|
+
captureOutput: true,
|
|
129
|
+
}, undefined, signal);
|
|
130
|
+
return { exitCode: result.exitCode, stdout: result.stdout, stderr: result.stderr };
|
|
131
|
+
}
|
|
132
|
+
return await this.commands.run(command, args, cwd, signal, execution?.environment);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
function isMissingParentRevision(args, result) {
|
|
136
|
+
if (args.length !== 5
|
|
137
|
+
|| args[0] !== "diff"
|
|
138
|
+
|| args[1] !== "--name-only"
|
|
139
|
+
|| args[2] !== "HEAD^"
|
|
140
|
+
|| args[3] !== "HEAD"
|
|
141
|
+
|| args[4] !== "--") {
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
const detail = `${result.stderr ?? result.stdout ?? ""}`;
|
|
145
|
+
return /(?:ambiguous argument|bad revision|unknown revision|invalid object name).*HEAD\^/iu.test(detail);
|
|
146
|
+
}
|
|
147
|
+
async function readValidationFiles(cwd, relativePaths) {
|
|
148
|
+
const values = new Map();
|
|
149
|
+
for (const relativePath of relativePaths) {
|
|
150
|
+
values.set(relativePath, await fs.readFile(path.join(cwd, relativePath), "utf8"));
|
|
151
|
+
}
|
|
152
|
+
return values;
|
|
153
|
+
}
|
|
154
|
+
async function assertValidationFilesUnchanged(cwd, before, result) {
|
|
155
|
+
if (result.exitCode !== 0)
|
|
156
|
+
return result;
|
|
157
|
+
for (const [relativePath, original] of before) {
|
|
158
|
+
const current = await fs.readFile(path.join(cwd, relativePath), "utf8");
|
|
159
|
+
if (current !== original) {
|
|
160
|
+
return {
|
|
161
|
+
exitCode: 1,
|
|
162
|
+
stderr: `generated_drift: ${relativePath} changed during validation; regenerate and review it before publication.`,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return result;
|
|
167
|
+
}
|
|
168
|
+
export function applicableChecks(contract, profile, changedPaths) {
|
|
169
|
+
return contract.checks.filter(check => check.profiles.includes(profile)
|
|
170
|
+
&& changedPaths.some(changedPath => check.changedPathPatterns.some(pattern => new RegExp(pattern, "u").test(changedPath))));
|
|
171
|
+
}
|
|
172
|
+
export async function loadValidationContract(worktreePath) {
|
|
173
|
+
let source;
|
|
174
|
+
try {
|
|
175
|
+
source = await fs.readFile(path.join(worktreePath, contractRelativePath), "utf8");
|
|
176
|
+
}
|
|
177
|
+
catch (error) {
|
|
178
|
+
if (error.code === "ENOENT")
|
|
179
|
+
return null;
|
|
180
|
+
throw error;
|
|
181
|
+
}
|
|
182
|
+
const contract = JSON.parse(source);
|
|
183
|
+
const ids = new Set(contract.checks?.map(check => check.id));
|
|
184
|
+
const contractDigest = createHash("sha256")
|
|
185
|
+
.update(JSON.stringify(canonicalizeJson(contract)))
|
|
186
|
+
.digest("hex");
|
|
187
|
+
if (contract.schemaVersion !== 1
|
|
188
|
+
|| contract.contractId !== "forge-ci-parity-v1"
|
|
189
|
+
|| contractDigest !== approvedContractDigest
|
|
190
|
+
|| requiredCheckIds.size !== ids.size
|
|
191
|
+
|| [...requiredCheckIds].some(id => !ids.has(id))
|
|
192
|
+
|| contract.checks.some(check => !check.id || !Array.isArray(check.steps) || !Array.isArray(check.changedPathPatterns))) {
|
|
193
|
+
throw new Error(`prepublication_contract_invalid: the Forge CI parity contract does not match the approved definition (digest ${contractDigest}, expected ${approvedContractDigest}).`);
|
|
194
|
+
}
|
|
195
|
+
return contract;
|
|
196
|
+
}
|
|
197
|
+
function canonicalizeJson(value) {
|
|
198
|
+
if (Array.isArray(value))
|
|
199
|
+
return value.map(canonicalizeJson);
|
|
200
|
+
if (value !== null && typeof value === "object") {
|
|
201
|
+
const record = value;
|
|
202
|
+
return Object.fromEntries(Object.keys(record).sort().map(key => [key, canonicalizeJson(record[key])]));
|
|
203
|
+
}
|
|
204
|
+
return value;
|
|
205
|
+
}
|
|
206
|
+
function validationEvent(eventType, checkId, content) {
|
|
207
|
+
return { role: "runner", kind: "checkpoint", content, eventType, data: JSON.stringify({ checkId, contractId: "forge-ci-parity-v1" }) };
|
|
208
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { RunOnceDispatchPacket } from "../session.types.js";
|
|
2
|
+
export declare const ciRepairProviderOnlySpecs: readonly ["**/bridge-dispatch.spec.ts", "**/develop-agent-run.spec.ts", "**/runner-availability.spec.ts"];
|
|
3
|
+
export declare const ciRepairLocalPreflightCommands: readonly CiRepairLocalPreflightCommand[];
|
|
4
|
+
export interface CiRepairLocalPreflightCommand {
|
|
5
|
+
readonly id: string;
|
|
6
|
+
readonly cwd: string;
|
|
7
|
+
readonly command: readonly string[];
|
|
8
|
+
readonly coverage: string;
|
|
9
|
+
}
|
|
10
|
+
export interface CiRepairReadinessResult {
|
|
11
|
+
readonly path: string;
|
|
12
|
+
readonly environment: Readonly<Record<string, string>>;
|
|
13
|
+
readonly manifest: Readonly<Record<string, unknown>>;
|
|
14
|
+
}
|
|
15
|
+
export declare function writeCiRepairReadinessManifest(dispatch: RunOnceDispatchPacket, worktreePath: string, specPath: string, runtimeEnvironment: Readonly<Record<string, string>> | undefined): Promise<CiRepairReadinessResult>;
|
|
16
|
+
export declare function assertCiRepairPreflightContract(providerOnlySpecs: readonly string[], localPreflightCommands: readonly CiRepairLocalPreflightCommand[]): void;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
export const ciRepairProviderOnlySpecs = [
|
|
5
|
+
"**/bridge-dispatch.spec.ts",
|
|
6
|
+
"**/develop-agent-run.spec.ts",
|
|
7
|
+
"**/runner-availability.spec.ts",
|
|
8
|
+
];
|
|
9
|
+
export const ciRepairLocalPreflightCommands = [];
|
|
10
|
+
export async function writeCiRepairReadinessManifest(dispatch, worktreePath, specPath, runtimeEnvironment) {
|
|
11
|
+
assertCiRepairPreflightContract(ciRepairProviderOnlySpecs, ciRepairLocalPreflightCommands);
|
|
12
|
+
const metadataRoot = path.dirname(specPath);
|
|
13
|
+
const manifestPath = path.join(metadataRoot, "ci-repair.readiness.json");
|
|
14
|
+
const workflowPath = path.join(worktreePath, ".github", "workflows", "ci.yml");
|
|
15
|
+
const browserReady = runtimeEnvironment?.FORGE_PLAYWRIGHT_BROWSER_READY === "true";
|
|
16
|
+
const providerOnlySpecsJson = JSON.stringify(ciRepairProviderOnlySpecs);
|
|
17
|
+
const apiUrl = runtimeEnvironment?.FORGE_E2E_API_URL ?? runtimeEnvironment?.FORGE_API_URL ?? null;
|
|
18
|
+
const webUrl = runtimeEnvironment?.FORGE_E2E_WEB_URL
|
|
19
|
+
?? runtimeEnvironment?.FORGE_WEB_URL
|
|
20
|
+
?? runtimeEnvironment?.FORGE_PLAYWRIGHT_BASE_URL
|
|
21
|
+
?? null;
|
|
22
|
+
const environment = {
|
|
23
|
+
...(runtimeEnvironment ?? {}),
|
|
24
|
+
...(apiUrl ? { FORGE_E2E_API_URL: apiUrl } : {}),
|
|
25
|
+
...(webUrl ? { FORGE_E2E_WEB_URL: webUrl } : {}),
|
|
26
|
+
FORGE_CI_REPAIR_READINESS_PATH: manifestPath,
|
|
27
|
+
FORGE_CI_REPAIR_HANDOFF_PATH: path.join(metadataRoot, "artifacts", "ci-repair.handoff.json"),
|
|
28
|
+
FORGE_CI_REPAIR_PROVIDER_ONLY_SPECS: providerOnlySpecsJson,
|
|
29
|
+
};
|
|
30
|
+
const manifest = {
|
|
31
|
+
schema_version: 1,
|
|
32
|
+
generated_by: "forge-bridge",
|
|
33
|
+
work_item_id: dispatch.workItem.id,
|
|
34
|
+
session_id: dispatch.session.id,
|
|
35
|
+
spec: {
|
|
36
|
+
path: relativePath(worktreePath, specPath),
|
|
37
|
+
ready: await isFile(specPath),
|
|
38
|
+
},
|
|
39
|
+
handoff: {
|
|
40
|
+
path: relativePath(worktreePath, environment.FORGE_CI_REPAIR_HANDOFF_PATH),
|
|
41
|
+
completion_tool: "forge.complete_ci_repair",
|
|
42
|
+
required_before_commit_or_push: true,
|
|
43
|
+
},
|
|
44
|
+
pull_request: {
|
|
45
|
+
provider: dispatch.pullRequest?.provider ?? "unknown",
|
|
46
|
+
number: dispatch.pullRequest?.number ?? null,
|
|
47
|
+
url: dispatch.pullRequest?.url ?? null,
|
|
48
|
+
source_branch: dispatch.pullRequest?.sourceBranch ?? dispatch.cycleRun.baseBranch,
|
|
49
|
+
target_branch: dispatch.pullRequest?.targetBranch ?? dispatch.cycleRun.requestedBase,
|
|
50
|
+
check_state: dispatch.pullRequest?.checkState ?? "unknown",
|
|
51
|
+
required_checks_passed: dispatch.pullRequest?.requiredChecksPassed ?? 0,
|
|
52
|
+
required_checks_total: dispatch.pullRequest?.requiredChecksTotal ?? 0,
|
|
53
|
+
failed_job_discovery: "Inspect provider check runs and record exact job/step in the completion payload.",
|
|
54
|
+
},
|
|
55
|
+
workflow: {
|
|
56
|
+
path: relativePath(worktreePath, workflowPath),
|
|
57
|
+
ready: await isFile(workflowPath),
|
|
58
|
+
requirement: "Record the failed step and every earlier guard/drift step in workflow_validation.",
|
|
59
|
+
},
|
|
60
|
+
runtime: {
|
|
61
|
+
mode: runtimeEnvironment?.FORGE_RUNTIME_MODE ?? "unknown",
|
|
62
|
+
application_runtime_ready: Boolean(apiUrl || webUrl),
|
|
63
|
+
api_url: apiUrl,
|
|
64
|
+
web_url: webUrl,
|
|
65
|
+
playwright_config: runtimeEnvironment?.FORGE_PLAYWRIGHT_CONFIG ?? null,
|
|
66
|
+
playwright_browser_cache: runtimeEnvironment?.PLAYWRIGHT_BROWSERS_PATH ?? null,
|
|
67
|
+
playwright_browser_ready: browserReady,
|
|
68
|
+
playwright_browser_install_command: browserReady
|
|
69
|
+
? null
|
|
70
|
+
: runtimeEnvironment?.FORGE_PLAYWRIGHT_BROWSER_INSTALL_COMMAND ?? "npx playwright install chromium",
|
|
71
|
+
attempt_database_schema: runtimeEnvironment?.FORGE_ATTEMPT_DATABASE_SCHEMA ?? null,
|
|
72
|
+
attempt_database_readiness_path: runtimeEnvironment?.FORGE_ATTEMPT_DATABASE_READINESS_PATH ?? null,
|
|
73
|
+
},
|
|
74
|
+
local_equivalence: {
|
|
75
|
+
default: "supported",
|
|
76
|
+
nested_bridge: "provider_only",
|
|
77
|
+
provider_only_specs: ciRepairProviderOnlySpecs,
|
|
78
|
+
provider_only_specs_fingerprint: `sha256:${createHash("sha256").update(providerOnlySpecsJson).digest("hex")}`,
|
|
79
|
+
local_preflight_commands: ciRepairLocalPreflightCommands,
|
|
80
|
+
reason: "A managed Forge attempt disables its hosted reconciler and cannot exercise specs that require the live Bridge/runner control plane. Those specs remain authoritative in provider CI; attempt-local unit, contract, API, and browser coverage remains supported.",
|
|
81
|
+
policy: "Run every listed local_preflight_command. Treat every provider_only_specs pattern as unavailable in the managed attempt, do not override its exclusion or weaken provider CI, and record the provider check that supplies that proof in workflow_validation.",
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
await fs.mkdir(metadataRoot, { recursive: true });
|
|
85
|
+
await fs.writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
86
|
+
return { path: manifestPath, environment, manifest };
|
|
87
|
+
}
|
|
88
|
+
export function assertCiRepairPreflightContract(providerOnlySpecs, localPreflightCommands) {
|
|
89
|
+
const providerOnlyFileNames = new Set(providerOnlySpecs.map(fileName));
|
|
90
|
+
for (const preflight of localPreflightCommands) {
|
|
91
|
+
const conflictingArgument = preflight.command.find(argument => providerOnlyFileNames.has(fileName(argument)));
|
|
92
|
+
if (conflictingArgument) {
|
|
93
|
+
throw new Error(`ci_repair_readiness_invalid: local preflight '${preflight.id}' references provider-only spec '${conflictingArgument}'.`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
async function isFile(candidate) {
|
|
98
|
+
try {
|
|
99
|
+
return (await fs.stat(candidate)).isFile();
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
function relativePath(worktreePath, candidate) {
|
|
106
|
+
return path.relative(worktreePath, candidate).replaceAll(path.sep, "/");
|
|
107
|
+
}
|
|
108
|
+
function fileName(candidate) {
|
|
109
|
+
return path.posix.basename(candidate.replaceAll("\\", "/"));
|
|
110
|
+
}
|