immune-brain 3.6.9 → 4.0.1
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/package.json +3 -2
- package/plugins/immune-brain/.claude-plugin/plugin.json +1 -1
- package/plugins/immune-brain/.pi-extension/imm-canary-enroll.ts +18 -2
- package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +76 -121
- package/plugins/immune-brain/.pi-extension/imm-unattended-batch.ts +106 -600
- package/plugins/immune-brain/.pi-extension/pi-canary-assurance-progression.ts +1 -0
- package/plugins/immune-brain/.pi-extension/pi-canary-verification.ts +3 -3
- package/plugins/immune-brain/.pi-extension/runtime-stub.ts +17 -43
- package/plugins/immune-brain/dist/claude/mcp-server.mjs +7587 -5049
- package/plugins/immune-brain/dist/docs/reference/planning-artifact-retention.md +11 -12
- package/plugins/immune-brain/dist/docs/reference/subagent-dispatch-protocol.md +1 -1
- package/plugins/immune-brain/dist/imm-loop.md +33 -25
- package/plugins/immune-brain/dist/imm-planner.md +41 -27
- package/plugins/immune-brain/dist/imm-review-retro.md +2 -2
- package/plugins/immune-brain/dist/role-prompts/code-review.md +11 -1
- package/plugins/immune-brain/dist/role-prompts/executor.md +4 -4
- package/plugins/immune-brain/runtime/assurance/coordinator.ts +183 -40
- package/plugins/immune-brain/runtime/assurance/delivery_workspace.ts +240 -0
- package/plugins/immune-brain/runtime/assurance/qa.ts +132 -58
- package/plugins/immune-brain/runtime/assurance/review_evidence.ts +15 -7
- package/plugins/immune-brain/runtime/assurance/verification.ts +246 -206
- package/plugins/immune-brain/runtime/authorization_operation.ts +20 -0
- package/plugins/immune-brain/runtime/claude/kernel_ports.ts +288 -721
- package/plugins/immune-brain/runtime/commands/kernel.ts +158 -67
- package/plugins/immune-brain/runtime/github_issue_tracker.ts +1 -1
- package/plugins/immune-brain/runtime/kernel/actor_identity.ts +33 -0
- package/plugins/immune-brain/runtime/kernel/application.ts +22 -6
- package/plugins/immune-brain/runtime/kernel/assurance_projection.ts +94 -5
- package/plugins/immune-brain/runtime/kernel/authority_port.ts +27 -6
- package/plugins/immune-brain/runtime/kernel/backend_claim.ts +43 -16
- package/plugins/immune-brain/runtime/kernel/batch_authority.ts +10 -6
- package/plugins/immune-brain/runtime/kernel/canary_application.ts +50 -63
- package/plugins/immune-brain/runtime/kernel/canary_eligibility.ts +13 -4
- package/plugins/immune-brain/runtime/kernel/completion.ts +5 -14
- package/plugins/immune-brain/runtime/kernel/enrollment.ts +124 -34
- package/plugins/immune-brain/runtime/kernel/enrollment_authority.ts +13 -5
- package/plugins/immune-brain/runtime/kernel/index.ts +3 -1
- package/plugins/immune-brain/runtime/kernel/intent.ts +7 -11
- package/plugins/immune-brain/runtime/kernel/legacy_audit.ts +4 -1
- package/plugins/immune-brain/runtime/kernel/legacy_task_record.ts +323 -0
- package/plugins/immune-brain/runtime/kernel/pi_canary_prepare.ts +10 -1
- package/plugins/immune-brain/runtime/kernel/reducer.ts +32 -31
- package/plugins/immune-brain/runtime/kernel/run_identity.ts +121 -0
- package/plugins/immune-brain/runtime/kernel/spec_binding.ts +100 -0
- package/plugins/immune-brain/runtime/kernel/sqlite_migration.ts +950 -0
- package/plugins/immune-brain/runtime/kernel/sqlite_store.ts +1193 -0
- package/plugins/immune-brain/runtime/kernel/storage.ts +1254 -1206
- package/plugins/immune-brain/runtime/kernel/storage_layout_migration.ts +129 -755
- package/plugins/immune-brain/runtime/kernel/storage_paths.ts +419 -46
- package/plugins/immune-brain/runtime/kernel/types.ts +12 -43
- package/plugins/immune-brain/runtime/kernel/validation.ts +60 -274
- package/plugins/immune-brain/runtime/managed_task_routing_policy.ts +0 -1
- package/plugins/immune-brain/runtime/plan_core.ts +27 -65
- package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
- package/plugins/immune-brain/runtime/prompts/code-review.md +11 -1
- package/plugins/immune-brain/runtime/prompts/executor.md +4 -4
- package/plugins/immune-brain/runtime/staged_intent.ts +58 -0
- package/plugins/immune-brain/runtime/unattended/batch_git.ts +37 -7
- package/plugins/immune-brain/runtime/unattended/batch_plan.ts +42 -2
- package/plugins/immune-brain/runtime/unattended/batch_preflight.ts +771 -0
- package/plugins/immune-brain/runtime/unattended/batch_reasons.ts +189 -0
- package/plugins/immune-brain/runtime/unattended/batch_runner.ts +35 -0
- package/plugins/immune-brain/runtime/unattended/confirmation_deadline.ts +33 -0
- package/plugins/immune-brain/runtime/unattended/types.ts +14 -1
- package/plugins/immune-brain/runtime/v4_runtime.ts +19 -23
- package/plugins/immune-brain/runtime/verification_descriptor.ts +92 -136
- package/plugins/immune-brain/runtime/workspace_scope.ts +98 -13
- package/plugins/immune-brain/skills/imm-planner/SKILL.md +3 -3
- package/plugins/immune-brain/bin/imm-retire-stale-wrapper +0 -4
- package/plugins/immune-brain/bin/imm-retired +0 -4
- package/plugins/immune-brain/runtime/authority_commit_receipts.ts +0 -716
- package/plugins/immune-brain/runtime/kernel/automatic_observations.ts +0 -451
- package/plugins/immune-brain/runtime/kernel/legacy.ts +0 -299
- package/plugins/immune-brain/runtime/kernel/observation.ts +0 -397
- package/plugins/immune-brain/runtime/kernel/readiness.ts +0 -282
- package/plugins/immune-brain/runtime/kernel/readiness_evidence.ts +0 -132
|
@@ -7,11 +7,10 @@
|
|
|
7
7
|
// evidence reservation.
|
|
8
8
|
|
|
9
9
|
import { createHash, randomUUID } from "node:crypto";
|
|
10
|
-
import type { FindingEvidence, TaskFinding } from "../kernel/types";
|
|
10
|
+
import type { FindingEvidence, ReviewAdvisoryFindingV1, TaskFinding } from "../kernel/types";
|
|
11
11
|
import { anchorForEvidence } from "../kernel/refutation";
|
|
12
12
|
import {
|
|
13
13
|
findingsDigest,
|
|
14
|
-
type FrozenRunner,
|
|
15
14
|
type VerificationDescriptor,
|
|
16
15
|
VerificationAbortedError,
|
|
17
16
|
} from "./verification";
|
|
@@ -21,6 +20,7 @@ import { buildRoleDelegationPacket } from "../role_prompt_bridge";
|
|
|
21
20
|
import type { AssuranceProjectionResult } from "../kernel/assurance_projection";
|
|
22
21
|
import type { TaskIntentIdentityToken } from "../kernel/intent_token_registry";
|
|
23
22
|
import type { AssuranceHostPort, HostReviewReservation } from "./host_port";
|
|
23
|
+
import type { GithubTrackerResult as GithubTrackerProjectionResult } from "../github_issue_tracker";
|
|
24
24
|
|
|
25
25
|
export type AssuranceRole = "qa" | "review";
|
|
26
26
|
export interface AssuranceCorrelation {
|
|
@@ -74,6 +74,42 @@ export function deriveGithubTerminalProjectionInput(
|
|
|
74
74
|
};
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
/**
|
|
78
|
+
* Post-settlement GitHub tracker projection, shared so a task settled under
|
|
79
|
+
* either Host produces the same opted-in terminal projection.
|
|
80
|
+
*
|
|
81
|
+
* Transport, never authority: the projection runs only for a fresh claimless
|
|
82
|
+
* done/stopped projection plus its exact terminal tombstone (`derive` returns
|
|
83
|
+
* null otherwise), a tracker failure is reported as a retryable failure rather
|
|
84
|
+
* than evidence or a blocker, and a failure never repeats the settling Kernel
|
|
85
|
+
* mutation.
|
|
86
|
+
*/
|
|
87
|
+
export async function projectTerminalTrackerState(input: {
|
|
88
|
+
root: string;
|
|
89
|
+
task_id: string;
|
|
90
|
+
projection: AssuranceProjectionResult;
|
|
91
|
+
tombstone: TaskTombstone | null;
|
|
92
|
+
markTerminal: (root: string, projection: GithubTerminalProjectionInput) => Promise<GithubTrackerProjectionResult>;
|
|
93
|
+
}): Promise<GithubTrackerProjectionResult | undefined> {
|
|
94
|
+
if (input.projection.error) return undefined;
|
|
95
|
+
const terminal = deriveGithubTerminalProjectionInput(input.task_id, input.projection, input.tombstone);
|
|
96
|
+
if (!terminal) return undefined;
|
|
97
|
+
try {
|
|
98
|
+
return await input.markTerminal(input.root, terminal);
|
|
99
|
+
} catch {
|
|
100
|
+
return TRACKER_PROJECTION_FAILURE;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The one shape both Hosts report when post-settlement observation fails. */
|
|
105
|
+
export const TRACKER_PROJECTION_FAILURE: GithubTrackerProjectionResult = {
|
|
106
|
+
contract: "immune_brain/github_issue_tracker_result/v1",
|
|
107
|
+
operation: "mark-terminal",
|
|
108
|
+
status: "retryable_failure",
|
|
109
|
+
association_found: false,
|
|
110
|
+
message: "tracker observation failed after authoritative settlement",
|
|
111
|
+
};
|
|
112
|
+
|
|
77
113
|
export interface AssuranceVerdict {
|
|
78
114
|
contract: "assurance_kernel/assurance_verdict/v2";
|
|
79
115
|
role: AssuranceRole;
|
|
@@ -91,8 +127,8 @@ export interface AssuranceVerdict {
|
|
|
91
127
|
acceptance_id: string | null;
|
|
92
128
|
summary: string;
|
|
93
129
|
/**
|
|
94
|
-
* Derived by `parseAssuranceVerdict` for Review-role
|
|
95
|
-
*
|
|
130
|
+
* Derived by `parseAssuranceVerdict` for every Review-role finding, the
|
|
131
|
+
* advisory notes of a pass verdict included. QA findings never carry it.
|
|
96
132
|
*/
|
|
97
133
|
anchor?: string;
|
|
98
134
|
evidence?: FindingEvidence;
|
|
@@ -124,6 +160,24 @@ export function reviewReworkFindings(verdict: AssuranceVerdict): TaskFinding[] {
|
|
|
124
160
|
}));
|
|
125
161
|
}
|
|
126
162
|
|
|
163
|
+
/**
|
|
164
|
+
* The advisory counterpart of `reviewReworkFindings`: a pass verdict may carry
|
|
165
|
+
* non-blocking Review notes, and both Hosts record them on the review
|
|
166
|
+
* attestation itself so settlement can never separate the two.
|
|
167
|
+
*/
|
|
168
|
+
export function reviewAdvisoryRecords(verdict: AssuranceVerdict): ReviewAdvisoryFindingV1[] {
|
|
169
|
+
if (verdict.decision !== "pass") throw new Error("advisory findings require a pass verdict");
|
|
170
|
+
return (verdict.findings ?? [])
|
|
171
|
+
.filter((finding) => finding.kind === "advisory")
|
|
172
|
+
.map((finding) => ({
|
|
173
|
+
id: finding.id,
|
|
174
|
+
acceptance_id: finding.acceptance_id,
|
|
175
|
+
summary: finding.summary,
|
|
176
|
+
anchor: finding.anchor ?? null,
|
|
177
|
+
evidence: finding.evidence ?? null,
|
|
178
|
+
}));
|
|
179
|
+
}
|
|
180
|
+
|
|
127
181
|
export interface ReviewRevisionIdentity {
|
|
128
182
|
contract: "assurance_kernel/review_revision_identity/v1";
|
|
129
183
|
base_head: string;
|
|
@@ -135,6 +189,8 @@ export interface ReviewRevisionIdentity {
|
|
|
135
189
|
export interface SnapshotDescriptor {
|
|
136
190
|
contract: "assurance_kernel/assurance_snapshot/v2";
|
|
137
191
|
task_id: string;
|
|
192
|
+
/** The exact run this snapshot was captured from; null when none is committed. */
|
|
193
|
+
run_id: string | null;
|
|
138
194
|
role: AssuranceRole;
|
|
139
195
|
record_revision: string;
|
|
140
196
|
workspace_revision: string;
|
|
@@ -201,13 +257,11 @@ export interface AssuranceCoordinatorPorts {
|
|
|
201
257
|
projectTask(root: string, taskId: string): Promise<AssuranceProjectionResult>;
|
|
202
258
|
readTaskRecord(root: string, taskId: string): Promise<TaskRecordRead>;
|
|
203
259
|
readTaskIntent(root: string, taskId: string): Promise<TaskIntentRead>;
|
|
204
|
-
frozenRunner(): Promise<FrozenRunner>;
|
|
205
260
|
buildAssurance(
|
|
206
261
|
root: string,
|
|
207
262
|
taskId: string,
|
|
208
263
|
role: AssuranceRole,
|
|
209
264
|
projection: AssuranceProjectionResult,
|
|
210
|
-
runner: FrozenRunner,
|
|
211
265
|
): Promise<{
|
|
212
266
|
snapshot: SnapshotDescriptor;
|
|
213
267
|
descriptors: Map<string, VerificationDescriptor>;
|
|
@@ -227,7 +281,6 @@ export interface AssuranceCoordinatorPorts {
|
|
|
227
281
|
runQa(
|
|
228
282
|
snapshot: SnapshotDescriptor,
|
|
229
283
|
descriptors: Map<string, VerificationDescriptor>,
|
|
230
|
-
runner: FrozenRunner,
|
|
231
284
|
options: { signal?: AbortSignal; onProgress?: (progress: QaVerificationProgress) => void },
|
|
232
285
|
): Promise<AssuranceVerdict>;
|
|
233
286
|
writeReviewEvidence(input: { snapshot: SnapshotDescriptor; evidence: ReviewBundle | ReviewManifestV5 }): { path: string; remove(): void };
|
|
@@ -272,13 +325,19 @@ export const REVIEW_TIMING_PROFILES: Readonly<Record<ReviewWorkload, ReviewTimin
|
|
|
272
325
|
heavy: { softDeadlineSeconds: 20 * 60, stopThresholdSeconds: 60 * 60 },
|
|
273
326
|
};
|
|
274
327
|
|
|
275
|
-
function declaredQaJobTimeoutMs(descriptors: Iterable<
|
|
328
|
+
function declaredQaJobTimeoutMs(descriptors: Iterable<VerificationDescriptor>): number {
|
|
276
329
|
let declaredMs = 0;
|
|
277
|
-
|
|
330
|
+
const prepared = new Set<string>();
|
|
331
|
+
for (const descriptor of descriptors) {
|
|
332
|
+
declaredMs += descriptor.command.timeout_ms;
|
|
333
|
+
const key = JSON.stringify(descriptor.environment);
|
|
334
|
+
if (!prepared.has(key)) declaredMs += descriptor.environment.prepare?.timeout_ms ?? 0;
|
|
335
|
+
prepared.add(key);
|
|
336
|
+
}
|
|
278
337
|
return Math.max(QA_MIN_JOB_TIMEOUT_SECONDS * 1000, declaredMs + QA_JOB_OVERHEAD_SECONDS * 1000);
|
|
279
338
|
}
|
|
280
339
|
|
|
281
|
-
export function deriveQaJobTimeoutMs(descriptors: Iterable<
|
|
340
|
+
export function deriveQaJobTimeoutMs(descriptors: Iterable<VerificationDescriptor>): number {
|
|
282
341
|
const derivedMs = declaredQaJobTimeoutMs(descriptors);
|
|
283
342
|
if (derivedMs > QA_MAX_JOB_TIMEOUT_SECONDS * 1000)
|
|
284
343
|
throw new Error(`declared QA budget ${Math.ceil(derivedMs / 60_000)} minutes exceeds the maximum of 60 minutes`);
|
|
@@ -348,6 +407,7 @@ export function buildReviewPrompt(snapshot: SnapshotDescriptor, evidencePath?: s
|
|
|
348
407
|
"Reserve the final turn for exactly one strict JSON verdict. Reply with ONLY that object, without markdown fences or commentary.",
|
|
349
408
|
`Every rework finding must carry machine-checkable provenance: evidence.trigger (the concrete inputs or state that reach the defect), a non-empty evidence.caller_chain (ordered repository paths or symbols), and evidence.violated {kind: "acceptance"|"security_boundary", ref}. The anchor is derived from that evidence; a finding without it is rejected and the correction must be resubmitted.`,
|
|
350
409
|
`PASS shape: {"contract":"assurance_kernel/assurance_verdict/v2","role":"review","task_id":"${snapshot.task_id}","snapshot_digest":"${digest}","decision":"pass","approval":{"kind":"review","authority_role":"reviewer","summary":"<one line>"}}`,
|
|
410
|
+
`A pass verdict may carry non-blocking notes as findings, but every one of them must set kind "advisory"; a blocking finding is a rework verdict and must omit approval: {"contract":"assurance_kernel/assurance_verdict/v2","role":"review","task_id":"${snapshot.task_id}","snapshot_digest":"${digest}","decision":"pass","approval":{"kind":"review","authority_role":"reviewer","summary":"<one line>"},"findings":[{"id":"review-1","kind":"advisory","acceptance_id":"<id|null>","summary":"<one line>","evidence":{"trigger":"<concrete inputs or state>","caller_chain":["<path-or-symbol>"],"violated":{"kind":"acceptance|security_boundary","ref":"<acceptance id or boundary>"}}}]}`,
|
|
351
411
|
`REWORK shape: {"contract":"assurance_kernel/assurance_verdict/v2","role":"review","task_id":"${snapshot.task_id}","snapshot_digest":"${digest}","decision":"rework","findings":[{"id":"review-1","kind":"blocking|advisory","acceptance_id":"<id|null>","summary":"<one line>","evidence":{"trigger":"<concrete inputs or state>","caller_chain":["<path-or-symbol>"],"violated":{"kind":"acceptance|security_boundary","ref":"<acceptance id or boundary>"}}}]}`,
|
|
352
412
|
`REWORK verdicts must omit the approval field entirely; do not emit "approval": null.`,
|
|
353
413
|
].join("\n");
|
|
@@ -423,12 +483,31 @@ export function parseAssuranceVerdict(input: unknown, snapshot: SnapshotDescript
|
|
|
423
483
|
if (!approval || approval.kind !== expectedKind || approval.authority_role !== expectedRole || typeof approval.summary !== "string" || !approval.summary.trim()) throw new Error("pass verdict approval is invalid");
|
|
424
484
|
const unknownApproval = Object.keys(approval).find((key) => !["kind", "authority_role", "summary"].includes(key));
|
|
425
485
|
if (unknownApproval) throw new Error(`pass verdict approval has unknown field: ${unknownApproval}`);
|
|
426
|
-
|
|
427
|
-
|
|
486
|
+
const passFindings = raw.findings === undefined ? [] : parseVerdictFindings(raw.findings as unknown[], snapshot);
|
|
487
|
+
if (passFindings.some((finding) => finding.kind !== "advisory")) throw new Error("pass verdict findings must all be advisory");
|
|
488
|
+
return {
|
|
489
|
+
contract: "assurance_kernel/assurance_verdict/v2",
|
|
490
|
+
role: snapshot.role,
|
|
491
|
+
task_id: snapshot.task_id,
|
|
492
|
+
snapshot_digest: snapshotDigest(snapshot),
|
|
493
|
+
decision: "pass",
|
|
494
|
+
approval: { kind: expectedKind, authority_role: expectedRole, summary: approval.summary },
|
|
495
|
+
...(passFindings.length > 0 ? { findings: passFindings } : {}),
|
|
496
|
+
};
|
|
428
497
|
}
|
|
429
498
|
if (!Array.isArray(raw.findings) || raw.findings.length === 0) throw new Error("rework verdict findings are invalid");
|
|
430
499
|
if (raw.approval !== undefined && raw.approval !== null) throw new Error("rework verdict must omit approval");
|
|
431
|
-
const findings = raw.findings
|
|
500
|
+
const findings = parseVerdictFindings(raw.findings, snapshot);
|
|
501
|
+
return { contract: "assurance_kernel/assurance_verdict/v2", role: snapshot.role, task_id: snapshot.task_id, snapshot_digest: snapshotDigest(snapshot), decision: "rework", findings };
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Parse one verdict's findings. Review findings carry evidence and derive their
|
|
506
|
+
* anchor here so a capability minted for one anchor set cannot be spent on
|
|
507
|
+
* another; QA findings stay identifier-only.
|
|
508
|
+
*/
|
|
509
|
+
function parseVerdictFindings(rawFindings: unknown[], snapshot: SnapshotDescriptor) {
|
|
510
|
+
return rawFindings.map((item, index) => {
|
|
432
511
|
const finding = item as Record<string, unknown>;
|
|
433
512
|
const allowedFindingKeys =
|
|
434
513
|
snapshot.role === "review"
|
|
@@ -458,7 +537,57 @@ export function parseAssuranceVerdict(input: unknown, snapshot: SnapshotDescript
|
|
|
458
537
|
findings_digest: findingsDigest([normalized]),
|
|
459
538
|
};
|
|
460
539
|
});
|
|
461
|
-
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* The five snapshot fields one Review reservation binds. A reservation is only
|
|
544
|
+
* reusable while all five still match the live projection; each call site adds
|
|
545
|
+
* its own conditions on top of this comparison.
|
|
546
|
+
*/
|
|
547
|
+
export interface ReservationSnapshot {
|
|
548
|
+
record_revision: string;
|
|
549
|
+
workspace_revision: string;
|
|
550
|
+
intent_revision: number;
|
|
551
|
+
intent_content_hash: string;
|
|
552
|
+
diff_hash: string;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* Field names where the reserved snapshot differs from the live projection;
|
|
557
|
+
* an empty result is an exact match on all five. BR-DEC-06 keeps the five
|
|
558
|
+
* fields distinct, so this compares each one instead of a merged digest.
|
|
559
|
+
*/
|
|
560
|
+
export function compareReservationSnapshot(
|
|
561
|
+
snapshot: ReservationSnapshot,
|
|
562
|
+
current: ReservationSnapshot,
|
|
563
|
+
): string[] {
|
|
564
|
+
const fields: Array<keyof ReservationSnapshot> = [
|
|
565
|
+
"record_revision",
|
|
566
|
+
"workspace_revision",
|
|
567
|
+
"intent_revision",
|
|
568
|
+
"intent_content_hash",
|
|
569
|
+
"diff_hash",
|
|
570
|
+
];
|
|
571
|
+
return fields.filter((field) => snapshot[field] !== current[field]);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* `advance()`'s complete Review-ready predicate: claim identity, an active task
|
|
576
|
+
* still awaiting Review, and an exactly matching reservation snapshot. The
|
|
577
|
+
* release-on-mismatch cleanup that follows stays with the caller, because it is
|
|
578
|
+
* a side effect rather than part of this pure decision.
|
|
579
|
+
*/
|
|
580
|
+
export function reservationStillValid(
|
|
581
|
+
reservation: { snapshot: ReservationSnapshot } | undefined,
|
|
582
|
+
projection: AssuranceProjectionResult,
|
|
583
|
+
taskId: string,
|
|
584
|
+
): boolean {
|
|
585
|
+
if (projection.error) return false;
|
|
586
|
+
if (projection.claim?.task_id !== taskId) return false;
|
|
587
|
+
const current = projection.projection;
|
|
588
|
+
if (current.lifecycle !== "active" || current.next_obligation !== "run_review") return false;
|
|
589
|
+
if (!reservation) return false;
|
|
590
|
+
return compareReservationSnapshot(reservation.snapshot, current).length === 0;
|
|
462
591
|
}
|
|
463
592
|
|
|
464
593
|
interface ReviewReservation {
|
|
@@ -544,17 +673,7 @@ export class AssuranceCoordinator {
|
|
|
544
673
|
} catch (error) {
|
|
545
674
|
return { state: "blocked", reason: `cannot validate Review reservation: ${boundedAssuranceError(error)}` };
|
|
546
675
|
}
|
|
547
|
-
const
|
|
548
|
-
const matches = !projection.error
|
|
549
|
-
&& projection.claim?.task_id === taskId
|
|
550
|
-
&& current.lifecycle === "active"
|
|
551
|
-
&& current.next_obligation === "run_review"
|
|
552
|
-
&& reservation !== undefined
|
|
553
|
-
&& reservation.snapshot.record_revision === current.record_revision
|
|
554
|
-
&& reservation.snapshot.workspace_revision === current.workspace_revision
|
|
555
|
-
&& reservation.snapshot.intent_revision === current.intent_revision
|
|
556
|
-
&& reservation.snapshot.intent_content_hash === current.intent_content_hash
|
|
557
|
-
&& reservation.snapshot.diff_hash === current.diff_hash;
|
|
676
|
+
const matches = reservationStillValid(reservation, projection, taskId);
|
|
558
677
|
if (matches) return this.reviewReadyResult(taskId);
|
|
559
678
|
if (reservation) this.releaseReviewReservation(taskId, reservation);
|
|
560
679
|
this.rejectedReviewOperations.delete(taskId);
|
|
@@ -579,6 +698,8 @@ export class AssuranceCoordinator {
|
|
|
579
698
|
// nothing instead of being reported as an unknown settlement.
|
|
580
699
|
let boundaryBaseline: string | null = null;
|
|
581
700
|
let reviewPreparationStarted = false;
|
|
701
|
+
// Set only by the QA job-budget deadline, which is not a host cancellation.
|
|
702
|
+
let qaJobBudgetExceeded = false;
|
|
582
703
|
let phase = "preparing";
|
|
583
704
|
const operationLive = () => this.sessionActive
|
|
584
705
|
&& this.sessionGeneration === operationGeneration
|
|
@@ -592,6 +713,14 @@ export class AssuranceCoordinator {
|
|
|
592
713
|
onUpdate?.({ content: [{ type: "text", text: summary }], details: { state: "running", operation: "qa", operation_id: operationId, stage, ...details } });
|
|
593
714
|
};
|
|
594
715
|
const aborted = () => operationController.signal.aborted;
|
|
716
|
+
// A declared job budget past the ceiling is refused here, before any
|
|
717
|
+
// preparation command runs or any snapshot is captured.
|
|
718
|
+
const qaBudgetFailure = () => ({
|
|
719
|
+
state: "failed" as const,
|
|
720
|
+
operation: "qa" as const,
|
|
721
|
+
operation_id: operationId,
|
|
722
|
+
reason: `${phase}: deterministic QA exceeded its declared job budget before settlement`,
|
|
723
|
+
});
|
|
595
724
|
try {
|
|
596
725
|
ensureOperationLive();
|
|
597
726
|
progress(phase, `Preparing deterministic QA for ${taskId}`);
|
|
@@ -621,7 +750,7 @@ export class AssuranceCoordinator {
|
|
|
621
750
|
if (projection.projection.lifecycle === "active") this.unknownOperations.delete(taskId);
|
|
622
751
|
const parked = await this.ports.readTaskRecord(ctx.cwd, taskId);
|
|
623
752
|
ensureOperationLive();
|
|
624
|
-
if (parked.record?.findings.some((finding) => finding.kind === "replan_required" && finding.status === "open")) return { state: "blocked", reason: "review
|
|
753
|
+
if (parked.record?.findings.some((finding) => finding.kind === "replan_required" && finding.status === "open")) return { state: "blocked", reason: "review requires a durable replan; the task is paused for a user decision" };
|
|
625
754
|
if (projection.projection.artifact_state === "active") {
|
|
626
755
|
if (projection.projection.next_obligation !== "submit_assurance")
|
|
627
756
|
return { state: "blocked", reason: `Kernel requires ${projection.projection.next_obligation}` };
|
|
@@ -657,7 +786,6 @@ export class AssuranceCoordinator {
|
|
|
657
786
|
return { state: "blocked", reason: `Kernel requires ${projection.projection.next_obligation}` };
|
|
658
787
|
const qaAlreadySettled = projection.projection.next_obligation === "run_review";
|
|
659
788
|
if (qaAlreadySettled) reviewPreparationStarted = true;
|
|
660
|
-
const runner = await this.ports.frozenRunner();
|
|
661
789
|
ensureOperationLive();
|
|
662
790
|
// Prove the immutable Review revision before any descriptor runs. A task
|
|
663
791
|
// that cannot publish its revision must stop with zero QA attestation
|
|
@@ -682,18 +810,31 @@ export class AssuranceCoordinator {
|
|
|
682
810
|
progress("capturing_snapshot", "Capturing the immutable QA snapshot");
|
|
683
811
|
await this.ports.qaBeforeProjection?.();
|
|
684
812
|
ensureOperationLive();
|
|
685
|
-
const assurance = await this.ports.buildAssurance(ctx.cwd, taskId, "qa", projection
|
|
813
|
+
const assurance = await this.ports.buildAssurance(ctx.cwd, taskId, "qa", projection);
|
|
686
814
|
ensureOperationLive();
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
815
|
+
// Preparation counts against the same ceiling as the checks, so the
|
|
816
|
+
// aggregate clock starts before the first prepared group runs. A host
|
|
817
|
+
// may only tighten the derived budget, never raise it past the ceiling.
|
|
818
|
+
const declaredQaJobMs = deriveQaJobTimeoutMs(assurance.descriptors.values());
|
|
819
|
+
const qaJobTimeoutMs = Math.min(declaredQaJobMs, this.ports.qaJobTimeoutMs ?? declaredQaJobMs);
|
|
820
|
+
const qaJobDeadline = setTimeout(() => {
|
|
821
|
+
qaJobBudgetExceeded = true;
|
|
822
|
+
operationController.abort(new Error(`deterministic QA exceeded its declared ${Math.ceil(qaJobTimeoutMs / 60_000)} minute job budget`));
|
|
823
|
+
}, qaJobTimeoutMs);
|
|
824
|
+
try {
|
|
825
|
+
qaVerdict = await this.ports.runQa(assurance.snapshot, assurance.descriptors, {
|
|
826
|
+
signal: operationController.signal,
|
|
827
|
+
onProgress: (item) => progress("verifying", `QA ${item.index}/${item.total} ${item.acceptance_id} ${item.phase}`, {
|
|
828
|
+
current: item.index,
|
|
829
|
+
total: item.total,
|
|
830
|
+
acceptance_id: item.acceptance_id,
|
|
831
|
+
acceptance_phase: item.phase,
|
|
832
|
+
elapsed_ms: item.elapsed_ms,
|
|
833
|
+
}),
|
|
834
|
+
});
|
|
835
|
+
} finally {
|
|
836
|
+
clearTimeout(qaJobDeadline);
|
|
837
|
+
}
|
|
697
838
|
ensureOperationLive();
|
|
698
839
|
const invocation = this.openInvocation(taskId);
|
|
699
840
|
try {
|
|
@@ -722,6 +863,7 @@ export class AssuranceCoordinator {
|
|
|
722
863
|
authorityBoundaryStarted = false;
|
|
723
864
|
}
|
|
724
865
|
} catch (error) {
|
|
866
|
+
if (qaJobBudgetExceeded) return qaBudgetFailure();
|
|
725
867
|
if (!authorityCommitted && (aborted() || error instanceof VerificationAbortedError)) return this.cancelled("qa", operationId, "host cancellation before QA authority commit");
|
|
726
868
|
return authorityCommitted
|
|
727
869
|
? this.unknownAfterCommit(taskId, "qa", operationId, boundedAssuranceError(error))
|
|
@@ -764,7 +906,7 @@ export class AssuranceCoordinator {
|
|
|
764
906
|
let review: Awaited<ReturnType<AssuranceCoordinatorPorts["buildAssurance"]>>;
|
|
765
907
|
let evidence: { path: string; remove(): void };
|
|
766
908
|
try {
|
|
767
|
-
review = await this.ports.buildAssurance(ctx.cwd, taskId, "review", fresh
|
|
909
|
+
review = await this.ports.buildAssurance(ctx.cwd, taskId, "review", fresh);
|
|
768
910
|
const payload = review.reviewManifest ?? review.reviewBundle;
|
|
769
911
|
if (!payload) throw new Error("review evidence is missing after QA settlement");
|
|
770
912
|
evidence = this.ports.writeReviewEvidence({ snapshot: review.snapshot, evidence: payload });
|
|
@@ -826,6 +968,7 @@ export class AssuranceCoordinator {
|
|
|
826
968
|
return { state: "failed", operation: "qa", operation_id: operationId, reason: `${phase}: ${boundedAssuranceError(error)}` };
|
|
827
969
|
return this.unknownAfterCommit(taskId, "qa", operationId, `${phase}: ${boundedAssuranceError(error)}`);
|
|
828
970
|
}
|
|
971
|
+
if (qaJobBudgetExceeded) return qaBudgetFailure();
|
|
829
972
|
if (aborted() || error instanceof VerificationAbortedError) return this.cancelled("qa", operationId, `${phase}: host cancellation`);
|
|
830
973
|
return { state: "failed", operation: "qa", operation_id: operationId, reason: `${phase}: ${boundedAssuranceError(error)}` };
|
|
831
974
|
} finally {
|
|
@@ -851,7 +994,7 @@ export class AssuranceCoordinator {
|
|
|
851
994
|
this.releaseReviewReservation(taskId, reservation, reason);
|
|
852
995
|
return { state: "blocked", reason };
|
|
853
996
|
}
|
|
854
|
-
if (fresh.error || !fresh.claim || fresh.claim.task_id !== taskId ||
|
|
997
|
+
if (fresh.error || !fresh.claim || fresh.claim.task_id !== taskId || compareReservationSnapshot(reservation.snapshot, fresh.projection).length > 0 || fresh.projection.lifecycle !== reservation.snapshot.lifecycle || fresh.projection.artifact_state !== reservation.snapshot.artifact_state) {
|
|
855
998
|
const reason = fresh.error ?? "assurance snapshot changed before Review submission";
|
|
856
999
|
this.releaseReviewReservation(taskId, reservation, reason);
|
|
857
1000
|
return { state: "blocked", reason };
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
// Disposable QA materialization of one immutable delivery tree.
|
|
2
|
+
// Never binds the user's index, refs, or node_modules.
|
|
3
|
+
|
|
4
|
+
import { execFileSync } from "node:child_process";
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
6
|
+
import { chmodSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, readlinkSync, realpathSync, rmSync } from "node:fs";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
import { dirname, isAbsolute, join, parse, relative, resolve, sep } from "node:path";
|
|
9
|
+
|
|
10
|
+
import type { GitTaskRevisionSnapshot } from "../workspace_scope";
|
|
11
|
+
|
|
12
|
+
export class DeliveryWorkspaceError extends Error {
|
|
13
|
+
constructor(message: string) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.name = "DeliveryWorkspaceError";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface DeliveryWorkspace {
|
|
20
|
+
root: string;
|
|
21
|
+
tree: string;
|
|
22
|
+
seal: string;
|
|
23
|
+
cleanup: () => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function removeTaskOwnedTree(root: string): void {
|
|
27
|
+
const makeDirectoriesWritable = (path: string): void => {
|
|
28
|
+
let stat;
|
|
29
|
+
try { stat = lstatSync(path); }
|
|
30
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return; throw error; }
|
|
31
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) return;
|
|
32
|
+
chmodSync(path, (stat.mode & 0o7777) | 0o700);
|
|
33
|
+
for (const name of readdirSync(path)) makeDirectoriesWritable(join(path, name));
|
|
34
|
+
};
|
|
35
|
+
makeDirectoriesWritable(root);
|
|
36
|
+
rmSync(root, { recursive: true, force: true });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const GIT_OBJECT_ID = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/;
|
|
40
|
+
|
|
41
|
+
function isolatedGitEnv(extra: Record<string, string> = {}): NodeJS.ProcessEnv {
|
|
42
|
+
const env = { ...process.env };
|
|
43
|
+
delete env.GIT_DIR;
|
|
44
|
+
delete env.GIT_WORK_TREE;
|
|
45
|
+
delete env.GIT_INDEX_FILE;
|
|
46
|
+
delete env.GIT_OBJECT_DIRECTORY;
|
|
47
|
+
delete env.GIT_ALTERNATE_OBJECT_DIRECTORIES;
|
|
48
|
+
return {
|
|
49
|
+
...env,
|
|
50
|
+
GIT_CONFIG_NOSYSTEM: "1",
|
|
51
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
52
|
+
GIT_OPTIONAL_LOCKS: "0",
|
|
53
|
+
...extra,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function git(cwd: string, args: string[], extra: Record<string, string> = {}): string {
|
|
58
|
+
return execFileSync("git", args, {
|
|
59
|
+
cwd,
|
|
60
|
+
encoding: "utf8",
|
|
61
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
62
|
+
env: isolatedGitEnv(extra),
|
|
63
|
+
}).trim();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function assertTree(tree: string): void {
|
|
67
|
+
if (!GIT_OBJECT_ID.test(tree)) throw new DeliveryWorkspaceError("delivery tree has invalid identity");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function assertInside(root: string, candidate: string, label: string): void {
|
|
71
|
+
const rel = relative(root, candidate);
|
|
72
|
+
if (rel.startsWith(`..${sep}`) || rel === ".." || isAbsolute(rel))
|
|
73
|
+
throw new DeliveryWorkspaceError(`delivery ${label} escapes materialization: ${rel}`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function resolvedSymlinkTarget(path: string): string {
|
|
77
|
+
const target = readlinkSync(path);
|
|
78
|
+
let current = isAbsolute(target) ? parse(target).root : parse(path).root;
|
|
79
|
+
let pending = (isAbsolute(target)
|
|
80
|
+
? target.split(sep)
|
|
81
|
+
: [...relative(current, dirname(path)).split(sep), ...target.split(sep)]
|
|
82
|
+
).filter(Boolean);
|
|
83
|
+
let links = 0;
|
|
84
|
+
while (pending.length) {
|
|
85
|
+
const part = pending.shift()!;
|
|
86
|
+
if (part === ".") continue;
|
|
87
|
+
if (part === "..") { current = dirname(current); continue; }
|
|
88
|
+
const next = join(current, part);
|
|
89
|
+
try {
|
|
90
|
+
if (!lstatSync(next).isSymbolicLink()) { current = next; continue; }
|
|
91
|
+
if (++links > 40) throw new DeliveryWorkspaceError("delivery symlink chain is too deep");
|
|
92
|
+
const nested = readlinkSync(next);
|
|
93
|
+
if (isAbsolute(nested)) current = parse(nested).root;
|
|
94
|
+
pending = [...nested.split(sep).filter(Boolean), ...pending];
|
|
95
|
+
} catch (error) {
|
|
96
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
97
|
+
current = next;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return current;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function assertNoEscapingSymlinks(root: string, dir = root): void {
|
|
104
|
+
const realRoot = realpathSync(root);
|
|
105
|
+
for (const name of readdirSync(dir)) {
|
|
106
|
+
const path = join(dir, name);
|
|
107
|
+
const stat = lstatSync(path);
|
|
108
|
+
if (stat.isSymbolicLink()) {
|
|
109
|
+
assertInside(realRoot, resolvedSymlinkTarget(path), `symlink ${relative(root, path)}`);
|
|
110
|
+
} else if (stat.isDirectory()) {
|
|
111
|
+
assertNoEscapingSymlinks(root, path);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
export function writeDeliveryTree(sourceRoot: string, snapshot: GitTaskRevisionSnapshot): string {
|
|
118
|
+
const indexDirectory = mkdtempSync(join(tmpdir(), "imm-delivery-index-"));
|
|
119
|
+
try {
|
|
120
|
+
git(sourceRoot, ["read-tree", snapshot.base_tree], {
|
|
121
|
+
GIT_INDEX_FILE: join(indexDirectory, "index"),
|
|
122
|
+
GIT_DIR: join(sourceRoot, ".git"),
|
|
123
|
+
});
|
|
124
|
+
for (const [path, entry] of Object.entries(snapshot.changed_paths)) {
|
|
125
|
+
if (entry.oid && entry.mode) {
|
|
126
|
+
git(sourceRoot, ["update-index", "--add", "--cacheinfo", `${entry.mode},${entry.oid},${path}`], {
|
|
127
|
+
GIT_INDEX_FILE: join(indexDirectory, "index"),
|
|
128
|
+
GIT_DIR: join(sourceRoot, ".git"),
|
|
129
|
+
});
|
|
130
|
+
} else {
|
|
131
|
+
git(sourceRoot, ["update-index", "--force-remove", "--", path], {
|
|
132
|
+
GIT_INDEX_FILE: join(indexDirectory, "index"),
|
|
133
|
+
GIT_DIR: join(sourceRoot, ".git"),
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
const next = git(sourceRoot, ["write-tree"], {
|
|
138
|
+
GIT_INDEX_FILE: join(indexDirectory, "index"),
|
|
139
|
+
GIT_DIR: join(sourceRoot, ".git"),
|
|
140
|
+
});
|
|
141
|
+
assertTree(next);
|
|
142
|
+
return next;
|
|
143
|
+
} finally {
|
|
144
|
+
rmSync(indexDirectory, { recursive: true, force: true });
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// The seal lives in host memory, outside the project. Compare bytes directly:
|
|
149
|
+
// project commands can rewrite their disposable index or porcelain state.
|
|
150
|
+
function workspaceFiles(
|
|
151
|
+
root: string,
|
|
152
|
+
dir = root,
|
|
153
|
+
result: Record<string, string> = Object.create(null) as Record<string, string>,
|
|
154
|
+
): Record<string, string> {
|
|
155
|
+
if (dir === root) result["."] = `${lstatSync(root).mode & 0o7777}:directory`;
|
|
156
|
+
for (const name of readdirSync(dir).sort()) {
|
|
157
|
+
const path = join(dir, name), rel = relative(root, path);
|
|
158
|
+
const stat = lstatSync(path);
|
|
159
|
+
if (stat.isSymbolicLink()) result[rel] = `link:${stat.mode & 0o7777}:${readlinkSync(path)}`;
|
|
160
|
+
else if (stat.isDirectory()) { result[rel] = `${stat.mode & 0o7777}:directory`; workspaceFiles(root, path, result); }
|
|
161
|
+
else if (stat.isFile()) result[rel] = `${stat.mode & 0o7777}:${createHash("sha256").update(readFileSync(path)).digest("hex")}`;
|
|
162
|
+
else throw new DeliveryWorkspaceError("delivery contains an unsupported filesystem entry");
|
|
163
|
+
}
|
|
164
|
+
return result;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function workspaceSeal(root: string): string {
|
|
168
|
+
return JSON.stringify(workspaceFiles(root));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function assertDeliveryClean(root: string, tree: string, seal?: string, writablePaths: string[] = []): void {
|
|
172
|
+
assertTree(tree);
|
|
173
|
+
if (seal === undefined) {
|
|
174
|
+
if (git(root, ["status", "--porcelain", "--untracked-files=all", "--ignored=matching"]))
|
|
175
|
+
throw new DeliveryWorkspaceError("delivery workspace was contaminated");
|
|
176
|
+
if (git(root, ["rev-parse", "HEAD^{tree}"]) !== tree)
|
|
177
|
+
throw new DeliveryWorkspaceError("delivery workspace tree drifted from the frozen identity");
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
if (seal === "[]") {
|
|
181
|
+
for (const name of readdirSync(root)) {
|
|
182
|
+
const stat = lstatSync(join(root, name));
|
|
183
|
+
if (!stat.isDirectory() || name !== ".git") throw new DeliveryWorkspaceError("delivery workspace was contaminated");
|
|
184
|
+
}
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
const expected = JSON.parse(seal) as Record<string, string>;
|
|
188
|
+
for (const path of writablePaths) {
|
|
189
|
+
if (path === "." || path === ".git" || path.startsWith(".git/") || isAbsolute(path) || path.split("/").includes("..")
|
|
190
|
+
|| Object.keys(expected).some(p => p === path || p.startsWith(`${path}/`)))
|
|
191
|
+
throw new DeliveryWorkspaceError("delivery writable path overlaps protected inputs");
|
|
192
|
+
}
|
|
193
|
+
const current = workspaceFiles(root);
|
|
194
|
+
for (const [path, bytes] of Object.entries(expected))
|
|
195
|
+
if (current[path] !== bytes) throw new DeliveryWorkspaceError("delivery protected input was contaminated");
|
|
196
|
+
for (const [path, bytes] of Object.entries(current)) {
|
|
197
|
+
if (Object.hasOwn(expected, path)) continue;
|
|
198
|
+
const permitted = writablePaths.some(p => path === p || path.startsWith(`${p}/`)
|
|
199
|
+
|| (bytes.endsWith(":directory") && p.startsWith(`${path}/`)));
|
|
200
|
+
if (!permitted) throw new DeliveryWorkspaceError("delivery undeclared output was contaminated");
|
|
201
|
+
if (bytes.startsWith("link:")) {
|
|
202
|
+
assertInside(realpathSync(root), resolvedSymlinkTarget(join(root, path)), "generated symlink");
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
export function materializeDeliveryWorkspace(
|
|
209
|
+
sourceRoot: string,
|
|
210
|
+
tree: string,
|
|
211
|
+
): DeliveryWorkspace {
|
|
212
|
+
assertTree(tree);
|
|
213
|
+
const dest = mkdtempSync(join(tmpdir(), "imm-delivery-"));
|
|
214
|
+
const cleanup = () => removeTaskOwnedTree(dest);
|
|
215
|
+
try {
|
|
216
|
+
mkdirSync(dest, { recursive: true });
|
|
217
|
+
const commit = git(sourceRoot, ["commit-tree", tree, "-m", `delivery ${tree}`], {
|
|
218
|
+
GIT_DIR: join(sourceRoot, ".git"),
|
|
219
|
+
GIT_AUTHOR_NAME: "Immune-Brain Assurance",
|
|
220
|
+
GIT_AUTHOR_EMAIL: "assurance@immune-brain.local",
|
|
221
|
+
GIT_AUTHOR_DATE: "1970-01-01T00:00:00 +0000",
|
|
222
|
+
GIT_COMMITTER_NAME: "Immune-Brain Assurance",
|
|
223
|
+
GIT_COMMITTER_EMAIL: "assurance@immune-brain.local",
|
|
224
|
+
GIT_COMMITTER_DATE: "1970-01-01T00:00:00 +0000",
|
|
225
|
+
});
|
|
226
|
+
if (!GIT_OBJECT_ID.test(commit)) throw new DeliveryWorkspaceError("delivery commit write failed");
|
|
227
|
+
git(dest, ["init", "-q"]);
|
|
228
|
+
git(dest, ["fetch", "--depth=1", `file://${resolve(sourceRoot)}`, `${commit}:refs/heads/delivery`]);
|
|
229
|
+
git(dest, ["checkout", "-q", "delivery"]);
|
|
230
|
+
const got = git(dest, ["rev-parse", "HEAD^{tree}"]);
|
|
231
|
+
if (got !== tree) throw new DeliveryWorkspaceError("delivery materialization does not match the frozen tree");
|
|
232
|
+
assertNoEscapingSymlinks(dest);
|
|
233
|
+
const seal = workspaceSeal(dest);
|
|
234
|
+
assertDeliveryClean(dest, tree, seal);
|
|
235
|
+
return { root: dest, tree, seal, cleanup };
|
|
236
|
+
} catch (error) {
|
|
237
|
+
cleanup();
|
|
238
|
+
throw error;
|
|
239
|
+
}
|
|
240
|
+
}
|