@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.
@@ -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
- throw new OutputObservationError('Filesystem output observation encountered a broken or cyclic symbolic link.', path, error);
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
- const started = Date.now();
640
+ let firstFailureAt;
538
641
  const assertObservationWindow = () => {
539
- if (firstFailure !== undefined && Date.now() - started >= 1_000) {
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
- // Removal can unlink a physical target before its alias. Never accept an incomplete scan:
554
- // retry ENOENT only during a command, with one bounded observation-stall window.
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
  };