@aviaratech/ai-delivery 0.3.11 → 0.3.13
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 +17 -1
- package/dist/issue.js +36 -7
- package/dist/issue.js.map +1 -1
- package/dist/lifecycle.test.js +539 -55
- package/dist/lifecycle.test.js.map +1 -1
- package/dist/pr.js +22 -4
- package/dist/pr.js.map +1 -1
- package/dist/services/worktreeRegistry.d.ts +2 -2
- package/dist/services/worktreeRegistry.js +2 -4
- package/dist/services/worktreeRegistry.js.map +1 -1
- package/dist/verification.js +123 -12
- package/dist/verification.js.map +1 -1
- package/dist/worktree.d.ts +2 -0
- package/dist/worktree.js +18 -9
- package/dist/worktree.js.map +1 -1
- package/dist/worktreeTransition.test.js +5 -5
- package/dist/worktreeTransition.test.js.map +1 -1
- package/package.json +1 -1
- package/plugins/ai-delivery/.claude-plugin/plugin.json +1 -1
package/dist/verification.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { spawn, spawnSync } from 'node:child_process';
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
3
|
import { createRequire } from 'node:module';
|
|
4
|
-
import { closeSync, existsSync, fsyncSync, openSync, unlinkSync, lstatSync, mkdirSync, rmdirSync, renameSync, readFileSync, readdirSync, realpathSync, statfsSync, } from 'node:fs';
|
|
4
|
+
import { closeSync, existsSync, fsyncSync, openSync, unlinkSync, lstatSync, mkdirSync, rmdirSync, renameSync, readFileSync, readlinkSync, readdirSync, realpathSync, statfsSync, } from 'node:fs';
|
|
5
5
|
import { basename, dirname, join, resolve, sep } from 'node:path';
|
|
6
6
|
import { fileURLToPath } from 'node:url';
|
|
7
7
|
import { z } from 'zod';
|
|
@@ -400,8 +400,9 @@ class OutputObservationError extends DeliveryError {
|
|
|
400
400
|
this.cause = cause;
|
|
401
401
|
}
|
|
402
402
|
}
|
|
403
|
-
function scanOutputRoots(roots) {
|
|
403
|
+
function scanOutputRoots(roots, links, commandRunning = false, onMissingTarget) {
|
|
404
404
|
const files = new Map();
|
|
405
|
+
const observedLinks = new Map();
|
|
405
406
|
const visit = (path) => {
|
|
406
407
|
let metadata;
|
|
407
408
|
try {
|
|
@@ -413,16 +414,113 @@ function scanOutputRoots(roots) {
|
|
|
413
414
|
throw new OutputObservationError('Filesystem output lstat failed.', path, error);
|
|
414
415
|
}
|
|
415
416
|
if (metadata.isSymbolicLink()) {
|
|
417
|
+
let link;
|
|
418
|
+
try {
|
|
419
|
+
link = readlinkSync(path);
|
|
420
|
+
}
|
|
421
|
+
catch (error) {
|
|
422
|
+
throw new OutputObservationError('Filesystem output readlink failed.', path, error);
|
|
423
|
+
}
|
|
424
|
+
const identity = digestValue({
|
|
425
|
+
device: metadata.dev,
|
|
426
|
+
inode: metadata.ino,
|
|
427
|
+
changed: metadata.ctimeMs,
|
|
428
|
+
modified: metadata.mtimeMs,
|
|
429
|
+
link,
|
|
430
|
+
});
|
|
416
431
|
let target;
|
|
432
|
+
let missing = false;
|
|
417
433
|
try {
|
|
418
434
|
target = realpathSync(path);
|
|
419
435
|
}
|
|
420
436
|
catch (error) {
|
|
421
|
-
|
|
437
|
+
const failure = new OutputObservationError('Filesystem output observation encountered a broken or cyclic symbolic link.', path, error);
|
|
438
|
+
const previous = links.get(path);
|
|
439
|
+
if (previous !== undefined && previous.identity !== identity)
|
|
440
|
+
throw new DeliveryError(`Filesystem output alias identity changed. Path ${JSON.stringify(path)}.`);
|
|
441
|
+
if (!commandRunning || failure.code !== 'ENOENT' || previous?.identity !== identity)
|
|
442
|
+
throw failure;
|
|
443
|
+
// A validated alias contributes no physical file bytes. Resolve the missing suffix through
|
|
444
|
+
// its current existing ancestor, so an ancestor retarget cannot hide an escaping output.
|
|
445
|
+
let lexical = resolve(dirname(path), link);
|
|
446
|
+
let ancestor = lexical;
|
|
447
|
+
const expanded = new Set([path]);
|
|
448
|
+
for (;;) {
|
|
449
|
+
try {
|
|
450
|
+
target = join(realpathSync(ancestor), lexical.slice(ancestor.length));
|
|
451
|
+
break;
|
|
452
|
+
}
|
|
453
|
+
catch (ancestorError) {
|
|
454
|
+
if (ancestorError.code !== 'ENOENT' || dirname(ancestor) === ancestor)
|
|
455
|
+
throw new OutputObservationError('Filesystem output ancestor resolution failed.', ancestor, ancestorError);
|
|
456
|
+
let ancestorMetadata;
|
|
457
|
+
try {
|
|
458
|
+
ancestorMetadata = lstatSync(ancestor);
|
|
459
|
+
}
|
|
460
|
+
catch (metadataError) {
|
|
461
|
+
if (metadataError.code !== 'ENOENT')
|
|
462
|
+
throw new OutputObservationError('Filesystem output ancestor lstat failed.', ancestor, metadataError);
|
|
463
|
+
}
|
|
464
|
+
if (ancestorMetadata?.isSymbolicLink()) {
|
|
465
|
+
let ancestorLink, currentAncestor;
|
|
466
|
+
try {
|
|
467
|
+
ancestorLink = readlinkSync(ancestor);
|
|
468
|
+
currentAncestor = lstatSync(ancestor);
|
|
469
|
+
}
|
|
470
|
+
catch (aliasError) {
|
|
471
|
+
throw new OutputObservationError('Filesystem output ancestor alias read failed.', ancestor, aliasError);
|
|
472
|
+
}
|
|
473
|
+
const ancestorIdentity = digestValue({
|
|
474
|
+
device: ancestorMetadata.dev,
|
|
475
|
+
inode: ancestorMetadata.ino,
|
|
476
|
+
changed: ancestorMetadata.ctimeMs,
|
|
477
|
+
modified: ancestorMetadata.mtimeMs,
|
|
478
|
+
link: ancestorLink,
|
|
479
|
+
});
|
|
480
|
+
const previousAncestor = links.get(ancestor);
|
|
481
|
+
if (previousAncestor === undefined)
|
|
482
|
+
throw failure;
|
|
483
|
+
if (expanded.has(ancestor) ||
|
|
484
|
+
previousAncestor.identity !== ancestorIdentity ||
|
|
485
|
+
!currentAncestor.isSymbolicLink() ||
|
|
486
|
+
currentAncestor.dev !== ancestorMetadata.dev ||
|
|
487
|
+
currentAncestor.ino !== ancestorMetadata.ino ||
|
|
488
|
+
currentAncestor.ctimeMs !== ancestorMetadata.ctimeMs ||
|
|
489
|
+
currentAncestor.mtimeMs !== ancestorMetadata.mtimeMs)
|
|
490
|
+
throw new DeliveryError(`Filesystem output ancestor alias identity changed. Path ${JSON.stringify(ancestor)}.`);
|
|
491
|
+
expanded.add(ancestor);
|
|
492
|
+
lexical = join(resolve(dirname(ancestor), ancestorLink), lexical.slice(ancestor.length));
|
|
493
|
+
ancestor = lexical;
|
|
494
|
+
continue;
|
|
495
|
+
}
|
|
496
|
+
ancestor = dirname(ancestor);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
if (target !== previous.target)
|
|
500
|
+
throw new DeliveryError(`Filesystem output alias target identity changed. Path ${JSON.stringify(path)}.`);
|
|
501
|
+
missing = true;
|
|
502
|
+
onMissingTarget?.(failure.message);
|
|
422
503
|
}
|
|
504
|
+
const previous = links.get(path);
|
|
505
|
+
if (previous?.missing && (previous.identity !== identity || previous.target !== target))
|
|
506
|
+
throw new DeliveryError(`Filesystem output alias identity changed after target absence. Path ${JSON.stringify(path)}.`);
|
|
423
507
|
if (!roots.some((root) => target === root || target.startsWith(`${root}${sep}`))) {
|
|
424
508
|
throw new DeliveryError(`Filesystem output observation encountered an escaping symbolic link. Path ${JSON.stringify(path)} resolves to ${JSON.stringify(target)}.`);
|
|
425
509
|
}
|
|
510
|
+
let current;
|
|
511
|
+
try {
|
|
512
|
+
current = lstatSync(path);
|
|
513
|
+
}
|
|
514
|
+
catch (error) {
|
|
515
|
+
throw new OutputObservationError('Filesystem output alias identity readback failed.', path, error);
|
|
516
|
+
}
|
|
517
|
+
if (!current.isSymbolicLink() ||
|
|
518
|
+
current.dev !== metadata.dev ||
|
|
519
|
+
current.ino !== metadata.ino ||
|
|
520
|
+
current.ctimeMs !== metadata.ctimeMs ||
|
|
521
|
+
current.mtimeMs !== metadata.mtimeMs)
|
|
522
|
+
throw new DeliveryError(`Filesystem output alias identity changed during observation. Path ${JSON.stringify(path)}.`);
|
|
523
|
+
observedLinks.set(path, { identity, target, missing });
|
|
426
524
|
// Observe physical targets through their declared root only, never traverse aliases.
|
|
427
525
|
return;
|
|
428
526
|
}
|
|
@@ -452,12 +550,17 @@ function scanOutputRoots(roots) {
|
|
|
452
550
|
throw new DeliveryError('Filesystem output roots must not be symbolic links.');
|
|
453
551
|
visit(root);
|
|
454
552
|
}
|
|
553
|
+
// Publish alias identities only with the complete physical-file scan that validated them.
|
|
554
|
+
links.clear();
|
|
555
|
+
for (const [path, link] of observedLinks)
|
|
556
|
+
links.set(path, link);
|
|
455
557
|
return files;
|
|
456
558
|
}
|
|
457
559
|
async function outputBaseline(repoRoot, common, classificationReceiptId, bounds, gate, signal) {
|
|
458
560
|
if (bounds.outputRoots === undefined)
|
|
459
561
|
return undefined;
|
|
460
562
|
const roots = bounds.outputRoots.map((path) => resolve(repoRoot, path)).sort();
|
|
563
|
+
const links = new Map();
|
|
461
564
|
for (let i = 1; i < roots.length; i++) {
|
|
462
565
|
if (roots[i] === roots[i - 1] || roots[i].startsWith(`${roots[i - 1]}${sep}`)) {
|
|
463
566
|
throw new DeliveryError('Filesystem output roots must not overlap.');
|
|
@@ -503,7 +606,7 @@ async function outputBaseline(repoRoot, common, classificationReceiptId, bounds,
|
|
|
503
606
|
let checkpoint;
|
|
504
607
|
if (state?.status !== 'complete') {
|
|
505
608
|
const content = {
|
|
506
|
-
files: [...(await withOutputGateLock(gate, 'scan', () => scanOutputRoots(roots), signal))].sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)),
|
|
609
|
+
files: [...(await withOutputGateLock(gate, 'scan', () => scanOutputRoots(roots, links), signal))].sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)),
|
|
507
610
|
inputId,
|
|
508
611
|
roots,
|
|
509
612
|
schemaVersion: 'ai-delivery.output-baseline@1',
|
|
@@ -528,15 +631,15 @@ async function outputBaseline(repoRoot, common, classificationReceiptId, bounds,
|
|
|
528
631
|
checkpoint.files.some(([file], index) => (index > 0 && file <= checkpoint.files[index - 1][0]) ||
|
|
529
632
|
!roots.some((root) => file === root || file.startsWith(`${root}${sep}`))))
|
|
530
633
|
throw new DeliveryError('Filesystem output baseline checkpoint is corrupt or belongs to another input.');
|
|
531
|
-
return { baselineId, roots, files: new Map(checkpoint.files), ...(gate === undefined ? {} : { gate }) };
|
|
634
|
+
return { baselineId, roots, files: new Map(checkpoint.files), links, ...(gate === undefined ? {} : { gate }) };
|
|
532
635
|
}
|
|
533
636
|
async function positiveNewOutputBytes(baseline, signal, commandRunning = false, onUnavailable) {
|
|
534
637
|
let files;
|
|
535
638
|
let firstFailure;
|
|
536
639
|
let lastFailure;
|
|
537
|
-
|
|
640
|
+
let firstFailureAt;
|
|
538
641
|
const assertObservationWindow = () => {
|
|
539
|
-
if (
|
|
642
|
+
if (firstFailureAt !== undefined && Date.now() - firstFailureAt >= 1_000) {
|
|
540
643
|
throw new DeliveryError(`Filesystem output observation remained unavailable for 1 second. First observation: ${firstFailure.message} Last observation: ${lastFailure.message}`);
|
|
541
644
|
}
|
|
542
645
|
};
|
|
@@ -545,17 +648,18 @@ async function positiveNewOutputBytes(baseline, signal, commandRunning = false,
|
|
|
545
648
|
throw new DeliveryError('Filesystem output observation cancelled.');
|
|
546
649
|
assertObservationWindow();
|
|
547
650
|
try {
|
|
548
|
-
files = await withOutputGateLock(baseline.gate, 'scan', () => scanOutputRoots(baseline.roots), signal);
|
|
651
|
+
files = await withOutputGateLock(baseline.gate, 'scan', () => scanOutputRoots(baseline.roots, baseline.links, commandRunning, onUnavailable), signal);
|
|
549
652
|
assertObservationWindow();
|
|
550
653
|
break;
|
|
551
654
|
}
|
|
552
655
|
catch (error) {
|
|
553
|
-
//
|
|
554
|
-
//
|
|
656
|
+
// Unvalidated aliases still prevent a complete scan. Retry only during a command,
|
|
657
|
+
// with the stall window starting at the first failed scan, not before that scan.
|
|
555
658
|
if (!commandRunning || !(error instanceof OutputObservationError) || error.code !== 'ENOENT')
|
|
556
659
|
throw error;
|
|
557
660
|
lastFailure = error;
|
|
558
661
|
if (firstFailure === undefined) {
|
|
662
|
+
firstFailureAt = Date.now();
|
|
559
663
|
firstFailure = error;
|
|
560
664
|
onUnavailable?.(error.message);
|
|
561
665
|
}
|
|
@@ -1201,12 +1305,13 @@ function runStageCommand(repoRoot, argv, abortSignal, onRunning, resourceBounds,
|
|
|
1201
1305
|
let settled = false;
|
|
1202
1306
|
let commandReleased = false;
|
|
1203
1307
|
let firstOutputObservationFailure;
|
|
1308
|
+
let lastOutputObservationFailure;
|
|
1204
1309
|
let pendingOutput;
|
|
1205
1310
|
const scanCancellation = new AbortController();
|
|
1206
1311
|
const failedOutput = () => {
|
|
1207
1312
|
const observation = firstOutputObservationFailure === undefined
|
|
1208
1313
|
? ''
|
|
1209
|
-
: ` First filesystem observation: ${firstOutputObservationFailure}`;
|
|
1314
|
+
: ` First filesystem observation: ${firstOutputObservationFailure} Last filesystem observation: ${lastOutputObservationFailure}`;
|
|
1210
1315
|
try {
|
|
1211
1316
|
const bytes = Buffer.concat([...stdout, ...stderr]);
|
|
1212
1317
|
return ` Command output ${writeRepositoryCommandOutput({ bytes, gitCommonDir: gitCommonDir(repoRoot) })}.${observation}`;
|
|
@@ -1334,6 +1439,7 @@ function runStageCommand(repoRoot, argv, abortSignal, onRunning, resourceBounds,
|
|
|
1334
1439
|
if (resourceBounds !== undefined && baseline !== undefined) {
|
|
1335
1440
|
const bytes = await positiveNewOutputBytes(baseline, scanCancellation.signal, commandReleased, (reason) => {
|
|
1336
1441
|
firstOutputObservationFailure ??= reason;
|
|
1442
|
+
lastOutputObservationFailure = reason;
|
|
1337
1443
|
onRunning?.(outputBytes, undefined, reason);
|
|
1338
1444
|
});
|
|
1339
1445
|
if (failure !== undefined)
|
|
@@ -1445,6 +1551,9 @@ async function verifyIssueOwned(input, writer, gate) {
|
|
|
1445
1551
|
assertResourceBounds(input.resourceBounds);
|
|
1446
1552
|
assertRegisteredIssue(root, input.issueNumber);
|
|
1447
1553
|
const common = gitCommonDir(root);
|
|
1554
|
+
if (existsSync(join(common, 'ai-delivery', 'merges', String(input.issueNumber), `${coordinate(root).sha}.json`))) {
|
|
1555
|
+
throw new DeliveryError('Commit continuation work before verifying an already-merged issue head; retain its receipts.');
|
|
1556
|
+
}
|
|
1448
1557
|
const loaded = await loadDeliveryConfig(root, input.personalAuth === undefined ? {} : { personalAuth: input.personalAuth });
|
|
1449
1558
|
const selected = await loadSelectedRepositoryPolicy({
|
|
1450
1559
|
repoRoot: root,
|
|
@@ -1785,14 +1894,16 @@ export async function withRuntimeSetupWriter(root, operation) {
|
|
|
1785
1894
|
throw new DeliveryError('Filesystem output roots must not overlap.');
|
|
1786
1895
|
}
|
|
1787
1896
|
return withOutputObservationGate(root, roots !== undefined, async (gate) => {
|
|
1897
|
+
const links = new Map();
|
|
1788
1898
|
const files = roots === undefined
|
|
1789
1899
|
? undefined
|
|
1790
|
-
: await withOutputGateLock(gate, 'scan', () => scanOutputRoots(roots), signal);
|
|
1900
|
+
: await withOutputGateLock(gate, 'scan', () => scanOutputRoots(roots, links), signal);
|
|
1791
1901
|
const baseline = roots === undefined || files === undefined
|
|
1792
1902
|
? undefined
|
|
1793
1903
|
: {
|
|
1794
1904
|
roots,
|
|
1795
1905
|
files,
|
|
1906
|
+
links,
|
|
1796
1907
|
baselineId: digestValue([...files]),
|
|
1797
1908
|
...(gate === undefined ? {} : { gate }),
|
|
1798
1909
|
};
|