@aviaratech/ai-delivery 0.3.3 → 0.3.5
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/CONTRIBUTING.md +36 -9
- package/README.md +134 -21
- package/dist/agent.d.ts +2 -0
- package/dist/agent.js +1 -0
- package/dist/agent.js.map +1 -1
- package/dist/cli.js +43 -2
- package/dist/cli.js.map +1 -1
- package/dist/config/deliveryConfig.d.ts +4 -1
- package/dist/config/deliveryConfig.js +5 -2
- package/dist/config/deliveryConfig.js.map +1 -1
- package/dist/delivery/common.d.ts +1 -1
- package/dist/delivery/common.js +39 -15
- package/dist/delivery/common.js.map +1 -1
- package/dist/delivery/delivery.test.js +251 -2
- package/dist/delivery/delivery.test.js.map +1 -1
- package/dist/delivery/policy.d.ts +19 -4
- package/dist/delivery/policy.js +18 -5
- package/dist/delivery/policy.js.map +1 -1
- package/dist/delivery/stage.d.ts +56 -8
- package/dist/delivery/stage.js +60 -30
- package/dist/delivery/stage.js.map +1 -1
- package/dist/dispatch.js +85 -4
- package/dist/dispatch.js.map +1 -1
- package/dist/fixtures/discovery.d.ts +1 -1
- package/dist/github/client.d.ts +1 -0
- package/dist/github/client.js +4 -1
- package/dist/github/client.js.map +1 -1
- package/dist/github/commandIdentityPolicy.js +2 -0
- package/dist/github/commandIdentityPolicy.js.map +1 -1
- package/dist/issue.js +2 -0
- package/dist/issue.js.map +1 -1
- package/dist/lifecycle.test.js +1159 -66
- package/dist/lifecycle.test.js.map +1 -1
- package/dist/mcp/index.js +1 -1
- package/dist/mcp/tools.d.ts +32 -0
- package/dist/mcp/tools.js +39 -0
- package/dist/mcp/tools.js.map +1 -1
- package/dist/pr.d.ts +4 -4
- package/dist/pr.js +10 -6
- package/dist/pr.js.map +1 -1
- package/dist/review.test.js +163 -0
- package/dist/review.test.js.map +1 -1
- package/dist/services/deliveryAdmission.d.ts +13 -2
- package/dist/services/deliveryAdmission.js +41 -2
- package/dist/services/deliveryAdmission.js.map +1 -1
- package/dist/setup.d.ts +58 -0
- package/dist/setup.js +415 -0
- package/dist/setup.js.map +1 -0
- package/dist/setup.test.d.ts +1 -0
- package/dist/setup.test.js +744 -0
- package/dist/setup.test.js.map +1 -0
- package/dist/utils/atomicJson.js +11 -2
- package/dist/utils/atomicJson.js.map +1 -1
- package/dist/verification.d.ts +10 -1
- package/dist/verification.js +462 -111
- package/dist/verification.js.map +1 -1
- package/package.json +1 -1
- package/plugins/ai-delivery/.claude-plugin/plugin.json +1 -1
- package/plugins/ai-delivery/README.md +1 -1
- package/plugins/ai-delivery/skills/worktree-lifecycle/SKILL.md +3 -0
package/dist/verification.js
CHANGED
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
import { spawn, spawnSync } from 'node:child_process';
|
|
2
|
-
import {
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
4
|
+
import { existsSync, unlinkSync, lstatSync, readFileSync, readdirSync, realpathSync, statfsSync } from 'node:fs';
|
|
3
5
|
import { basename, dirname, join, resolve, sep } from 'node:path';
|
|
4
6
|
import { fileURLToPath } from 'node:url';
|
|
5
7
|
import { z } from 'zod';
|
|
6
8
|
import { loadDeliveryConfig } from './config/deliveryConfig.js';
|
|
7
|
-
import {
|
|
9
|
+
import { assertPrivateFile } from './delivery/common.js';
|
|
10
|
+
import { classifyRepositoryExactRange, assertRepositoryClassificationCurrent, createRepositoryDeliveryEvidence, createRepositoryStageAggregate, createRepositoryStageInput, createRepositoryStageReceipt, digestBytes, digestValue, assertRepositoryStageProof, loadRepositoryStageCheckpoint, loadSelectedRepositoryPolicy, RepositoryClassificationReceiptSchema, RepositoryStageAggregateSchema, RepositoryStageReceiptSchema, writeRepositoryCommandOutput, writeRepositoryDeliveryEvidence, writeRepositoryStageAggregate, writeRepositoryStageCheckpoint, } from './delivery/index.js';
|
|
8
11
|
import { DeliveryError } from './errors.js';
|
|
9
12
|
import { assertClean, changedPaths, coordinate, defaultBaseRef, git, gitCommonDir, gitRoot } from './git.js';
|
|
10
13
|
import { getIssueWorktreeStrict } from './services/worktreeRegistry.js';
|
|
11
|
-
import {
|
|
14
|
+
import { withLock } from './utils/lockfile.js';
|
|
15
|
+
import { ensurePrivateDirectoryDurably, writePrivateJsonFileAtomically } from './utils/atomicJson.js';
|
|
12
16
|
const ResourceSummarySchema = z
|
|
13
17
|
.strictObject({
|
|
14
18
|
bounds: z.strictObject({
|
|
@@ -151,7 +155,18 @@ function scanOutputRoots(roots) {
|
|
|
151
155
|
throw error;
|
|
152
156
|
}
|
|
153
157
|
if (metadata.isSymbolicLink()) {
|
|
154
|
-
|
|
158
|
+
let target;
|
|
159
|
+
try {
|
|
160
|
+
target = realpathSync(path);
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
throw new DeliveryError('Filesystem output observation encountered a broken or cyclic symbolic link.');
|
|
164
|
+
}
|
|
165
|
+
if (!roots.some((root) => target === root || target.startsWith(`${root}${sep}`))) {
|
|
166
|
+
throw new DeliveryError('Filesystem output observation encountered an escaping symbolic link.');
|
|
167
|
+
}
|
|
168
|
+
// Observe physical targets through their declared root only, never traverse aliases.
|
|
169
|
+
return;
|
|
155
170
|
}
|
|
156
171
|
if (metadata.isDirectory()) {
|
|
157
172
|
for (const name of readdirSync(path))
|
|
@@ -165,8 +180,11 @@ function scanOutputRoots(roots) {
|
|
|
165
180
|
if (files.size > 1_000_000)
|
|
166
181
|
throw new DeliveryError('Filesystem output observation exceeded its file limit.');
|
|
167
182
|
};
|
|
168
|
-
for (const root of roots)
|
|
183
|
+
for (const root of roots) {
|
|
184
|
+
if (existsSync(root) && realpathSync(root) !== root)
|
|
185
|
+
throw new DeliveryError('Filesystem output roots must not be symbolic links.');
|
|
169
186
|
visit(root);
|
|
187
|
+
}
|
|
170
188
|
return files;
|
|
171
189
|
}
|
|
172
190
|
function outputBaseline(repoRoot, common, classificationReceiptId, bounds) {
|
|
@@ -418,6 +436,149 @@ async function confirmOwnedCleanup(state) {
|
|
|
418
436
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
419
437
|
}
|
|
420
438
|
}
|
|
439
|
+
/** Signal only birth identities established by this command, including observed detached children. */
|
|
440
|
+
function terminateOwnedProcesses(state) {
|
|
441
|
+
if (!state.sampled || state.rootIdentity === undefined)
|
|
442
|
+
throw new DeliveryError('Owned process identity is unknown.');
|
|
443
|
+
const snapshot = processSnapshot();
|
|
444
|
+
const root = snapshot.get(state.rootPid);
|
|
445
|
+
if (root !== undefined && root.identity !== state.rootIdentity)
|
|
446
|
+
throw new DeliveryError('Owned process group identity changed before cleanup.');
|
|
447
|
+
const alive = [...state.tracked.values()].flatMap((previous) => {
|
|
448
|
+
const current = snapshot.get(previous.pid);
|
|
449
|
+
if (current === undefined || current.status.startsWith('Z'))
|
|
450
|
+
return [];
|
|
451
|
+
if (current.identity !== previous.identity)
|
|
452
|
+
throw new DeliveryError('Tracked process identity changed before cleanup.');
|
|
453
|
+
return [current];
|
|
454
|
+
});
|
|
455
|
+
const group = [...snapshot.values()].filter((member) => member.pgid === state.rootPid && !member.status.startsWith('Z'));
|
|
456
|
+
if (group.length > 0 && !alive.some((member) => member.pgid === state.rootPid))
|
|
457
|
+
throw new DeliveryError('Owned process group has no surviving recorded identity; cleanup is ambiguous.');
|
|
458
|
+
const signal = (pid) => {
|
|
459
|
+
try {
|
|
460
|
+
process.kill(pid, 'SIGKILL');
|
|
461
|
+
}
|
|
462
|
+
catch (error) {
|
|
463
|
+
if (error.code !== 'ESRCH')
|
|
464
|
+
throw error;
|
|
465
|
+
}
|
|
466
|
+
};
|
|
467
|
+
if (group.length > 0)
|
|
468
|
+
signal(-state.rootPid);
|
|
469
|
+
for (const member of alive.reverse())
|
|
470
|
+
signal(member.pid);
|
|
471
|
+
}
|
|
472
|
+
const ProcessIdentitySchema = z.strictObject({ pid: z.number().int().positive(), identity: z.string().min(1) });
|
|
473
|
+
const WriterStateSchema = z
|
|
474
|
+
.strictObject({
|
|
475
|
+
schemaVersion: z.literal('ai-delivery.verification-writer@1'),
|
|
476
|
+
writerId: z.uuid(),
|
|
477
|
+
worktreeDigest: z.string().regex(/^sha256:[a-f0-9]{64}$/u),
|
|
478
|
+
owner: ProcessIdentitySchema,
|
|
479
|
+
command: z.union([
|
|
480
|
+
z.strictObject({ phase: z.literal('idle') }),
|
|
481
|
+
z.strictObject({ phase: z.literal('starting') }),
|
|
482
|
+
z.strictObject({
|
|
483
|
+
phase: z.literal('running'),
|
|
484
|
+
root: ProcessIdentitySchema,
|
|
485
|
+
tracked: z.array(z.strictObject({ ...ProcessIdentitySchema.shape, pgid: z.number().int().positive() })),
|
|
486
|
+
}),
|
|
487
|
+
]),
|
|
488
|
+
stateId: z.string().regex(/^sha256:[a-f0-9]{64}$/u),
|
|
489
|
+
})
|
|
490
|
+
.superRefine((state, ctx) => {
|
|
491
|
+
const { stateId, ...content } = state;
|
|
492
|
+
if (stateId !== digestValue(content))
|
|
493
|
+
ctx.addIssue({ code: 'custom', message: 'Writer state is corrupt.' });
|
|
494
|
+
});
|
|
495
|
+
async function withVerificationWriter(root, operation) {
|
|
496
|
+
const digest = worktreeDigest(root);
|
|
497
|
+
const path = join(gitCommonDir(root), 'ai-delivery', 'writers@1', `${digest.slice(7)}.json`);
|
|
498
|
+
ensurePrivateDirectoryDurably(dirname(path));
|
|
499
|
+
return withLock(path, {
|
|
500
|
+
projectRoot: root,
|
|
501
|
+
timeout: 200,
|
|
502
|
+
operation: async () => {
|
|
503
|
+
const snapshot = processSnapshot();
|
|
504
|
+
const owner = snapshot.get(process.pid);
|
|
505
|
+
if (owner === undefined || owner.status.startsWith('Z'))
|
|
506
|
+
throw new DeliveryError('Verification writer identity is unknown.');
|
|
507
|
+
if (existsSync(path)) {
|
|
508
|
+
const previous = WriterStateSchema.parse(JSON.parse(assertPrivateFile(path).toString('utf8')));
|
|
509
|
+
if (previous.worktreeDigest !== digest)
|
|
510
|
+
throw new DeliveryError('Verification writer belongs to another worktree.');
|
|
511
|
+
const priorOwner = snapshot.get(previous.owner.pid);
|
|
512
|
+
if (priorOwner?.identity === previous.owner.identity && !priorOwner.status.startsWith('Z'))
|
|
513
|
+
throw new DeliveryError('A live verification writer still owns this worktree.');
|
|
514
|
+
if (previous.command.phase === 'starting')
|
|
515
|
+
throw new DeliveryError('Interrupted writer has unknown command ownership; reconcile before retrying.');
|
|
516
|
+
if (previous.command.phase === 'running') {
|
|
517
|
+
const state = {
|
|
518
|
+
rootPid: previous.command.root.pid,
|
|
519
|
+
rootIdentity: previous.command.root.identity,
|
|
520
|
+
sampled: true,
|
|
521
|
+
tracked: new Map(previous.command.tracked.map((member) => [member.pid, { ...member, ppid: 0, rssBytes: 0, status: '' }])),
|
|
522
|
+
};
|
|
523
|
+
terminateOwnedProcesses(state);
|
|
524
|
+
await confirmOwnedCleanup(state);
|
|
525
|
+
reportVerificationProgress({
|
|
526
|
+
state: 'recovered',
|
|
527
|
+
stageId: 'writer',
|
|
528
|
+
completedStages: 0,
|
|
529
|
+
reusedStages: 0,
|
|
530
|
+
remainingStages: 0,
|
|
531
|
+
elapsedMs: 0,
|
|
532
|
+
reason: 'Recorded interrupted command cleanup confirmed; compatible completed stages remain available.',
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
let state = {
|
|
537
|
+
schemaVersion: 'ai-delivery.verification-writer@1',
|
|
538
|
+
writerId: randomUUID(),
|
|
539
|
+
worktreeDigest: digest,
|
|
540
|
+
owner: { pid: owner.pid, identity: owner.identity },
|
|
541
|
+
command: { phase: 'idle' },
|
|
542
|
+
};
|
|
543
|
+
const persist = (command) => {
|
|
544
|
+
state = { ...state, command };
|
|
545
|
+
writePrivateJsonFileAtomically(path, { ...state, stateId: digestValue(state) });
|
|
546
|
+
};
|
|
547
|
+
persist({ phase: 'idle' });
|
|
548
|
+
const writer = {
|
|
549
|
+
starting: () => persist({ phase: 'starting' }),
|
|
550
|
+
observed: (owned) => {
|
|
551
|
+
if (owned.rootIdentity === undefined)
|
|
552
|
+
throw new DeliveryError('Cannot persist an unknown command owner.');
|
|
553
|
+
persist({
|
|
554
|
+
phase: 'running',
|
|
555
|
+
root: { pid: owned.rootPid, identity: owned.rootIdentity },
|
|
556
|
+
tracked: [...owned.tracked.values()].map(({ pid, identity, pgid }) => ({ pid, identity, pgid })),
|
|
557
|
+
});
|
|
558
|
+
},
|
|
559
|
+
idle: () => persist({ phase: 'idle' }),
|
|
560
|
+
assertQuiescent: () => {
|
|
561
|
+
const current = WriterStateSchema.parse(JSON.parse(assertPrivateFile(path).toString('utf8')));
|
|
562
|
+
if (current.writerId !== state.writerId || current.command.phase !== 'idle')
|
|
563
|
+
throw new DeliveryError('Verification writer command quiescence is not confirmed.');
|
|
564
|
+
},
|
|
565
|
+
};
|
|
566
|
+
const releaseWriter = () => {
|
|
567
|
+
const current = WriterStateSchema.parse(JSON.parse(assertPrivateFile(path).toString('utf8')));
|
|
568
|
+
if (current.writerId !== state.writerId)
|
|
569
|
+
throw new DeliveryError('Verification writer ownership changed.');
|
|
570
|
+
if (current.command.phase === 'idle')
|
|
571
|
+
unlinkSync(path);
|
|
572
|
+
};
|
|
573
|
+
try {
|
|
574
|
+
return await operation(writer);
|
|
575
|
+
}
|
|
576
|
+
finally {
|
|
577
|
+
releaseWriter();
|
|
578
|
+
}
|
|
579
|
+
},
|
|
580
|
+
});
|
|
581
|
+
}
|
|
421
582
|
function primaryRoot(worktreeRoot) {
|
|
422
583
|
const common = gitCommonDir(worktreeRoot);
|
|
423
584
|
if (basename(common) !== '.git')
|
|
@@ -433,8 +594,64 @@ function assertRegisteredIssue(worktreeRoot, issueNumber, allowMerged = false) {
|
|
|
433
594
|
}
|
|
434
595
|
assertClean(worktreeRoot);
|
|
435
596
|
}
|
|
436
|
-
function
|
|
437
|
-
return
|
|
597
|
+
function worktreeDigest(root) {
|
|
598
|
+
return digestValue(resolve(root));
|
|
599
|
+
}
|
|
600
|
+
function producerDigest() {
|
|
601
|
+
const root = dirname(fileURLToPath(import.meta.url));
|
|
602
|
+
const code = [];
|
|
603
|
+
const collect = (directory) => {
|
|
604
|
+
for (const entry of readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
605
|
+
const path = join(directory, entry.name);
|
|
606
|
+
if (entry.isDirectory())
|
|
607
|
+
collect(path);
|
|
608
|
+
else if (entry.isFile() && entry.name.endsWith('.js') && !entry.name.endsWith('.test.js'))
|
|
609
|
+
code.push({ path: path.slice(root.length + 1), digest: digestBytes(readFileSync(path)) });
|
|
610
|
+
}
|
|
611
|
+
};
|
|
612
|
+
collect(root);
|
|
613
|
+
const dependencies = new Map();
|
|
614
|
+
const collectDependencies = (manifestPath) => {
|
|
615
|
+
if (dependencies.has(manifestPath))
|
|
616
|
+
return;
|
|
617
|
+
const bytes = readFileSync(manifestPath);
|
|
618
|
+
const manifest = z
|
|
619
|
+
.object({ dependencies: z.record(z.string(), z.string()).optional() })
|
|
620
|
+
.parse(JSON.parse(bytes.toString('utf8')));
|
|
621
|
+
dependencies.set(manifestPath, digestBytes(bytes));
|
|
622
|
+
const require = createRequire(manifestPath);
|
|
623
|
+
for (const name of Object.keys(manifest.dependencies ?? {}).sort()) {
|
|
624
|
+
let resolved;
|
|
625
|
+
try {
|
|
626
|
+
resolved = require.resolve(`${name}/package.json`);
|
|
627
|
+
}
|
|
628
|
+
catch {
|
|
629
|
+
resolved = require.resolve(name);
|
|
630
|
+
}
|
|
631
|
+
let directory = dirname(resolved);
|
|
632
|
+
for (;;) {
|
|
633
|
+
const path = join(directory, 'package.json');
|
|
634
|
+
if (existsSync(path)) {
|
|
635
|
+
const candidate = z.object({ name: z.string().optional() }).parse(JSON.parse(readFileSync(path, 'utf8')));
|
|
636
|
+
if (candidate.name === name) {
|
|
637
|
+
collectDependencies(path);
|
|
638
|
+
break;
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
const parent = dirname(directory);
|
|
642
|
+
if (parent === directory)
|
|
643
|
+
throw new DeliveryError(`Producer dependency identity is unavailable for ${name}.`);
|
|
644
|
+
directory = parent;
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
};
|
|
648
|
+
collectDependencies(join(dirname(root), 'package.json'));
|
|
649
|
+
return digestValue({ code, dependencies: [...dependencies].sort(([a], [b]) => a.localeCompare(b)) });
|
|
650
|
+
}
|
|
651
|
+
function manifestPath(common, headSha, root) {
|
|
652
|
+
return root === undefined
|
|
653
|
+
? join(common, 'ai-delivery', 'runs', `${headSha}.json`)
|
|
654
|
+
: join(common, 'ai-delivery', 'runs@2', worktreeDigest(root).slice(7), `${headSha}.json`);
|
|
438
655
|
}
|
|
439
656
|
function parseRun(value) {
|
|
440
657
|
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
@@ -443,68 +660,115 @@ function parseRun(value) {
|
|
|
443
660
|
const classification = RepositoryClassificationReceiptSchema.parse(input.classification);
|
|
444
661
|
const aggregate = RepositoryStageAggregateSchema.parse(input.aggregate);
|
|
445
662
|
const stageReceipts = RepositoryStageReceiptSchema.array().parse(input.stageReceipts);
|
|
446
|
-
if (
|
|
663
|
+
if (!['ai-delivery.run@1', 'ai-delivery.run@2', 'ai-delivery.run@3'].includes(String(input.schemaVersion)))
|
|
447
664
|
throw new DeliveryError('Run manifest schema version is unsupported.');
|
|
448
|
-
|
|
449
|
-
if (input.schemaVersion === 'ai-delivery.run@1' && input.resources !== undefined) {
|
|
665
|
+
if (input.schemaVersion === 'ai-delivery.run@1' && input.resources !== undefined)
|
|
450
666
|
throw new DeliveryError('Historical run manifest has unexpected resource evidence.');
|
|
451
|
-
|
|
452
|
-
|
|
667
|
+
const resources = input.resources === undefined ? undefined : ResourceSummarySchema.parse(input.resources);
|
|
668
|
+
if (input.schemaVersion === 'ai-delivery.run@2' && resources === undefined)
|
|
669
|
+
throw new DeliveryError('Historical bounded run lacks resource evidence.');
|
|
670
|
+
const writer = input.schemaVersion === 'ai-delivery.run@3'
|
|
671
|
+
? z
|
|
672
|
+
.strictObject({
|
|
673
|
+
worktreeDigest: z.string().regex(/^sha256:[a-f0-9]{64}$/u),
|
|
674
|
+
producerDigest: z.string().regex(/^sha256:[a-f0-9]{64}$/u),
|
|
675
|
+
})
|
|
676
|
+
.parse(input.writer)
|
|
677
|
+
: undefined;
|
|
453
678
|
const content = {
|
|
454
679
|
aggregate,
|
|
455
680
|
classification,
|
|
456
681
|
completedAt: input.completedAt,
|
|
457
682
|
...(resources === undefined ? {} : { resources }),
|
|
683
|
+
...(writer === undefined ? {} : { writer }),
|
|
458
684
|
schemaVersion: input.schemaVersion,
|
|
459
685
|
stageReceipts,
|
|
460
686
|
};
|
|
461
|
-
if (typeof input.completedAt !== 'string' ||
|
|
462
|
-
|
|
687
|
+
if (typeof input.completedAt !== 'string' ||
|
|
688
|
+
input.manifestId !== digestValue(content) ||
|
|
689
|
+
createRepositoryStageAggregate({ classification, receipts: stageReceipts }).aggregateId !== aggregate.aggregateId)
|
|
690
|
+
throw new DeliveryError('Run manifest identity or aggregate is invalid.');
|
|
463
691
|
return { ...content, completedAt: input.completedAt, manifestId: input.manifestId };
|
|
464
692
|
}
|
|
465
|
-
function loadRun(common, headSha) {
|
|
693
|
+
function loadRun(common, headSha, root, historical = false) {
|
|
466
694
|
let raw;
|
|
467
695
|
try {
|
|
468
|
-
raw = JSON.parse(readFileSync(manifestPath(common, headSha), 'utf8'));
|
|
696
|
+
raw = JSON.parse(readFileSync(manifestPath(common, headSha, root), 'utf8'));
|
|
469
697
|
}
|
|
470
|
-
catch {
|
|
471
|
-
|
|
698
|
+
catch (error) {
|
|
699
|
+
if (!historical || error.code !== 'ENOENT')
|
|
700
|
+
throw new DeliveryError('Exact-head worktree verification run is missing or corrupt; verify with the current producer.');
|
|
701
|
+
raw = JSON.parse(readFileSync(manifestPath(common, headSha), 'utf8'));
|
|
472
702
|
}
|
|
473
|
-
|
|
703
|
+
const run = parseRun(raw);
|
|
704
|
+
if (run.classification.head.sha !== headSha ||
|
|
705
|
+
(run.writer !== undefined &&
|
|
706
|
+
(run.writer.worktreeDigest !== worktreeDigest(root) ||
|
|
707
|
+
(!historical && run.writer.producerDigest !== producerDigest()))) ||
|
|
708
|
+
(!historical && run.writer === undefined))
|
|
709
|
+
throw new DeliveryError('Verification run belongs to another writer or producer.');
|
|
710
|
+
const expectedEnvironment = historical ? undefined : verificationEnvironmentDigest(root, run.resources);
|
|
711
|
+
if (expectedEnvironment !== undefined &&
|
|
712
|
+
run.stageReceipts.some((receipt) => receipt.input.environmentDigest !== expectedEnvironment))
|
|
713
|
+
throw new DeliveryError('Verification stage environment belongs to a foreign worktree or producer.');
|
|
714
|
+
return run;
|
|
474
715
|
}
|
|
475
|
-
function stageEnvironmentDigest() {
|
|
716
|
+
function stageEnvironmentDigest(root, producer = producerDigest()) {
|
|
476
717
|
return digestValue({
|
|
477
718
|
arch: process.arch,
|
|
478
719
|
node: process.version,
|
|
479
720
|
path: process.env.PATH ?? '',
|
|
480
721
|
platform: process.platform,
|
|
722
|
+
worktreeDigest: worktreeDigest(root),
|
|
723
|
+
producerDigest: producer,
|
|
481
724
|
});
|
|
482
725
|
}
|
|
726
|
+
function verificationEnvironmentDigest(root, resources, producer = producerDigest()) {
|
|
727
|
+
const environment = stageEnvironmentDigest(root, producer);
|
|
728
|
+
return resources === undefined
|
|
729
|
+
? environment
|
|
730
|
+
: digestValue({
|
|
731
|
+
environment,
|
|
732
|
+
...(resources.outputBaselineId === undefined ? {} : { outputBaselineId: resources.outputBaselineId }),
|
|
733
|
+
resourceBounds: resources.bounds,
|
|
734
|
+
resourceRunner: digestBytes(readFileSync(fileURLToPath(import.meta.url))),
|
|
735
|
+
});
|
|
736
|
+
}
|
|
483
737
|
function reportVerificationProgress(input) {
|
|
484
738
|
process.stderr.write(`ai-delivery.verify ${JSON.stringify(input)}\n`);
|
|
485
739
|
}
|
|
486
|
-
function runStageCommand(repoRoot, argv, abortSignal, onRunning, resourceBounds, baseline, onResourceSample) {
|
|
740
|
+
function runStageCommand(repoRoot, argv, abortSignal, onRunning, resourceBounds, baseline, onResourceSample, writer) {
|
|
487
741
|
const [executable, ...args] = argv;
|
|
488
742
|
if (!executable)
|
|
489
743
|
throw new DeliveryError('Policy selected an empty stage command.');
|
|
490
744
|
if (abortSignal?.aborted)
|
|
491
745
|
throw new DeliveryError('Selected policy stage command cancelled.');
|
|
746
|
+
writer?.starting();
|
|
492
747
|
return new Promise((resolve, reject) => {
|
|
493
|
-
|
|
748
|
+
// The shell waits for our ownership receipt, then exec preserves its PID, birth identity and process group.
|
|
749
|
+
const child = spawn('/bin/sh', [
|
|
750
|
+
'-c',
|
|
751
|
+
'IFS= read -r ready || exit 125; [ "$ready" = run ] || exit 125; exec "$@" </dev/null',
|
|
752
|
+
'ai-delivery-command',
|
|
753
|
+
executable,
|
|
754
|
+
...args,
|
|
755
|
+
], {
|
|
494
756
|
cwd: repoRoot,
|
|
495
757
|
detached: process.platform !== 'win32',
|
|
496
|
-
stdio: ['
|
|
758
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
497
759
|
});
|
|
498
760
|
const stdout = [];
|
|
499
761
|
const stderr = [];
|
|
500
|
-
const owned =
|
|
762
|
+
const owned = process.platform === 'win32' || child.pid === undefined
|
|
501
763
|
? undefined
|
|
502
764
|
: { rootPid: child.pid, sampled: false, tracked: new Map() };
|
|
503
765
|
let outputBytes = 0;
|
|
504
766
|
let failure;
|
|
505
767
|
let cleanupFailure;
|
|
768
|
+
let writerFailure;
|
|
506
769
|
let closed = false;
|
|
507
770
|
let settled = false;
|
|
771
|
+
let commandReleased = false;
|
|
508
772
|
let closeTimer;
|
|
509
773
|
let exitPipeTimer;
|
|
510
774
|
let resourceTimer;
|
|
@@ -523,57 +787,37 @@ function runStageCommand(repoRoot, argv, abortSignal, onRunning, resourceBounds,
|
|
|
523
787
|
const killOwned = () => {
|
|
524
788
|
if (child.pid === undefined)
|
|
525
789
|
return;
|
|
526
|
-
if (process.platform === 'win32') {
|
|
527
|
-
if (!child.kill('SIGKILL') && child.exitCode === null && child.signalCode === null)
|
|
528
|
-
cleanupFailure ??= 'could not signal the owned child';
|
|
529
|
-
return;
|
|
530
|
-
}
|
|
531
|
-
let groupIsOwned = resourceBounds === undefined;
|
|
532
790
|
if (owned?.sampled) {
|
|
533
791
|
try {
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
continue;
|
|
549
|
-
}
|
|
550
|
-
try {
|
|
551
|
-
process.kill(previous.pid, 'SIGKILL');
|
|
552
|
-
}
|
|
553
|
-
catch (error) {
|
|
554
|
-
if (error.code !== 'ESRCH') {
|
|
555
|
-
cleanupFailure ??= error instanceof Error ? error.message : String(error);
|
|
556
|
-
}
|
|
557
|
-
}
|
|
558
|
-
}
|
|
792
|
+
observeOwnedProcesses(owned, processSnapshot());
|
|
793
|
+
}
|
|
794
|
+
catch (error) {
|
|
795
|
+
cleanupFailure ??= error instanceof Error ? error.message : String(error);
|
|
796
|
+
}
|
|
797
|
+
try {
|
|
798
|
+
writer?.observed(owned);
|
|
799
|
+
}
|
|
800
|
+
catch (error) {
|
|
801
|
+
writerFailure ??= error instanceof Error ? error.message : String(error);
|
|
802
|
+
}
|
|
803
|
+
// Durable state failure cannot prevent identity-checked termination of known-owned processes.
|
|
804
|
+
try {
|
|
805
|
+
terminateOwnedProcesses(owned);
|
|
559
806
|
}
|
|
560
807
|
catch (error) {
|
|
561
808
|
cleanupFailure ??= error instanceof Error ? error.message : String(error);
|
|
562
|
-
groupIsOwned = child.exitCode === null && child.signalCode === null;
|
|
563
809
|
}
|
|
564
810
|
}
|
|
565
|
-
else
|
|
566
|
-
groupIsOwned = child.exitCode === null && child.signalCode === null;
|
|
567
|
-
cleanupFailure ??= 'owned process identity was not observed before cleanup';
|
|
568
|
-
}
|
|
569
|
-
if (groupIsOwned) {
|
|
811
|
+
else {
|
|
570
812
|
try {
|
|
571
|
-
|
|
813
|
+
// The still-owned direct child is the only safe signal target before identity observation.
|
|
814
|
+
if (child.exitCode === null && child.signalCode === null)
|
|
815
|
+
child.kill('SIGKILL');
|
|
816
|
+
if (commandReleased)
|
|
817
|
+
cleanupFailure ??= 'owned process identity was not observed before cleanup';
|
|
572
818
|
}
|
|
573
819
|
catch (error) {
|
|
574
|
-
|
|
575
|
-
cleanupFailure ??= error instanceof Error ? error.message : String(error);
|
|
576
|
-
}
|
|
820
|
+
cleanupFailure ??= error instanceof Error ? error.message : String(error);
|
|
577
821
|
}
|
|
578
822
|
}
|
|
579
823
|
};
|
|
@@ -591,7 +835,7 @@ function runStageCommand(repoRoot, argv, abortSignal, onRunning, resourceBounds,
|
|
|
591
835
|
child.stdout.destroy();
|
|
592
836
|
child.stderr.destroy();
|
|
593
837
|
child.unref();
|
|
594
|
-
reject(new DeliveryError(`Selected policy stage command failed (${failure})
|
|
838
|
+
reject(new DeliveryError(`Selected policy stage command failed (${failure}).${writerFailure === undefined ? '' : ` Writer state persistence failed (${writerFailure}).`} Owned process cleanup failed (owned command pipes did not close; descendant cleanup could not be verified).`));
|
|
595
839
|
}, 2_000);
|
|
596
840
|
}
|
|
597
841
|
};
|
|
@@ -607,7 +851,12 @@ function runStageCommand(repoRoot, argv, abortSignal, onRunning, resourceBounds,
|
|
|
607
851
|
child.stderr.on('data', (chunk) => capture(stderr, chunk));
|
|
608
852
|
child.stdout.once('error', (error) => stop(error.message));
|
|
609
853
|
child.stderr.once('error', (error) => stop(error.message));
|
|
610
|
-
child.once('error', (error) => stop(error.message));
|
|
854
|
+
child.stdin.once('error', (error) => stop(error.message));
|
|
855
|
+
child.once('error', (error) => {
|
|
856
|
+
if (child.pid === undefined)
|
|
857
|
+
writer?.idle();
|
|
858
|
+
stop(error.message);
|
|
859
|
+
});
|
|
611
860
|
child.once('exit', (code, signal) => {
|
|
612
861
|
if (code !== 0 || signal !== null)
|
|
613
862
|
stop(`exit ${String(code)}${signal === null ? '' : ` signal ${signal}`}`);
|
|
@@ -618,27 +867,33 @@ function runStageCommand(repoRoot, argv, abortSignal, onRunning, resourceBounds,
|
|
|
618
867
|
}, 1_000);
|
|
619
868
|
}
|
|
620
869
|
});
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
870
|
+
child.once('spawn', () => {
|
|
871
|
+
const sample = () => {
|
|
872
|
+
if (failure !== undefined || child.pid === undefined)
|
|
873
|
+
return;
|
|
874
|
+
try {
|
|
875
|
+
if (owned === undefined)
|
|
876
|
+
throw new DeliveryError('Owned process identity is unavailable on this platform.');
|
|
877
|
+
observeOwnedProcesses(owned, processSnapshot());
|
|
878
|
+
writer?.observed(owned);
|
|
879
|
+
if (resourceBounds !== undefined) {
|
|
629
880
|
const result = sampleOwnedTree(owned, repoRoot, resourceBounds, baseline);
|
|
630
881
|
onResourceSample?.(result);
|
|
631
882
|
onRunning?.(outputBytes, result);
|
|
632
883
|
}
|
|
633
|
-
|
|
634
|
-
|
|
884
|
+
if (!commandReleased) {
|
|
885
|
+
commandReleased = true;
|
|
886
|
+
child.stdin.end('run\n');
|
|
635
887
|
}
|
|
636
|
-
}
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
}
|
|
641
|
-
|
|
888
|
+
}
|
|
889
|
+
catch (error) {
|
|
890
|
+
stop(error instanceof Error ? error.message : String(error));
|
|
891
|
+
}
|
|
892
|
+
};
|
|
893
|
+
sample();
|
|
894
|
+
resourceTimer = setInterval(sample, 1_000);
|
|
895
|
+
resourceTimer.unref();
|
|
896
|
+
});
|
|
642
897
|
const progressTimer = setInterval(() => onRunning?.(outputBytes), 5_000);
|
|
643
898
|
progressTimer.unref();
|
|
644
899
|
const onAbort = () => stop('cancelled');
|
|
@@ -656,12 +911,13 @@ function runStageCommand(repoRoot, argv, abortSignal, onRunning, resourceBounds,
|
|
|
656
911
|
clearWatchers();
|
|
657
912
|
if (failure === undefined && owned !== undefined) {
|
|
658
913
|
try {
|
|
659
|
-
if (!owned.sampled)
|
|
660
|
-
throw new DeliveryError('Owned process resource observation was unavailable.');
|
|
661
914
|
if (resourceBounds !== undefined) {
|
|
662
|
-
const
|
|
663
|
-
onResourceSample?.(
|
|
915
|
+
const sample = sampleOwnedTree(owned, repoRoot, resourceBounds, baseline);
|
|
916
|
+
onResourceSample?.(sample);
|
|
917
|
+
onRunning?.(outputBytes, sample);
|
|
664
918
|
}
|
|
919
|
+
if (!owned.sampled)
|
|
920
|
+
throw new DeliveryError('Owned process resource observation was unavailable.');
|
|
665
921
|
const remaining = observeOwnedProcesses(owned, processSnapshot());
|
|
666
922
|
if (remaining.length > 0)
|
|
667
923
|
throw new DeliveryError('A sampled owned descendant survived command exit.');
|
|
@@ -672,18 +928,29 @@ function runStageCommand(repoRoot, argv, abortSignal, onRunning, resourceBounds,
|
|
|
672
928
|
}
|
|
673
929
|
if (failure !== undefined || code !== 0 || signal !== null) {
|
|
674
930
|
const reason = failure ?? `exit ${String(code)}${signal === null ? '' : ` signal ${signal}`}`;
|
|
675
|
-
|
|
931
|
+
let cleanupConfirmed = child.pid === undefined || !commandReleased;
|
|
932
|
+
if (owned !== undefined && owned.sampled) {
|
|
676
933
|
try {
|
|
677
934
|
await confirmOwnedCleanup(owned);
|
|
935
|
+
cleanupConfirmed = true;
|
|
678
936
|
}
|
|
679
937
|
catch (error) {
|
|
680
938
|
cleanupFailure ??= error instanceof Error ? error.message : String(error);
|
|
681
939
|
}
|
|
682
940
|
}
|
|
941
|
+
if (cleanupConfirmed) {
|
|
942
|
+
try {
|
|
943
|
+
writer?.idle();
|
|
944
|
+
}
|
|
945
|
+
catch (error) {
|
|
946
|
+
writerFailure ??= error instanceof Error ? error.message : String(error);
|
|
947
|
+
}
|
|
948
|
+
}
|
|
683
949
|
settled = true;
|
|
684
|
-
reject(new DeliveryError(`Selected policy stage command failed (${reason}).${cleanupFailure === undefined ? '' : ` Owned process cleanup failed (${cleanupFailure}).`}`));
|
|
950
|
+
reject(new DeliveryError(`Selected policy stage command failed (${reason}).${writerFailure === undefined ? '' : ` Writer state persistence failed (${writerFailure}).`}${cleanupFailure === undefined ? '' : ` Owned process cleanup failed (${cleanupFailure}).`}`));
|
|
685
951
|
return;
|
|
686
952
|
}
|
|
953
|
+
writer?.idle();
|
|
687
954
|
settled = true;
|
|
688
955
|
resolve(Buffer.concat([...stdout, ...stderr], outputBytes));
|
|
689
956
|
};
|
|
@@ -696,6 +963,10 @@ function runStageCommand(repoRoot, argv, abortSignal, onRunning, resourceBounds,
|
|
|
696
963
|
});
|
|
697
964
|
}
|
|
698
965
|
export async function verifyIssue(input) {
|
|
966
|
+
const root = gitRoot(input.repoRoot);
|
|
967
|
+
return withVerificationWriter(root, (writer) => verifyIssueOwned(input, writer));
|
|
968
|
+
}
|
|
969
|
+
async function verifyIssueOwned(input, writer) {
|
|
699
970
|
const assertNotCancelled = () => {
|
|
700
971
|
if (input.signal?.aborted)
|
|
701
972
|
throw new DeliveryError('Verification cancelled.');
|
|
@@ -732,6 +1003,21 @@ export async function verifyIssue(input) {
|
|
|
732
1003
|
const admitted = new Set(input.admittedResourceClasses ?? ['source_only']);
|
|
733
1004
|
const startedAtMs = Date.now();
|
|
734
1005
|
let reusedStages = 0;
|
|
1006
|
+
let completedCommands = 0;
|
|
1007
|
+
let reusedCommands = 0;
|
|
1008
|
+
let lastCompletedAtMs = startedAtMs;
|
|
1009
|
+
const totalCommands = classification.requiredStages.reduce((total, stage) => total + stage.commands.length, 0);
|
|
1010
|
+
const report = (progress) => {
|
|
1011
|
+
const elapsedMs = Date.now() - startedAtMs;
|
|
1012
|
+
reportVerificationProgress({
|
|
1013
|
+
...progress,
|
|
1014
|
+
completedCommands,
|
|
1015
|
+
remainingCommands: totalCommands - completedCommands,
|
|
1016
|
+
reusedCommands,
|
|
1017
|
+
executedCommandsPerSecond: elapsedMs === 0 ? 0 : ((completedCommands - reusedCommands) * 1000) / elapsedMs,
|
|
1018
|
+
lastCompletedWorkAgeMs: Date.now() - lastCompletedAtMs,
|
|
1019
|
+
});
|
|
1020
|
+
};
|
|
735
1021
|
const normalizedBounds = input.resourceBounds === undefined
|
|
736
1022
|
? undefined
|
|
737
1023
|
: {
|
|
@@ -753,14 +1039,8 @@ export async function verifyIssue(input) {
|
|
|
753
1039
|
sampleCount: 0,
|
|
754
1040
|
});
|
|
755
1041
|
const resources = normalizedBounds === undefined ? undefined : emptyResources();
|
|
756
|
-
const
|
|
757
|
-
|
|
758
|
-
: digestValue({
|
|
759
|
-
environment: stageEnvironmentDigest(),
|
|
760
|
-
...(baseline === undefined ? {} : { outputBaselineId: baseline.baselineId }),
|
|
761
|
-
resourceBounds: normalizedBounds,
|
|
762
|
-
resourceRunner: digestBytes(readFileSync(fileURLToPath(import.meta.url))),
|
|
763
|
-
});
|
|
1042
|
+
const producer = producerDigest();
|
|
1043
|
+
const environmentDigest = verificationEnvironmentDigest(root, resources, producer);
|
|
764
1044
|
let stageResources;
|
|
765
1045
|
const recordResourceSample = (sample) => {
|
|
766
1046
|
if (resources === undefined)
|
|
@@ -784,10 +1064,11 @@ export async function verifyIssue(input) {
|
|
|
784
1064
|
const stageInput = createRepositoryStageInput({
|
|
785
1065
|
classification,
|
|
786
1066
|
environmentDigest,
|
|
787
|
-
semanticInputs: stage.
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
1067
|
+
semanticInputs: stage.semanticInputs ??
|
|
1068
|
+
stage.semanticInputKeys.map((key) => ({
|
|
1069
|
+
digest: digestValue({ key, evidenceId: classification.policyEvidence.evidenceId }),
|
|
1070
|
+
key,
|
|
1071
|
+
})),
|
|
791
1072
|
stageId: stage.id,
|
|
792
1073
|
upstream: stage.dependsOn.map((stageId) => {
|
|
793
1074
|
const receipt = receipts.find((value) => value.input.stageId === stageId);
|
|
@@ -830,7 +1111,10 @@ export async function verifyIssue(input) {
|
|
|
830
1111
|
if (cached) {
|
|
831
1112
|
receipts.push(cached);
|
|
832
1113
|
reusedStages += 1;
|
|
833
|
-
|
|
1114
|
+
completedCommands += cached.commands.length;
|
|
1115
|
+
reusedCommands += cached.commands.length;
|
|
1116
|
+
lastCompletedAtMs = Date.now();
|
|
1117
|
+
report({
|
|
834
1118
|
state: 'reused',
|
|
835
1119
|
stageId: stage.id,
|
|
836
1120
|
completedStages: receipts.length,
|
|
@@ -845,9 +1129,10 @@ export async function verifyIssue(input) {
|
|
|
845
1129
|
const startedAt = new Date().toISOString();
|
|
846
1130
|
const commands = [];
|
|
847
1131
|
for (const command of stage.commands) {
|
|
1132
|
+
const commandStartedAtMs = Date.now();
|
|
848
1133
|
if (input.resourceBounds !== undefined)
|
|
849
1134
|
assertDiskHeadroom(root, input.resourceBounds, baseline);
|
|
850
|
-
const progress = (capturedOutputBytes, sample) =>
|
|
1135
|
+
const progress = (capturedOutputBytes, sample) => report({
|
|
851
1136
|
state: 'running',
|
|
852
1137
|
stageId: stage.id,
|
|
853
1138
|
commandLabel: command.label,
|
|
@@ -856,6 +1141,7 @@ export async function verifyIssue(input) {
|
|
|
856
1141
|
reusedStages,
|
|
857
1142
|
elapsedMs: Date.now() - startedAtMs,
|
|
858
1143
|
capturedOutputBytes,
|
|
1144
|
+
commandElapsedMs: Date.now() - commandStartedAtMs,
|
|
859
1145
|
...(sample === undefined
|
|
860
1146
|
? {}
|
|
861
1147
|
: {
|
|
@@ -868,10 +1154,10 @@ export async function verifyIssue(input) {
|
|
|
868
1154
|
progress(0);
|
|
869
1155
|
let bytes;
|
|
870
1156
|
try {
|
|
871
|
-
bytes = await runStageCommand(root, command.argv, input.signal, progress, input.resourceBounds, baseline, recordResourceSample);
|
|
1157
|
+
bytes = await runStageCommand(root, command.argv, input.signal, progress, input.resourceBounds, baseline, recordResourceSample, writer);
|
|
872
1158
|
}
|
|
873
1159
|
catch (error) {
|
|
874
|
-
|
|
1160
|
+
report({
|
|
875
1161
|
state: 'failed',
|
|
876
1162
|
stageId: stage.id,
|
|
877
1163
|
commandLabel: command.label,
|
|
@@ -884,6 +1170,8 @@ export async function verifyIssue(input) {
|
|
|
884
1170
|
throw error;
|
|
885
1171
|
}
|
|
886
1172
|
assertNotCancelled();
|
|
1173
|
+
completedCommands += 1;
|
|
1174
|
+
lastCompletedAtMs = Date.now();
|
|
887
1175
|
commands.push({
|
|
888
1176
|
exitCode: 0,
|
|
889
1177
|
label: command.label,
|
|
@@ -912,7 +1200,8 @@ export async function verifyIssue(input) {
|
|
|
912
1200
|
writeRepositoryStageCheckpoint({ gitCommonDir: common, receipt, repoRoot: root });
|
|
913
1201
|
stageResources = undefined;
|
|
914
1202
|
receipts.push(receipt);
|
|
915
|
-
|
|
1203
|
+
lastCompletedAtMs = Date.now();
|
|
1204
|
+
report({
|
|
916
1205
|
state: 'completed',
|
|
917
1206
|
stageId: stage.id,
|
|
918
1207
|
completedStages: receipts.length,
|
|
@@ -922,6 +1211,16 @@ export async function verifyIssue(input) {
|
|
|
922
1211
|
});
|
|
923
1212
|
}
|
|
924
1213
|
assertNotCancelled();
|
|
1214
|
+
assertRepositoryClassificationCurrent({
|
|
1215
|
+
classification,
|
|
1216
|
+
configDigest: loaded.configDigest,
|
|
1217
|
+
policySourcePath: loaded.config.policy.module,
|
|
1218
|
+
repoRoot: root,
|
|
1219
|
+
});
|
|
1220
|
+
if (coordinate(root).sha !== head.sha || coordinate(root, defaultBaseRef(root, loaded.remote)).sha !== base.sha)
|
|
1221
|
+
throw new DeliveryError('Verification source changed during execution; classify and resume the current source.');
|
|
1222
|
+
if (producerDigest() !== producer)
|
|
1223
|
+
throw new DeliveryError('Verification producer changed during execution.');
|
|
925
1224
|
const aggregate = createRepositoryStageAggregate({ classification, receipts });
|
|
926
1225
|
writeRepositoryStageAggregate({ gitCommonDir: common, aggregate });
|
|
927
1226
|
const content = {
|
|
@@ -929,11 +1228,12 @@ export async function verifyIssue(input) {
|
|
|
929
1228
|
classification,
|
|
930
1229
|
completedAt: new Date().toISOString(),
|
|
931
1230
|
...(resources === undefined ? {} : { resources: ResourceSummarySchema.parse(resources) }),
|
|
932
|
-
schemaVersion:
|
|
1231
|
+
schemaVersion: 'ai-delivery.run@3',
|
|
1232
|
+
writer: { worktreeDigest: worktreeDigest(root), producerDigest: producer },
|
|
933
1233
|
stageReceipts: receipts,
|
|
934
1234
|
};
|
|
935
1235
|
const run = { ...content, manifestId: digestValue(content) };
|
|
936
|
-
writePrivateJsonFileAtomically(manifestPath(common, head.sha), run);
|
|
1236
|
+
writePrivateJsonFileAtomically(manifestPath(common, head.sha, root), run);
|
|
937
1237
|
return run;
|
|
938
1238
|
}
|
|
939
1239
|
export async function createIssuePhaseEvidence(input) {
|
|
@@ -944,7 +1244,7 @@ export async function createIssuePhaseEvidence(input) {
|
|
|
944
1244
|
repoRoot: root,
|
|
945
1245
|
policySourcePath: loaded.config.policy.module,
|
|
946
1246
|
});
|
|
947
|
-
const run = loadRun(gitCommonDir(root), coordinate(root).sha);
|
|
1247
|
+
const run = loadRun(gitCommonDir(root), coordinate(root).sha, root);
|
|
948
1248
|
const evidence = createRepositoryDeliveryEvidence({
|
|
949
1249
|
aggregate: run.aggregate,
|
|
950
1250
|
...(input.approval === undefined ? {} : { approval: input.approval }),
|
|
@@ -970,7 +1270,8 @@ export async function createIssuePhaseEvidence(input) {
|
|
|
970
1270
|
export function loadVerifiedRun(repoRoot, issueNumber) {
|
|
971
1271
|
const root = gitRoot(repoRoot);
|
|
972
1272
|
assertRegisteredIssue(root, issueNumber, true);
|
|
973
|
-
|
|
1273
|
+
const historical = getIssueWorktreeStrict(issueNumber, primaryRoot(root)).status === 'merged';
|
|
1274
|
+
return loadRun(gitCommonDir(root), coordinate(root).sha, root, historical);
|
|
974
1275
|
}
|
|
975
1276
|
/** Recover an exact merged run after worktree removal interrupted terminal recording. */
|
|
976
1277
|
export function loadRemovedMergedRun(primaryRepoRoot, issueNumber) {
|
|
@@ -980,10 +1281,60 @@ export function loadRemovedMergedRun(primaryRepoRoot, issueNumber) {
|
|
|
980
1281
|
throw new DeliveryError('Removed-run recovery requires the exact registered merged issue.');
|
|
981
1282
|
}
|
|
982
1283
|
const headSha = git(root, 'rev-parse', `refs/heads/${row.branch}`);
|
|
983
|
-
const run = loadRun(gitCommonDir(root), headSha);
|
|
1284
|
+
const run = loadRun(gitCommonDir(root), headSha, row.path, true);
|
|
984
1285
|
if (run.classification.head.sha !== headSha) {
|
|
985
1286
|
throw new DeliveryError('Recovered merged run disagrees with the registered branch.');
|
|
986
1287
|
}
|
|
987
1288
|
return run;
|
|
988
1289
|
}
|
|
1290
|
+
/** @internal Runtime setup shares the existing writer, ownership handshake and bounded command runner. */
|
|
1291
|
+
export async function withRuntimeSetupWriter(root, operation) {
|
|
1292
|
+
return withVerificationWriter(root, async (writer) => operation({
|
|
1293
|
+
assertQuiescent: () => writer.assertQuiescent(),
|
|
1294
|
+
run: async (argv, bounds, signal) => {
|
|
1295
|
+
assertResourceBounds(bounds);
|
|
1296
|
+
const roots = bounds.outputRoots?.map((path) => resolve(root, path)).sort();
|
|
1297
|
+
if (roots !== undefined) {
|
|
1298
|
+
for (let i = 1; i < roots.length; i++)
|
|
1299
|
+
if (roots[i] === roots[i - 1] || roots[i].startsWith(`${roots[i - 1]}${sep}`))
|
|
1300
|
+
throw new DeliveryError('Filesystem output roots must not overlap.');
|
|
1301
|
+
}
|
|
1302
|
+
const files = roots === undefined ? undefined : scanOutputRoots(roots);
|
|
1303
|
+
const baseline = roots === undefined || files === undefined
|
|
1304
|
+
? undefined
|
|
1305
|
+
: {
|
|
1306
|
+
roots,
|
|
1307
|
+
files,
|
|
1308
|
+
baselineId: digestValue([...files]),
|
|
1309
|
+
};
|
|
1310
|
+
assertDiskHeadroom(root, bounds, baseline);
|
|
1311
|
+
const started = Date.now();
|
|
1312
|
+
const output = await runStageCommand(root, argv, signal, (capturedOutputBytes, sample) => reportVerificationProgress({
|
|
1313
|
+
state: 'running',
|
|
1314
|
+
stageId: 'runtime-setup',
|
|
1315
|
+
completedStages: 0,
|
|
1316
|
+
reusedStages: 0,
|
|
1317
|
+
remainingStages: 1,
|
|
1318
|
+
elapsedMs: Date.now() - started,
|
|
1319
|
+
capturedOutputBytes,
|
|
1320
|
+
...(sample === undefined
|
|
1321
|
+
? {}
|
|
1322
|
+
: {
|
|
1323
|
+
sampledAggregateRssBytes: sample.aggregateRssBytes,
|
|
1324
|
+
sampledFreeDiskBytes: sample.freeDiskBytes,
|
|
1325
|
+
sampledNewOutputBytes: sample.newOutputBytes,
|
|
1326
|
+
}),
|
|
1327
|
+
}), bounds, baseline, undefined, writer);
|
|
1328
|
+
reportVerificationProgress({
|
|
1329
|
+
state: 'completed',
|
|
1330
|
+
stageId: 'runtime-setup',
|
|
1331
|
+
completedStages: 1,
|
|
1332
|
+
reusedStages: 0,
|
|
1333
|
+
remainingStages: 0,
|
|
1334
|
+
elapsedMs: Date.now() - started,
|
|
1335
|
+
});
|
|
1336
|
+
return output;
|
|
1337
|
+
},
|
|
1338
|
+
}));
|
|
1339
|
+
}
|
|
989
1340
|
//# sourceMappingURL=verification.js.map
|