@aviaratech/ai-delivery 0.3.18 → 0.3.19

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 CHANGED
@@ -325,6 +325,13 @@ comment's native ID. The operator attests that all other launchers and writers
325
325
  are quiescent and remain excluded through recovery. The existing writer slot
326
326
  must be absent; this flow never recovers or terminates an unknown writer.
327
327
 
328
+ For this committed-descendant case only, a complete empty native closing-issue
329
+ connection may instead bind an explicit `Refs #<issue>` reference, with optional
330
+ explanatory text, to complete native issue-timeline cross-reference evidence for
331
+ the same PR, repository and authenticated historical author before the merge. The PR body alone is
332
+ insufficient. Missing, incomplete or mismatched native evidence is refused;
333
+ other worktree transition purposes retain their closing-issue requirement.
334
+
328
335
  Repeat `develop` or existing-issue `start` after interruption. Recovery checks
329
336
  the same pinned native comments, revocation, original bytes and exact original
330
337
  or replacement row before completing. Other mutations, including metadata
@@ -457,6 +464,15 @@ cooperative callback: use its AbortSignal and issue all subprocesses through
457
464
  snapshot or produce the one declared commit with its exact parent/tree and
458
465
  clean postconditions. Normal commit hooks remain enabled.
459
466
 
467
+ For a declared candidate policy change, set the optional
468
+ `source.effect.configDigest` on `commitOnce` to the final resolved candidate
469
+ configuration digest. `source.configDigest` binds the initial configuration;
470
+ the final digest applies only to the exact declared clean child commit. Resolve
471
+ both digests through the SDK's configuration loader; a policy file hash is not
472
+ a resolved configuration digest. Omitting the final digest requires the initial
473
+ configuration throughout and preserves existing record identities. `preserve`
474
+ does not accept a final digest. The controller configuration remains fixed.
475
+
460
476
  The existing writer fence is published before lengthy validation/scans. The
461
477
  same runner accounts for the controller and owned descendants, captured logs,
462
478
  callback output and SDK metadata under one original cumulative physical
@@ -931,11 +931,197 @@ test('supported issue source phase runs fixture tests, an enabled-hook commit an
931
931
  assert.equal(git(row.path, 'rev-parse', 'HEAD^'), input.source.head);
932
932
  assert.equal(receipt.source.indexTree, input.source.indexTree);
933
933
  assert.equal(readFileSync(join(outputRoot, 'hook-count.txt'), 'utf8'), 'enabled\n');
934
+ const { environment, ...legacyBinding } = input;
935
+ assert.equal(receipt.phaseId, digestValue({ ...legacyBinding, environmentDigest: environment.digest }));
936
+ assert.equal(Object.hasOwn(input.source.effect, 'configDigest'), false);
937
+ const completedPath = join(gitCommonDir(root), 'ai-delivery', 'receipts', 'source-phase@1', digestValue(row.path).slice(7), `${receipt.phaseId.slice(7)}.json`);
938
+ const completedBytes = readFileSync(completedPath);
939
+ const completed = JSON.parse(completedBytes.toString());
940
+ assert.equal(Object.hasOwn(completed.binding.source.effect, 'configDigest'), false);
934
941
  const reused = await withIssueSourcePhase(input, async () => {
935
942
  entered += 1;
936
943
  });
937
944
  assert.equal(reused.recordId, receipt.recordId);
938
945
  assert.equal(entered, 1);
946
+ assert.deepEqual(readFileSync(completedPath), completedBytes);
947
+ }
948
+ finally {
949
+ rmSync(root, { recursive: true, force: true });
950
+ }
951
+ });
952
+ async function sourceConfigurationTransitionFixture() {
953
+ const result = await sourcePhaseFixture({ personalAuthor: true, custodianIdentity: 'historical-builder' });
954
+ const { root, row, outputRoot, input } = result;
955
+ const policy = join(row.path, 'policy.mjs');
956
+ const original = readFileSync(policy, 'utf8');
957
+ const revised = `${original}\n// Declared candidate policy revision.\n`;
958
+ writeFileSync(policy, revised);
959
+ const finalConfiguration = await loadDeliveryConfig(row.path);
960
+ const finalPolicyDigest = sha256(policy);
961
+ git(row.path, 'add', 'policy.mjs');
962
+ const tree = git(row.path, 'write-tree');
963
+ writeFileSync(policy, original);
964
+ git(row.path, 'add', 'policy.mjs');
965
+ assert.equal(git(row.path, 'write-tree'), input.source.indexTree);
966
+ assert.equal(git(row.path, 'status', '--porcelain'), '');
967
+ assert.equal((await loadDeliveryConfig(row.path)).configDigest, input.source.configDigest);
968
+ assert.notEqual(finalConfiguration.configDigest, input.source.configDigest);
969
+ assert.notEqual(finalConfiguration.configDigest, finalPolicyDigest);
970
+ const effect = {
971
+ kind: 'commitOnce',
972
+ parent: input.source.head,
973
+ tree,
974
+ configDigest: finalConfiguration.configDigest,
975
+ };
976
+ input.source.effect = effect;
977
+ const hook = join(root, '.git', 'source-phase-hooks', 'pre-commit');
978
+ mkdirSync(dirname(hook));
979
+ writeFileSync(hook, `#!/bin/sh\nprintf 'enabled\\n' >> ${JSON.stringify(join(outputRoot, 'hook-count.txt'))}\n`, {
980
+ mode: 0o700,
981
+ });
982
+ const gitPath = realpathSync(execFileSync('/usr/bin/which', ['git'], { encoding: 'utf8' }).trim());
983
+ input.inputs = [hook, gitPath].map((path) => ({ path, digest: sha256(path) }));
984
+ const transition = `const fs=require('node:fs'),c=require('node:child_process');
985
+ fs.writeFileSync(${JSON.stringify(policy)},${JSON.stringify(revised)});
986
+ const git=(...args)=>c.execFileSync(${JSON.stringify(gitPath)},args,{stdio:'inherit'});
987
+ git('add','policy.mjs');
988
+ git('-c',${JSON.stringify(`core.hooksPath=${dirname(hook)}`)},'commit','-m','Reviewed candidate policy transition');`;
989
+ input.commands[1].argv = [realpathSync(process.execPath), '-e', transition];
990
+ input.commandGraphDigest = digestValue(input.commands);
991
+ return { ...result, policy, original, revised, effect, transition };
992
+ }
993
+ test('supported issue source phase binds initial and final resolved candidate configurations across one normal-hook commit', async () => {
994
+ const { root, row, outputRoot, input, effect } = await sourceConfigurationTransitionFixture();
995
+ try {
996
+ const { withIssueSourcePhase } = await import('./agent.js');
997
+ let entered = 0;
998
+ const receipt = await withIssueSourcePhase(input, async (context) => {
999
+ entered += 1;
1000
+ assert.equal(context.source.configDigest, input.source.configDigest);
1001
+ mkdirSync(outputRoot);
1002
+ writeFileSync(join(outputRoot, 'metadata.json'), '{}');
1003
+ for (let index = 0; index < input.commands.length; index++)
1004
+ await context.run(index);
1005
+ });
1006
+ assert.equal(receipt.source.configDigest, effect.configDigest);
1007
+ assert.equal(receipt.source.indexTree, effect.tree);
1008
+ assert.equal(git(row.path, 'rev-list', '--parents', '-n', '1', 'HEAD'), `${receipt.source.head} ${input.source.head}`);
1009
+ assert.equal(git(row.path, 'status', '--porcelain'), '');
1010
+ assert.equal(git(root, 'rev-parse', 'HEAD'), input.controller.head);
1011
+ assert.equal((await loadDeliveryConfig(root)).configDigest, input.controller.configDigest);
1012
+ assert.equal(readFileSync(join(outputRoot, 'hook-count.txt'), 'utf8'), 'enabled\n');
1013
+ const completed = sourcePhaseRecords(root).find((record) => record.phaseId === receipt.phaseId);
1014
+ assert.deepEqual(completed?.binding?.source?.effect, effect);
1015
+ const reused = await withIssueSourcePhase(input, async () => {
1016
+ entered += 1;
1017
+ });
1018
+ assert.equal(reused.recordId, receipt.recordId);
1019
+ assert.deepEqual(sourcePhaseRecords(root).find((record) => record.phaseId === receipt.phaseId), completed);
1020
+ assert.equal(entered, 1);
1021
+ assert.equal(readFileSync(join(outputRoot, 'hook-count.txt'), 'utf8'), 'enabled\n');
1022
+ for (const digest of ['initial', 'final']) {
1023
+ const changed = structuredClone(input);
1024
+ if (digest === 'initial')
1025
+ changed.source.configDigest = digestValue('different initial configuration');
1026
+ else
1027
+ changed.source.effect = { ...effect, configDigest: digestValue('different final configuration') };
1028
+ await assert.rejects(withIssueSourcePhase(changed, async () => assert.fail('changed binding cannot reuse completion or enter')), /initial source|configuration|reconciliation/u);
1029
+ assert.deepEqual(sourcePhaseRecords(root).find((record) => record.phaseId === receipt.phaseId), completed);
1030
+ assert.equal(readFileSync(join(outputRoot, 'hook-count.txt'), 'utf8'), 'enabled\n');
1031
+ }
1032
+ writeFileSync(join(row.path, 'policy.mjs'), 'export const deliverySettings = {};\n');
1033
+ await assert.rejects(withIssueSourcePhase(input, async () => assert.fail('changed final source cannot reuse')));
1034
+ assert.deepEqual(sourcePhaseRecords(root).find((record) => record.phaseId === receipt.phaseId), completed);
1035
+ }
1036
+ finally {
1037
+ rmSync(root, { recursive: true, force: true });
1038
+ }
1039
+ });
1040
+ test.each(['preserve', 'invalid digest', 'unknown field'])('supported issue source phase strictly refuses a final configuration declaration with %s', async (invalid) => {
1041
+ const { root, input, effect } = await sourceConfigurationTransitionFixture();
1042
+ const declaration = invalid === 'preserve'
1043
+ ? { kind: 'preserve', configDigest: effect.configDigest }
1044
+ : invalid === 'invalid digest'
1045
+ ? { ...effect, configDigest: 'invalid' }
1046
+ : { ...effect, finalConfigDigest: effect.configDigest };
1047
+ try {
1048
+ const { withIssueSourcePhase } = await import('./agent.js');
1049
+ await assert.rejects(withIssueSourcePhase({ ...input, source: { ...input.source, effect: declaration } }, async () => assert.fail('invalid declaration cannot enter')));
1050
+ assert.equal(existsSync(join(gitCommonDir(root), 'ai-delivery', 'receipts', 'source-phase@1')), false);
1051
+ }
1052
+ finally {
1053
+ rmSync(root, { recursive: true, force: true });
1054
+ }
1055
+ });
1056
+ test('supported issue source phase refuses the final configuration at the initial head before work', async () => {
1057
+ const { root, input, policy, revised } = await sourceConfigurationTransitionFixture();
1058
+ writeFileSync(policy, revised);
1059
+ try {
1060
+ const { withIssueSourcePhase } = await import('./agent.js');
1061
+ await assert.rejects(withIssueSourcePhase(input, async () => assert.fail('initial head requires initial configuration')), /configuration/u);
1062
+ }
1063
+ finally {
1064
+ rmSync(root, { recursive: true, force: true });
1065
+ }
1066
+ });
1067
+ test.each([
1068
+ 'omitted digest',
1069
+ 'wrong digest',
1070
+ 'wrong tree',
1071
+ 'wrong parent',
1072
+ 'second commit',
1073
+ 'dirty final',
1074
+ 'controller configuration',
1075
+ 'seal configuration',
1076
+ ])('supported issue source phase refuses a candidate configuration transition with %s without replay', async (invalid) => {
1077
+ const { root, row, outputRoot, input, effect, transition, policy, original } = await sourceConfigurationTransitionFixture();
1078
+ if (invalid === 'omitted digest') {
1079
+ input.source.effect = { kind: 'commitOnce', parent: effect.parent, tree: effect.tree };
1080
+ }
1081
+ else if (invalid === 'wrong digest') {
1082
+ input.source.effect = { ...effect, configDigest: input.source.configDigest };
1083
+ }
1084
+ else if (invalid === 'wrong tree') {
1085
+ input.source.effect = { ...effect, tree: input.source.indexTree };
1086
+ }
1087
+ else if (invalid === 'wrong parent') {
1088
+ input.source.effect = { ...effect, parent: '1'.repeat(40) };
1089
+ }
1090
+ else if (invalid === 'second commit') {
1091
+ input.commands[1].argv[2] = `${transition}\ngit('commit','--allow-empty','-m','Undeclared second commit');`;
1092
+ }
1093
+ else if (invalid === 'dirty final') {
1094
+ input.commands[1].argv[2] = `${transition}\nfs.writeFileSync('artifact.txt','undeclared dirty final');`;
1095
+ }
1096
+ else if (invalid === 'controller configuration') {
1097
+ input.commands[1].argv[2] = `${transition}\nfs.appendFileSync(${JSON.stringify(join(root, 'policy.mjs'))},'\\n// Undeclared controller change.\\n');`;
1098
+ }
1099
+ else {
1100
+ input.commands[2].argv = [
1101
+ realpathSync(process.execPath),
1102
+ '-e',
1103
+ `require('node:fs').writeFileSync(${JSON.stringify(policy)},${JSON.stringify(original)});`,
1104
+ ];
1105
+ }
1106
+ input.commandGraphDigest = digestValue(input.commands);
1107
+ try {
1108
+ const { withIssueSourcePhase } = await import('./agent.js');
1109
+ let entered = 0;
1110
+ await assert.rejects(withIssueSourcePhase(input, async (context) => {
1111
+ entered += 1;
1112
+ mkdirSync(outputRoot);
1113
+ writeFileSync(join(outputRoot, 'metadata.json'), '{}');
1114
+ for (let index = 0; index < input.commands.length; index++)
1115
+ await context.run(index);
1116
+ }), invalid === 'controller configuration' ? /repository admission/u : /configuration|parent, tree/u);
1117
+ assert.equal(entered, 1);
1118
+ assert.notEqual(git(row.path, 'rev-parse', 'HEAD'), input.source.head);
1119
+ assert.equal(sourcePhaseRecords(root)[0]?.status, 'unresolved');
1120
+ const head = git(row.path, 'rev-parse', 'HEAD');
1121
+ const hooks = readFileSync(join(outputRoot, 'hook-count.txt'));
1122
+ await assert.rejects(withIssueSourcePhase(input, async () => assert.fail('failed commit cannot replay')));
1123
+ assert.equal(git(row.path, 'rev-parse', 'HEAD'), head);
1124
+ assert.deepEqual(readFileSync(join(outputRoot, 'hook-count.txt')), hooks);
939
1125
  }
940
1126
  finally {
941
1127
  rmSync(root, { recursive: true, force: true });
@@ -4567,6 +4753,13 @@ async function syntheticLifecycle(routing) {
4567
4753
  const historicalActor = routing.committedDescendantContinuation ? 'host-user' : 'synthetic-author[bot]';
4568
4754
  let nativePrAuthor = historicalActor;
4569
4755
  let nativeClosingIssue = issueNumber;
4756
+ let nativeClosingConnectionEmpty = false;
4757
+ let nativeClosingHasNextPage = false;
4758
+ let nativeRefsBody = `Refs #${String(issueNumber)}`;
4759
+ let nativeTimelinePages;
4760
+ let nativeTimelineFailurePage;
4761
+ let nativeTimelineNextLink = false;
4762
+ let nativeTimelineCalls = 0;
4570
4763
  let nativePrRepository;
4571
4764
  const continuationComments = [];
4572
4765
  const createPayloads = [];
@@ -4593,6 +4786,8 @@ async function syntheticLifecycle(routing) {
4593
4786
  node_id: 'PR-23',
4594
4787
  title: 'Synthetic change',
4595
4788
  html_url: 'https://example.test/pulls/23',
4789
+ url: 'https://api.example.test/repos/example/widget/pulls/23',
4790
+ body: nativeRefsBody,
4596
4791
  state: stale ? 'open' : prState,
4597
4792
  draft: prDraft,
4598
4793
  merged_at: !stale && prState === 'closed' ? mergedAt : null,
@@ -4602,16 +4797,39 @@ async function syntheticLifecycle(routing) {
4602
4797
  mergeable_state: 'clean',
4603
4798
  user: { login: nativePrAuthor, id: 37, type: routing.committedDescendantContinuation ? 'User' : 'Bot' },
4604
4799
  head: { sha: headSha, ref: 'issue/17', ...(nativePrRepository ? { repo: { full_name: nativePrRepository } } : {}) },
4605
- base: { sha: baseSha, ref: 'main', ...(nativePrRepository ? { repo: { full_name: nativePrRepository } } : {}) },
4800
+ base: {
4801
+ sha: baseSha,
4802
+ ref: 'main',
4803
+ ...(nativePrRepository
4804
+ ? { repo: { full_name: nativePrRepository, url: `https://api.example.test/repos/${nativePrRepository}` } }
4805
+ : {}),
4806
+ },
4807
+ });
4808
+ const nativeReference = () => ({
4809
+ event: 'cross-referenced',
4810
+ actor: { login: historicalActor, id: 37, type: 'User' },
4811
+ created_at: new Date(Date.parse(mergedAt) - 60_000).toISOString(),
4812
+ source: {
4813
+ type: 'issue',
4814
+ issue: {
4815
+ number: prNumber,
4816
+ user: { login: historicalActor, id: 37, type: 'User' },
4817
+ repository_url: 'https://api.example.test/repos/example/widget',
4818
+ html_url: 'https://example.test/pulls/23',
4819
+ pull_request: { url: 'https://api.example.test/repos/example/widget/pulls/23' },
4820
+ },
4821
+ },
4606
4822
  });
4607
4823
  const graphql = async (query, variables = {}) => {
4608
- if (query.includes('WorktreeTransitionLineage'))
4824
+ if (query.includes('WorktreeTransitionLineage') || query.includes('CommittedContinuationLineage'))
4609
4825
  return {
4610
4826
  repository: {
4611
4827
  pullRequest: {
4612
4828
  closingIssuesReferences: {
4613
- nodes: [{ number: nativeClosingIssue, repository: { nameWithOwner: nativePrRepository } }],
4614
- pageInfo,
4829
+ nodes: nativeClosingConnectionEmpty
4830
+ ? []
4831
+ : [{ number: nativeClosingIssue, repository: { nameWithOwner: nativePrRepository } }],
4832
+ pageInfo: { ...pageInfo, hasNextPage: nativeClosingHasNextPage },
4615
4833
  },
4616
4834
  },
4617
4835
  },
@@ -4814,6 +5032,18 @@ async function syntheticLifecycle(routing) {
4814
5032
  throw new Error(`Unexpected REST route: ${route}`);
4815
5033
  },
4816
5034
  issues: {
5035
+ listEventsForTimeline: async (input) => {
5036
+ assert.equal(input.issue_number, issueNumber);
5037
+ nativeTimelineCalls += 1;
5038
+ if (input.page === nativeTimelineFailurePage)
5039
+ throw new Error('Synthetic native timeline unavailable');
5040
+ return {
5041
+ headers: nativeTimelineNextLink && input.page === 1
5042
+ ? { link: '<https://api.example.test/repos/example/widget/issues/17/timeline?page=2>; rel="next"' }
5043
+ : {},
5044
+ data: nativeTimelinePages?.[input.page - 1] ?? (input.page === 1 ? [nativeReference()] : []),
5045
+ };
5046
+ },
4817
5047
  listComments: async () => ({ data: continuationComments }),
4818
5048
  getComment: async (input) => ({
4819
5049
  data: continuationComments.find((comment) => comment.id === input.comment_id),
@@ -5675,6 +5905,78 @@ exec "${realGit}" "$@"
5675
5905
  });
5676
5906
  assert.equal(crashIndex, 2);
5677
5907
  }
5908
+ if (routing.nativeRefsContinuation) {
5909
+ nativeClosingConnectionEmpty = true;
5910
+ nativeClosingHasNextPage = true;
5911
+ await refuse(/Native PR lineage/u);
5912
+ nativeClosingHasNextPage = false;
5913
+ nativeClosingConnectionEmpty = false;
5914
+ nativeClosingIssue = 18;
5915
+ await refuse(/Native PR lineage/u);
5916
+ nativeClosingIssue = issueNumber;
5917
+ nativeClosingConnectionEmpty = true;
5918
+ for (const body of ['', 'Refs #18', 'Refs #170', 'Refs #17letters', 'Refs #17_extra', 'Mention #17']) {
5919
+ nativeRefsBody = body;
5920
+ await refuse(/Native PR lineage/u);
5921
+ }
5922
+ nativeRefsBody = `Refs #${String(issueNumber)}`;
5923
+ const event = nativeReference();
5924
+ const source = event.source;
5925
+ const sourceIssue = source.issue;
5926
+ for (const invalid of [
5927
+ undefined,
5928
+ { ...event, actor: { ...event.actor, id: 99 } },
5929
+ { ...event, actor: { ...event.actor, login: 'different-user' } },
5930
+ { ...event, actor: { ...event.actor, type: 'Bot' } },
5931
+ { ...event, created_at: mergedAt },
5932
+ { ...event, created_at: new Date(Date.parse(mergedAt) + 1000).toISOString() },
5933
+ { ...event, created_at: 'unknown' },
5934
+ { ...event, source: { ...source, type: 'pull_request' } },
5935
+ { ...event, source: { ...source, issue: { ...sourceIssue, number: 24 } } },
5936
+ {
5937
+ ...event,
5938
+ source: {
5939
+ ...source,
5940
+ issue: { ...sourceIssue, repository_url: 'https://api.example.test/repos/other/widget' },
5941
+ },
5942
+ },
5943
+ { ...event, source: { ...source, issue: { ...sourceIssue, html_url: 'https://example.test/pulls/24' } } },
5944
+ {
5945
+ ...event,
5946
+ source: {
5947
+ ...source,
5948
+ issue: {
5949
+ ...sourceIssue,
5950
+ pull_request: { url: 'https://api.example.test/repos/example/widget/pulls/24' },
5951
+ },
5952
+ },
5953
+ },
5954
+ { ...event, source: { ...source, issue: { ...sourceIssue, pull_request: undefined } } },
5955
+ { ...event, source: { ...source, issue: { ...sourceIssue, user: { ...sourceIssue.user, id: 99 } } } },
5956
+ ]) {
5957
+ nativeTimelinePages = [invalid === undefined ? [] : [invalid]];
5958
+ await refuse(/Native PR lineage/u);
5959
+ }
5960
+ nativeTimelinePages = [[event]];
5961
+ nativeTimelineFailurePage = 1;
5962
+ await refuse(/Synthetic native timeline unavailable/u);
5963
+ nativeTimelineFailurePage = 2;
5964
+ nativeTimelineNextLink = true;
5965
+ const before = nativeTimelineCalls;
5966
+ await refuse(/Synthetic native timeline unavailable/u);
5967
+ assert.equal(nativeTimelineCalls - before, 2, 'matching first page cannot hide a missing next page');
5968
+ nativeTimelineFailurePage = undefined;
5969
+ nativeTimelineNextLink = false;
5970
+ nativeTimelinePages = [Array.from({ length: 100 }, () => ({ event: 'commented' })), [event]];
5971
+ unlinkSync(witness.path);
5972
+ try {
5973
+ await refuse(/ownership witness/u);
5974
+ }
5975
+ finally {
5976
+ writeFileSync(witness.path, witness.bytes, { mode: 0o600 });
5977
+ }
5978
+ nativeRefsBody = `Refs #${String(issueNumber)}. Keep the issue open for retirement and supported consumer cutover.`;
5979
+ }
5678
5980
  const first = await executeTool('issue_develop', { issueNumber }, execution).catch((error) => error);
5679
5981
  assert.ok(first instanceof Error);
5680
5982
  assert.match(first.message, /exact native.*acceptance/u);
@@ -6123,6 +6425,13 @@ test('supported develop reconciles authenticated host custody for an already com
6123
6425
  assert.ok(uninterrupted.value);
6124
6426
  assert.deepEqual(recovered.value, uninterrupted.value);
6125
6427
  });
6428
+ test('supported develop preserves native Refs lineage for a committed descendant', { timeout: 0 }, async () => syntheticLifecycle({
6429
+ remote: 'origin',
6430
+ divergentOrigin: false,
6431
+ committedDescendantContinuation: true,
6432
+ nativeRefsContinuation: true,
6433
+ interruptContinuation: false,
6434
+ }));
6126
6435
  test('committed descendant checkpoint publication recovers from SIGKILL before and after every rename', { timeout: 0 }, async () => syntheticLifecycle({
6127
6436
  remote: 'origin',
6128
6437
  divergentOrigin: false,