@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/lifecycle.test.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import assert from 'node:assert/strict';
|
|
2
|
-
import { execFileSync, spawn } from 'node:child_process';
|
|
2
|
+
import { execFileSync, spawn, spawnSync } from 'node:child_process';
|
|
3
3
|
import { createHash } from 'node:crypto';
|
|
4
|
-
import { cpSync, chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, readdirSync, realpathSync, rmSync, symlinkSync, unlinkSync, writeFileSync, } from 'node:fs';
|
|
4
|
+
import { cpSync, chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, readdirSync, realpathSync, rmSync, symlinkSync, unlinkSync, utimesSync, writeFileSync, } from 'node:fs';
|
|
5
5
|
import { tmpdir } from 'node:os';
|
|
6
6
|
import { dirname, join } from 'node:path';
|
|
7
7
|
import { test, vi } from 'vitest';
|
|
@@ -13,15 +13,16 @@ import { AI_DELIVERY_MCP_TOOLS } from './mcp/tools.js';
|
|
|
13
13
|
import { createAiDeliveryMcpServer } from './mcp/index.js';
|
|
14
14
|
import { loadDeliveryConfig, parseDeliveryConfig } from './config/deliveryConfig.js';
|
|
15
15
|
import { digestValue } from './delivery/index.js';
|
|
16
|
-
import { executeTool, resumedIssueUpdate, startTrackedIssue } from './dispatch.js';
|
|
16
|
+
import { contextFor, executeTool, resumedIssueUpdate, startTrackedIssue } from './dispatch.js';
|
|
17
17
|
import { defaultBaseRef, gitCommonDir } from './git.js';
|
|
18
18
|
import * as githubClient from './github/client.js';
|
|
19
|
+
import * as atomicJson from './utils/atomicJson.js';
|
|
19
20
|
import { createIssue, developIssue, listIssueSubissues, readyCheck, resumeCreatedIssue, updateIssue, } from './issue.js';
|
|
20
21
|
import { checkoutPr, finishIssue, listPrs, mergePr, prChecks, prInfo, publishPr, submitFormalReview } from './pr.js';
|
|
21
|
-
import { updateIssueWorktreeDelivery } from './services/worktreeRegistry.js';
|
|
22
|
+
import { addWorktreeEntry, removeWorktreeEntry, updateIssueWorktreeDelivery } from './services/worktreeRegistry.js';
|
|
22
23
|
import { assertDeliveryRuntimeAdmitted } from './services/deliveryAdmission.js';
|
|
23
24
|
import { getDeliveryRecords } from './services/deliveryRecordService.js';
|
|
24
|
-
import { createIssuePhaseEvidence, verifyIssue } from './verification.js';
|
|
25
|
+
import { createIssuePhaseEvidence, loadVerifiedRun, verifyIssue } from './verification.js';
|
|
25
26
|
import { cleanupNonIssueWorktree, prepareIssueWorktree, preparePrWorktree, prepareStandaloneWorktree, } from './worktree.js';
|
|
26
27
|
const personalRoute = vi.hoisted(() => ({
|
|
27
28
|
active: false,
|
|
@@ -29,6 +30,10 @@ const personalRoute = vi.hoisted(() => ({
|
|
|
29
30
|
headSha: '',
|
|
30
31
|
reviews: [],
|
|
31
32
|
submitted: 0,
|
|
33
|
+
created: false,
|
|
34
|
+
failCreate: false,
|
|
35
|
+
ready: false,
|
|
36
|
+
readyPromotions: 0,
|
|
32
37
|
}));
|
|
33
38
|
vi.mock('./github/client.js', async (importOriginal) => {
|
|
34
39
|
const actual = await importOriginal();
|
|
@@ -43,7 +48,7 @@ vi.mock('./github/client.js', async (importOriginal) => {
|
|
|
43
48
|
head: { sha: personalRoute.headSha, ref: 'issue/17' },
|
|
44
49
|
base: { sha: personalRoute.baseSha, ref: 'main' },
|
|
45
50
|
state: 'open',
|
|
46
|
-
draft:
|
|
51
|
+
draft: !personalRoute.ready,
|
|
47
52
|
user: { login: 'host-user' },
|
|
48
53
|
});
|
|
49
54
|
if (input.role === 'reviewer') {
|
|
@@ -101,6 +106,11 @@ vi.mock('./github/client.js', async (importOriginal) => {
|
|
|
101
106
|
},
|
|
102
107
|
};
|
|
103
108
|
}
|
|
109
|
+
if (query.includes('DeliveryDiscovery') ||
|
|
110
|
+
query.includes('DeliveryRepository') ||
|
|
111
|
+
query.includes('ProjectDeliveryConfiguration')) {
|
|
112
|
+
throw Object.assign(new Error('Reviewer cannot discover author routing metadata'), { status: 403 });
|
|
113
|
+
}
|
|
104
114
|
return syntheticDiscoveryClients().graphql(query, variables);
|
|
105
115
|
},
|
|
106
116
|
};
|
|
@@ -112,14 +122,29 @@ vi.mock('./github/client.js', async (importOriginal) => {
|
|
|
112
122
|
});
|
|
113
123
|
Object.assign(clients, {
|
|
114
124
|
authenticatedAuthor: async () => ({ actorLogin: 'host-user', credentialIdentity: 'user:37' }),
|
|
115
|
-
graphql:
|
|
125
|
+
graphql: async (query, variables) => {
|
|
126
|
+
if (query.includes('markPullRequestReadyForReview')) {
|
|
127
|
+
personalRoute.ready = true;
|
|
128
|
+
personalRoute.readyPromotions += 1;
|
|
129
|
+
return { markPullRequestReadyForReview: { pullRequest: { id: 'PR-23', isDraft: false } } };
|
|
130
|
+
}
|
|
131
|
+
return syntheticDiscoveryClients().graphql(query, variables);
|
|
132
|
+
},
|
|
116
133
|
rest: {
|
|
117
134
|
issues: { get: async () => ({ data: { title: 'Synthetic issue' } }) },
|
|
118
135
|
git: { getRef: async () => ({ data: { object: { sha: personalRoute.baseSha } } }) },
|
|
119
136
|
pulls: {
|
|
120
|
-
list: async () => ({ data: [] }),
|
|
121
|
-
create: async () =>
|
|
137
|
+
list: async () => ({ data: personalRoute.created ? [pr()] : [] }),
|
|
138
|
+
create: async () => {
|
|
139
|
+
if (personalRoute.failCreate) {
|
|
140
|
+
personalRoute.failCreate = false;
|
|
141
|
+
throw new Error('Synthetic response lost after branch push');
|
|
142
|
+
}
|
|
143
|
+
personalRoute.created = true;
|
|
144
|
+
return { data: pr() };
|
|
145
|
+
},
|
|
122
146
|
get: async () => ({ data: pr() }),
|
|
147
|
+
listReviews: async () => ({ data: personalRoute.reviews }),
|
|
123
148
|
},
|
|
124
149
|
request: async () => Promise.reject(Object.assign(new Error('Rules unreadable'), { status: 403 })),
|
|
125
150
|
},
|
|
@@ -194,6 +219,8 @@ async function fixture(options = {}) {
|
|
|
194
219
|
git(root, 'remote', 'add', remoteName, join(root, '.git', 'remote.git'));
|
|
195
220
|
writeFileSync(join(root, '.gitignore'), '.issue-cli/\n.worktrees/\n');
|
|
196
221
|
writeFileSync(join(root, 'artifact.txt'), 'synthetic proof\n');
|
|
222
|
+
if (options.componentPolicy)
|
|
223
|
+
writeFileSync(join(root, 'component.txt'), 'second component\n');
|
|
197
224
|
const config = {
|
|
198
225
|
commandPolicy: {
|
|
199
226
|
checks: { format: 'REQUIRED', gitClean: 'REQUIRED', lint: 'REQUIRED', test: 'REQUIRED', typecheck: 'REQUIRED' },
|
|
@@ -251,7 +278,7 @@ const bytes = value => 'sha256:' + createHash('sha256').update(value).digest('he
|
|
|
251
278
|
const policyDigest = bytes(readFileSync(fileURLToPath(import.meta.url)));
|
|
252
279
|
export const deliverySettings = ${JSON.stringify({ roles: config.roles, commandPolicy: config.commandPolicy })};
|
|
253
280
|
export default {
|
|
254
|
-
schemaVersion: 'RepositoryDeliveryPolicy@1',
|
|
281
|
+
schemaVersion: '${options.componentPolicy ? 'RepositoryDeliveryPolicy@2' : 'RepositoryDeliveryPolicy@1'}',
|
|
255
282
|
classifyExactRange(input) {
|
|
256
283
|
const content = { artifacts: [{ digest: bytes(readFileSync(new URL('./artifact.txt', import.meta.url))),
|
|
257
284
|
path: 'artifact.txt', producer: 'synthetic' }],
|
|
@@ -261,9 +288,11 @@ export default {
|
|
|
261
288
|
schemaVersion: 'ai-delivery.policy-evidence@1' };
|
|
262
289
|
return { policyDigest, policyEvidence: { ...content, evidenceId: digest(content) },
|
|
263
290
|
requiredStages: [{ id: 'check', dependsOn: [], semanticInputKeys: ['source'],
|
|
291
|
+
${options.componentPolicy ? "semanticInputs: [{ key: 'source', digest: bytes(readFileSync(new URL('./artifact.txt', import.meta.url))) }]," : ''}
|
|
264
292
|
resourceClass: 'source_only', commands: [{ label: 'count', argv: [process.execPath, '-e',
|
|
265
293
|
${JSON.stringify(options.firstStageScript ?? `const fs=require('fs');const p=${JSON.stringify(counter)};fs.writeFileSync(p,String(Number(fs.existsSync(p)?fs.readFileSync(p,'utf8'):0)+1));`)}] }] }${options.twoStages
|
|
266
294
|
? `, { id: 'second', dependsOn: ['check'], semanticInputKeys: ['source'],
|
|
295
|
+
${options.componentPolicy ? "semanticInputs: [{ key: 'source', digest: bytes(readFileSync(new URL('./component.txt', import.meta.url))) }]," : ''}
|
|
267
296
|
resourceClass: 'source_only', commands: [{ label: 'retry', argv: [process.execPath, '-e',
|
|
268
297
|
${JSON.stringify(options.secondStageScript ?? `const fs=require('fs');const p=${JSON.stringify(secondCounter)};fs.writeFileSync(p,String(Number(fs.existsSync(p)?fs.readFileSync(p,'utf8'):0)+1));if(fs.existsSync(${JSON.stringify(failSecond)}))process.exit(7);`)}] }] }`
|
|
269
298
|
: ''}],
|
|
@@ -300,7 +329,7 @@ export default {
|
|
|
300
329
|
mkdirSync(dirname(path), { recursive: true });
|
|
301
330
|
}
|
|
302
331
|
writeFileSync(runtimeEntryPath, 'synthetic CLI bytes\n');
|
|
303
|
-
writeFileSync(packagePath, JSON.stringify({ name: '@aviaratech/ai-delivery', version: '0.1.0' }));
|
|
332
|
+
writeFileSync(packagePath, JSON.stringify({ name: '@aviaratech/ai-delivery', version: '0.1.0', bin: { 'ai-delivery': './dist/cli.js' } }));
|
|
304
333
|
writeFileSync(mcpLauncherPath, 'synthetic MCP launcher bytes\n');
|
|
305
334
|
writeFileSync(pluginManifestPath, JSON.stringify({ name: 'ai-delivery', packageVersion: '0.1.0', deliveryCapabilityVersion: 2 }));
|
|
306
335
|
const content = {
|
|
@@ -322,7 +351,8 @@ export default {
|
|
|
322
351
|
};
|
|
323
352
|
const admissionPath = join(root, '.git', 'ai-delivery', 'runtime-admission.json');
|
|
324
353
|
mkdirSync(dirname(admissionPath), { recursive: true });
|
|
325
|
-
|
|
354
|
+
if (!options.omitRuntimeAdmission)
|
|
355
|
+
writeFileSync(admissionPath, JSON.stringify({ ...content, admissionId: digestValue(content) }), { mode: 0o600 });
|
|
326
356
|
return { root, counter, secondCounter, failSecond, runtimeEntryPath };
|
|
327
357
|
}
|
|
328
358
|
catch (error) {
|
|
@@ -647,6 +677,443 @@ test('synthetic cutover restores exact pre-write state and resumes an incomplete
|
|
|
647
677
|
rmSync(root, { recursive: true, force: true });
|
|
648
678
|
}
|
|
649
679
|
});
|
|
680
|
+
test('immediate successful commands establish ownership and remain reusable', async () => {
|
|
681
|
+
const { root } = await fixture();
|
|
682
|
+
try {
|
|
683
|
+
const row = await prepareIssueWorktree({
|
|
684
|
+
baseRef: 'main',
|
|
685
|
+
identity: 'synthetic-author',
|
|
686
|
+
issueNumber: 17,
|
|
687
|
+
repoRoot: root,
|
|
688
|
+
});
|
|
689
|
+
const policyPath = join(row.path, 'policy.mjs');
|
|
690
|
+
writeFileSync(policyPath, readFileSync(policyPath, 'utf8').replace("argv: [process.execPath, '-e',", "argv: ['/usr/bin/true',"));
|
|
691
|
+
git(row.path, 'add', 'policy.mjs');
|
|
692
|
+
git(row.path, 'commit', '-qm', 'immediate stage');
|
|
693
|
+
const first = await verifyIssue({ issueNumber: 17, repoRoot: row.path });
|
|
694
|
+
const reused = await verifyIssue({ issueNumber: 17, repoRoot: row.path });
|
|
695
|
+
assert.equal(first.stageReceipts[0].receiptId, reused.stageReceipts[0].receiptId);
|
|
696
|
+
const bounded = await verifyIssue({
|
|
697
|
+
issueNumber: 17,
|
|
698
|
+
repoRoot: row.path,
|
|
699
|
+
resourceBounds: { maxAggregateRssBytes: 1_000_000_000, minFreeDiskBytes: 1 },
|
|
700
|
+
});
|
|
701
|
+
assert.equal(bounded.aggregate.result, 'passed');
|
|
702
|
+
assert.ok(bounded.resources.sampleCount > 0);
|
|
703
|
+
assert.deepEqual(readdirSync(join(root, '.git/ai-delivery/writers@1')), []);
|
|
704
|
+
}
|
|
705
|
+
finally {
|
|
706
|
+
rmSync(root, { recursive: true, force: true });
|
|
707
|
+
}
|
|
708
|
+
});
|
|
709
|
+
test('component verification recomposes current-source aggregates with only affected stage execution', async () => {
|
|
710
|
+
const { root, counter, secondCounter } = await fixture({ twoStages: true, componentPolicy: true });
|
|
711
|
+
try {
|
|
712
|
+
const row = await prepareIssueWorktree({
|
|
713
|
+
baseRef: 'main',
|
|
714
|
+
identity: 'synthetic-author',
|
|
715
|
+
issueNumber: 17,
|
|
716
|
+
repoRoot: root,
|
|
717
|
+
});
|
|
718
|
+
const resourceBounds = { maxAggregateRssBytes: 1_000_000_000, minFreeDiskBytes: 1 };
|
|
719
|
+
const original = await verifyIssue({ issueNumber: 17, repoRoot: row.path, resourceBounds });
|
|
720
|
+
writeFileSync(join(row.path, 'unrelated.txt'), 'documentation');
|
|
721
|
+
git(row.path, 'add', '.');
|
|
722
|
+
git(row.path, 'commit', '-qm', 'unrelated source');
|
|
723
|
+
const current = await verifyIssue({ issueNumber: 17, repoRoot: row.path, resourceBounds });
|
|
724
|
+
assert.notEqual(current.classification.receiptId, original.classification.receiptId);
|
|
725
|
+
assert.deepEqual(current.stageReceipts.map((r) => r.receiptId), original.stageReceipts.map((r) => r.receiptId));
|
|
726
|
+
assert.equal(current.aggregate.classificationReceiptId, current.classification.receiptId);
|
|
727
|
+
assert.equal(readFileSync(counter, 'utf8'), '1');
|
|
728
|
+
assert.equal(readFileSync(secondCounter, 'utf8'), '1');
|
|
729
|
+
writeFileSync(join(row.path, 'component.txt'), 'changed second component');
|
|
730
|
+
git(row.path, 'add', '.');
|
|
731
|
+
git(row.path, 'commit', '-qm', 'second component');
|
|
732
|
+
const secondChanged = await verifyIssue({ issueNumber: 17, repoRoot: row.path, resourceBounds });
|
|
733
|
+
assert.equal(secondChanged.stageReceipts[0].receiptId, original.stageReceipts[0].receiptId);
|
|
734
|
+
assert.equal(readFileSync(counter, 'utf8'), '1');
|
|
735
|
+
assert.equal(readFileSync(secondCounter, 'utf8'), '2');
|
|
736
|
+
writeFileSync(join(row.path, 'artifact.txt'), 'changed upstream component');
|
|
737
|
+
git(row.path, 'add', '.');
|
|
738
|
+
git(row.path, 'commit', '-qm', 'upstream component');
|
|
739
|
+
await verifyIssue({ issueNumber: 17, repoRoot: row.path, resourceBounds });
|
|
740
|
+
assert.equal(readFileSync(counter, 'utf8'), '2');
|
|
741
|
+
assert.equal(readFileSync(secondCounter, 'utf8'), '3');
|
|
742
|
+
await createIssuePhaseEvidence({ issueNumber: 17, repoRoot: row.path, phase: 'verify' });
|
|
743
|
+
}
|
|
744
|
+
finally {
|
|
745
|
+
rmSync(root, { recursive: true, force: true });
|
|
746
|
+
}
|
|
747
|
+
});
|
|
748
|
+
test('quiet verification reports completion-based progress and remains cancellable', { timeout: 12_000 }, async () => {
|
|
749
|
+
const { root } = await fixture({ firstStageScript: 'setInterval(()=>{},1000);' });
|
|
750
|
+
const controller = new AbortController();
|
|
751
|
+
const progress = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
|
752
|
+
let running;
|
|
753
|
+
try {
|
|
754
|
+
const row = await prepareIssueWorktree({
|
|
755
|
+
baseRef: 'main',
|
|
756
|
+
identity: 'synthetic-author',
|
|
757
|
+
issueNumber: 17,
|
|
758
|
+
repoRoot: root,
|
|
759
|
+
});
|
|
760
|
+
running = verifyIssue({ issueNumber: 17, repoRoot: row.path, signal: controller.signal });
|
|
761
|
+
void running.catch(() => undefined);
|
|
762
|
+
let status;
|
|
763
|
+
for (let attempt = 0; attempt < 350; attempt++) {
|
|
764
|
+
const latest = progress.mock.calls
|
|
765
|
+
.map(([chunk]) => String(chunk))
|
|
766
|
+
.filter((line) => line.startsWith('ai-delivery.verify '))
|
|
767
|
+
.at(-1);
|
|
768
|
+
if (latest)
|
|
769
|
+
status = JSON.parse(latest.slice('ai-delivery.verify '.length));
|
|
770
|
+
if (Number(status?.elapsedMs) >= 5_000)
|
|
771
|
+
break;
|
|
772
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
773
|
+
}
|
|
774
|
+
assert.equal(status?.completedCommands, 0);
|
|
775
|
+
assert.equal(status?.remainingCommands, 1);
|
|
776
|
+
assert.equal(status?.completedStages, 0);
|
|
777
|
+
assert.equal(status?.capturedOutputBytes, 0);
|
|
778
|
+
assert.ok(Number(status?.lastCompletedWorkAgeMs) >= 5_000);
|
|
779
|
+
controller.abort();
|
|
780
|
+
await assert.rejects(running, /cancelled/u);
|
|
781
|
+
}
|
|
782
|
+
finally {
|
|
783
|
+
controller.abort();
|
|
784
|
+
await running?.catch(() => undefined);
|
|
785
|
+
vi.restoreAllMocks();
|
|
786
|
+
rmSync(root, { recursive: true, force: true });
|
|
787
|
+
}
|
|
788
|
+
});
|
|
789
|
+
test('writer persistence failure retains recovery identity while cancellation cleans its owned command', async () => {
|
|
790
|
+
const directory = mkdtempSync(join(tmpdir(), 'ai-delivery-writer-failure-'));
|
|
791
|
+
const marker = join(directory, 'started');
|
|
792
|
+
const { root } = await fixture({
|
|
793
|
+
firstStageScript: `const fs=require('fs');fs.writeFileSync(${JSON.stringify(marker)},String(process.pid));setInterval(()=>{},1000);`,
|
|
794
|
+
});
|
|
795
|
+
const controller = new AbortController();
|
|
796
|
+
const original = atomicJson.writePrivateJsonFileAtomically;
|
|
797
|
+
let failWrites = false;
|
|
798
|
+
const persistence = vi.spyOn(atomicJson, 'writePrivateJsonFileAtomically').mockImplementation((path, value) => {
|
|
799
|
+
if (failWrites && path.includes('/writers@1/'))
|
|
800
|
+
throw new Error('ENOSPC: synthetic writer persistence failure');
|
|
801
|
+
original(path, value);
|
|
802
|
+
});
|
|
803
|
+
let running;
|
|
804
|
+
let owner;
|
|
805
|
+
try {
|
|
806
|
+
const row = await prepareIssueWorktree({
|
|
807
|
+
baseRef: 'main',
|
|
808
|
+
identity: 'synthetic-author',
|
|
809
|
+
issueNumber: 17,
|
|
810
|
+
repoRoot: root,
|
|
811
|
+
});
|
|
812
|
+
const writerPath = join(root, '.git/ai-delivery/writers@1', `${digestValue(row.path).slice(7)}.json`);
|
|
813
|
+
running = verifyIssue({ issueNumber: 17, repoRoot: row.path, signal: controller.signal }).catch((error) => error);
|
|
814
|
+
for (let attempt = 0; attempt < 100 && !existsSync(marker); attempt++)
|
|
815
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
816
|
+
assert.ok(existsSync(marker));
|
|
817
|
+
const recorded = JSON.parse(readFileSync(writerPath, 'utf8'));
|
|
818
|
+
owner = recorded.command.root;
|
|
819
|
+
assert.equal(Number(readFileSync(marker, 'utf8')), owner.pid);
|
|
820
|
+
failWrites = true;
|
|
821
|
+
controller.abort();
|
|
822
|
+
const failure = String(await running);
|
|
823
|
+
assert.match(failure, /cancelled.*Writer state persistence failed.*ENOSPC/u);
|
|
824
|
+
assert.doesNotMatch(failure, /Owned process cleanup failed/u);
|
|
825
|
+
const remaining = spawnSync('ps', ['-p', String(owner.pid), '-o', 'stat='], { encoding: 'utf8' });
|
|
826
|
+
assert.ok(remaining.status === 1 || remaining.stdout.trim().startsWith('Z'));
|
|
827
|
+
assert.deepEqual(JSON.parse(readFileSync(writerPath, 'utf8')).command.root, owner);
|
|
828
|
+
assert.equal(existsSync(`${writerPath}.lock`), false);
|
|
829
|
+
failWrites = false;
|
|
830
|
+
await assert.rejects(verifyIssue({ issueNumber: 17, repoRoot: row.path }), /live verification writer/u);
|
|
831
|
+
assert.equal(existsSync(writerPath), true);
|
|
832
|
+
}
|
|
833
|
+
finally {
|
|
834
|
+
controller.abort();
|
|
835
|
+
await running;
|
|
836
|
+
persistence.mockRestore();
|
|
837
|
+
if (owner !== undefined) {
|
|
838
|
+
const current = spawnSync('ps', ['-p', String(owner.pid), '-o', 'lstart='], { encoding: 'utf8' });
|
|
839
|
+
if (current.status === 0 && current.stdout.trim() === owner.identity)
|
|
840
|
+
process.kill(owner.pid, 'SIGKILL');
|
|
841
|
+
}
|
|
842
|
+
rmSync(root, { recursive: true, force: true });
|
|
843
|
+
rmSync(directory, { recursive: true, force: true });
|
|
844
|
+
}
|
|
845
|
+
});
|
|
846
|
+
test('verification excludes a second writer in the same worktree before command execution', async () => {
|
|
847
|
+
const marker = join(tmpdir(), `ai-delivery-writer-${String(process.pid)}`);
|
|
848
|
+
const { root } = await fixture({
|
|
849
|
+
firstStageScript: `const fs=require('fs');fs.appendFileSync(${JSON.stringify(marker)},'started\\n');setInterval(()=>{},1000);`,
|
|
850
|
+
});
|
|
851
|
+
const controller = new AbortController();
|
|
852
|
+
let running;
|
|
853
|
+
try {
|
|
854
|
+
const row = await prepareIssueWorktree({
|
|
855
|
+
baseRef: 'main',
|
|
856
|
+
identity: 'synthetic-author',
|
|
857
|
+
issueNumber: 17,
|
|
858
|
+
repoRoot: root,
|
|
859
|
+
});
|
|
860
|
+
running = verifyIssue({ issueNumber: 17, repoRoot: row.path, signal: controller.signal }).catch((error) => error);
|
|
861
|
+
for (let attempt = 0; attempt < 100 && !existsSync(marker); attempt++)
|
|
862
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
863
|
+
assert.ok(existsSync(marker));
|
|
864
|
+
const contender = new AbortController();
|
|
865
|
+
const stopContender = setTimeout(() => contender.abort(), 500);
|
|
866
|
+
try {
|
|
867
|
+
await assert.rejects(verifyIssue({ issueNumber: 17, repoRoot: row.path, signal: contender.signal }), /writer|Lock acquisition/u);
|
|
868
|
+
}
|
|
869
|
+
finally {
|
|
870
|
+
clearTimeout(stopContender);
|
|
871
|
+
}
|
|
872
|
+
assert.equal(readFileSync(marker, 'utf8'), 'started\n');
|
|
873
|
+
controller.abort();
|
|
874
|
+
assert.match(String(await running), /cancelled/u);
|
|
875
|
+
}
|
|
876
|
+
finally {
|
|
877
|
+
controller.abort();
|
|
878
|
+
await running;
|
|
879
|
+
rmSync(root, { recursive: true, force: true });
|
|
880
|
+
rmSync(marker, { force: true });
|
|
881
|
+
}
|
|
882
|
+
});
|
|
883
|
+
test('live or unknown writer state fails closed even after a file lease becomes stale', async () => {
|
|
884
|
+
const { root, counter } = await fixture();
|
|
885
|
+
try {
|
|
886
|
+
const row = await prepareIssueWorktree({
|
|
887
|
+
baseRef: 'main',
|
|
888
|
+
identity: 'synthetic-author',
|
|
889
|
+
issueNumber: 17,
|
|
890
|
+
repoRoot: root,
|
|
891
|
+
});
|
|
892
|
+
const writerDirectory = join(root, '.git/ai-delivery/writers@1');
|
|
893
|
+
mkdirSync(writerDirectory, { recursive: true });
|
|
894
|
+
const writerPath = join(writerDirectory, `${digestValue(row.path).slice(7)}.json`);
|
|
895
|
+
const state = {
|
|
896
|
+
schemaVersion: 'ai-delivery.verification-writer@1',
|
|
897
|
+
writerId: '00000000-0000-4000-8000-000000000001',
|
|
898
|
+
worktreeDigest: digestValue(row.path),
|
|
899
|
+
owner: {
|
|
900
|
+
pid: process.pid,
|
|
901
|
+
identity: execFileSync('/bin/ps', ['-p', String(process.pid), '-o', 'lstart='], { encoding: 'utf8' }).trim(),
|
|
902
|
+
},
|
|
903
|
+
command: { phase: 'idle' },
|
|
904
|
+
};
|
|
905
|
+
writeFileSync(writerPath, JSON.stringify({ ...state, stateId: digestValue(state) }), { mode: 0o600 });
|
|
906
|
+
await assert.rejects(verifyIssue({ issueNumber: 17, repoRoot: row.path }), /live verification writer/u);
|
|
907
|
+
const unknown = {
|
|
908
|
+
...state,
|
|
909
|
+
owner: { ...state.owner, identity: 'replaced historical process' },
|
|
910
|
+
command: { phase: 'starting' },
|
|
911
|
+
};
|
|
912
|
+
writeFileSync(writerPath, JSON.stringify({ ...unknown, stateId: digestValue(unknown) }));
|
|
913
|
+
await assert.rejects(verifyIssue({ issueNumber: 17, repoRoot: row.path }), /unknown command ownership/u);
|
|
914
|
+
assert.equal(existsSync(counter), false);
|
|
915
|
+
assert.equal(existsSync(writerPath), true);
|
|
916
|
+
}
|
|
917
|
+
finally {
|
|
918
|
+
rmSync(root, { recursive: true, force: true });
|
|
919
|
+
}
|
|
920
|
+
});
|
|
921
|
+
test('same-head worktrees overlap with isolated checkpoints and cancellation preserves the peer', { timeout: 15_000 }, async () => {
|
|
922
|
+
const { root } = await fixture({
|
|
923
|
+
firstStageScript: `const fs=require('fs');const cp=require('child_process');const path=require('path');const dir=cp.execFileSync('git',['rev-parse','--absolute-git-dir'],{encoding:'utf8'}).trim();const marker=path.join(dir,'peer-command');fs.writeFileSync(marker,'running');const wait=setInterval(()=>{if(fs.existsSync(marker+'.release')){clearInterval(wait);fs.writeFileSync(marker,'passed');}},20);`,
|
|
924
|
+
});
|
|
925
|
+
const cancel = new AbortController();
|
|
926
|
+
const parallelCancel = new AbortController();
|
|
927
|
+
let first;
|
|
928
|
+
let second;
|
|
929
|
+
let parallelRuns = [];
|
|
930
|
+
try {
|
|
931
|
+
const a = await prepareIssueWorktree({
|
|
932
|
+
baseRef: 'main',
|
|
933
|
+
identity: 'synthetic-author',
|
|
934
|
+
issueNumber: 17,
|
|
935
|
+
repoRoot: root,
|
|
936
|
+
});
|
|
937
|
+
const b = await prepareIssueWorktree({
|
|
938
|
+
baseRef: 'main',
|
|
939
|
+
identity: 'synthetic-author',
|
|
940
|
+
issueNumber: 18,
|
|
941
|
+
repoRoot: root,
|
|
942
|
+
});
|
|
943
|
+
assert.equal(git(a.path, 'rev-parse', 'HEAD'), git(b.path, 'rev-parse', 'HEAD'));
|
|
944
|
+
first = verifyIssue({ issueNumber: 17, repoRoot: a.path, signal: cancel.signal }).catch((error) => error);
|
|
945
|
+
second = verifyIssue({ issueNumber: 18, repoRoot: b.path });
|
|
946
|
+
void second.catch(() => undefined);
|
|
947
|
+
const markerA = join(git(a.path, 'rev-parse', '--absolute-git-dir'), 'peer-command');
|
|
948
|
+
const markerB = join(git(b.path, 'rev-parse', '--absolute-git-dir'), 'peer-command');
|
|
949
|
+
for (let attempt = 0; attempt < 100 && !(existsSync(markerA) && existsSync(markerB)); attempt++)
|
|
950
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
951
|
+
assert.equal(readFileSync(markerA, 'utf8'), 'running');
|
|
952
|
+
assert.equal(readFileSync(markerB, 'utf8'), 'running');
|
|
953
|
+
cancel.abort();
|
|
954
|
+
assert.match(String(await first), /cancelled/u);
|
|
955
|
+
writeFileSync(`${markerB}.release`, 'ready');
|
|
956
|
+
const peer = await second;
|
|
957
|
+
writeFileSync(`${markerA}.release`, 'ready');
|
|
958
|
+
const retried = await verifyIssue({ issueNumber: 17, repoRoot: a.path });
|
|
959
|
+
assert.notEqual(peer.stageReceipts[0].input.inputId, retried.stageReceipts[0].input.inputId);
|
|
960
|
+
assert.equal(loadVerifiedRun(b.path, 18).manifestId, peer.manifestId);
|
|
961
|
+
assert.equal(loadVerifiedRun(a.path, 17).manifestId, retried.manifestId);
|
|
962
|
+
assert.equal(readFileSync(markerA, 'utf8'), 'passed');
|
|
963
|
+
assert.equal(readFileSync(markerB, 'utf8'), 'passed');
|
|
964
|
+
assert.deepEqual(peer.stageReceipts[0].commands, retried.stageReceipts[0].commands);
|
|
965
|
+
const resourceBounds = { maxAggregateRssBytes: 1_000_000_000, minFreeDiskBytes: 1 };
|
|
966
|
+
const parallelStartedAt = Date.now();
|
|
967
|
+
for (const marker of [markerA, markerB]) {
|
|
968
|
+
rmSync(marker);
|
|
969
|
+
rmSync(`${marker}.release`);
|
|
970
|
+
}
|
|
971
|
+
parallelRuns = [
|
|
972
|
+
verifyIssue({ issueNumber: 17, repoRoot: a.path, resourceBounds, signal: parallelCancel.signal }),
|
|
973
|
+
verifyIssue({ issueNumber: 18, repoRoot: b.path, resourceBounds, signal: parallelCancel.signal }),
|
|
974
|
+
];
|
|
975
|
+
const parallelRun = Promise.all(parallelRuns);
|
|
976
|
+
void parallelRun.catch(() => undefined);
|
|
977
|
+
for (let attempt = 0; attempt < 200 && !(existsSync(markerA) && existsSync(markerB)); attempt++)
|
|
978
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
979
|
+
assert.equal(readFileSync(markerA, 'utf8'), 'running');
|
|
980
|
+
assert.equal(readFileSync(markerB, 'utf8'), 'running');
|
|
981
|
+
for (const marker of [markerA, markerB])
|
|
982
|
+
writeFileSync(`${marker}.release`, 'ready');
|
|
983
|
+
const parallel = await parallelRun;
|
|
984
|
+
const parallelElapsedMs = Date.now() - parallelStartedAt;
|
|
985
|
+
assert.deepEqual(parallel.map((run) => run.stageReceipts[0].commands), [peer.stageReceipts[0].commands, retried.stageReceipts[0].commands]);
|
|
986
|
+
assert.ok(parallel.every((run) => run.resources.sampleCount > 0));
|
|
987
|
+
const reused = await verifyIssue({ issueNumber: 18, repoRoot: b.path, resourceBounds });
|
|
988
|
+
assert.equal(reused.stageReceipts[0].receiptId, parallel[1].stageReceipts[0].receiptId);
|
|
989
|
+
const manifestA = join(root, '.git/ai-delivery/runs@2', digestValue(a.path).slice(7), `${peer.classification.head.sha}.json`);
|
|
990
|
+
const { manifestId: _foreignId, ...foreign } = parallel[1];
|
|
991
|
+
const foreignContent = { ...foreign, writer: { ...foreign.writer, worktreeDigest: digestValue(a.path) } };
|
|
992
|
+
writeFileSync(manifestA, JSON.stringify({ ...foreignContent, manifestId: digestValue(foreignContent) }));
|
|
993
|
+
assert.throws(() => loadVerifiedRun(a.path, 17), /environment|foreign/u);
|
|
994
|
+
writeFileSync(manifestA, JSON.stringify(parallel[0]));
|
|
995
|
+
console.log('WRITER_ISOLATION_RECEIPT', JSON.stringify({
|
|
996
|
+
sameHead: peer.classification.head.sha,
|
|
997
|
+
peerManifest: peer.manifestId,
|
|
998
|
+
retryManifest: retried.manifestId,
|
|
999
|
+
peerReused: true,
|
|
1000
|
+
parallelElapsedMs,
|
|
1001
|
+
maxObservedRssBytes: Math.max(...parallel.map((run) => run.resources.maxSampledAggregateRssBytes)),
|
|
1002
|
+
}));
|
|
1003
|
+
}
|
|
1004
|
+
finally {
|
|
1005
|
+
cancel.abort();
|
|
1006
|
+
parallelCancel.abort();
|
|
1007
|
+
await first;
|
|
1008
|
+
await second?.catch(() => undefined);
|
|
1009
|
+
await Promise.allSettled(parallelRuns);
|
|
1010
|
+
rmSync(root, { recursive: true, force: true });
|
|
1011
|
+
}
|
|
1012
|
+
});
|
|
1013
|
+
test('interruption child verification', { skip: !process.env.AI_DELIVERY_INTERRUPTION_WORKTREE }, async () => {
|
|
1014
|
+
await verifyIssue({ issueNumber: 17, repoRoot: process.env.AI_DELIVERY_INTERRUPTION_WORKTREE });
|
|
1015
|
+
});
|
|
1016
|
+
test('interrupted writer recovers only its recorded command and resumes completed checkpoints', { timeout: 30_000 }, async () => {
|
|
1017
|
+
const { root, counter, secondCounter } = await fixture({
|
|
1018
|
+
twoStages: true,
|
|
1019
|
+
secondStageScript: `const fs=require('fs');const cp=require('child_process');const path=require('path');const dir=cp.execFileSync('git',['rev-parse','--git-common-dir'],{encoding:'utf8'}).trim();const count=path.join(dir,'second-stage-count.txt');fs.writeFileSync(count,String(Number(fs.existsSync(count)?fs.readFileSync(count,'utf8'):0)+1));if(!fs.existsSync(path.join(dir,'resume-interrupted')))setInterval(()=>{},1000);`,
|
|
1020
|
+
});
|
|
1021
|
+
let child;
|
|
1022
|
+
let workerPid;
|
|
1023
|
+
let ownedPid;
|
|
1024
|
+
let workerIdentity;
|
|
1025
|
+
let ownedIdentity;
|
|
1026
|
+
let peer;
|
|
1027
|
+
try {
|
|
1028
|
+
const row = await prepareIssueWorktree({
|
|
1029
|
+
baseRef: 'main',
|
|
1030
|
+
identity: 'synthetic-author',
|
|
1031
|
+
issueNumber: 17,
|
|
1032
|
+
repoRoot: root,
|
|
1033
|
+
});
|
|
1034
|
+
child = spawn(process.execPath, [
|
|
1035
|
+
join(process.cwd(), 'node_modules/vitest/vitest.mjs'),
|
|
1036
|
+
'run',
|
|
1037
|
+
'dist/lifecycle.test.js',
|
|
1038
|
+
'-t',
|
|
1039
|
+
'^interruption child verification$',
|
|
1040
|
+
], {
|
|
1041
|
+
cwd: process.cwd(),
|
|
1042
|
+
stdio: 'ignore',
|
|
1043
|
+
env: { ...process.env, AI_DELIVERY_INTERRUPTION_WORKTREE: row.path },
|
|
1044
|
+
});
|
|
1045
|
+
const childClosed = new Promise((resolve) => child.once('close', resolve));
|
|
1046
|
+
const writerPath = join(root, '.git/ai-delivery/writers@1', `${digestValue(row.path).slice(7)}.json`);
|
|
1047
|
+
let recorded;
|
|
1048
|
+
for (let attempt = 0; attempt < 200; attempt++) {
|
|
1049
|
+
if (existsSync(writerPath))
|
|
1050
|
+
recorded = JSON.parse(readFileSync(writerPath, 'utf8'));
|
|
1051
|
+
if (recorded?.command.phase === 'running' && existsSync(secondCounter))
|
|
1052
|
+
break;
|
|
1053
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
1054
|
+
}
|
|
1055
|
+
assert.equal(readFileSync(counter, 'utf8'), '1');
|
|
1056
|
+
assert.equal(readFileSync(secondCounter, 'utf8'), '1');
|
|
1057
|
+
assert.equal(recorded?.command.phase, 'running');
|
|
1058
|
+
workerPid = recorded.owner.pid;
|
|
1059
|
+
ownedPid = recorded.command.root.pid;
|
|
1060
|
+
workerIdentity = recorded.owner.identity;
|
|
1061
|
+
ownedIdentity = recorded.command.root.identity;
|
|
1062
|
+
const completedDir = join(root, '.git/ai-delivery/verification@1/stages/check');
|
|
1063
|
+
const completedCheckpoint = join(completedDir, readdirSync(completedDir)[0]);
|
|
1064
|
+
const completedDigest = sha256(completedCheckpoint);
|
|
1065
|
+
peer = spawn(process.execPath, ['-e', 'setInterval(()=>{},1000)'], { detached: true, stdio: 'ignore' });
|
|
1066
|
+
assert.equal(spawnSync('ps', ['-p', String(workerPid), '-o', 'lstart='], { encoding: 'utf8' }).stdout.trim(), workerIdentity);
|
|
1067
|
+
process.kill(workerPid, 'SIGKILL');
|
|
1068
|
+
workerPid = undefined;
|
|
1069
|
+
await childClosed;
|
|
1070
|
+
process.kill(ownedPid, 0);
|
|
1071
|
+
// A stale file lease is not permission to forget the durable command owner.
|
|
1072
|
+
const stale = new Date(Date.now() - 20_000);
|
|
1073
|
+
utimesSync(`${writerPath}.lock`, stale, stale);
|
|
1074
|
+
writeFileSync(join(root, '.git/resume-interrupted'), 'ready');
|
|
1075
|
+
const recovered = await verifyIssue({ issueNumber: 17, repoRoot: row.path });
|
|
1076
|
+
assert.equal(readFileSync(counter, 'utf8'), '1');
|
|
1077
|
+
assert.equal(readFileSync(secondCounter, 'utf8'), '2');
|
|
1078
|
+
assert.equal(sha256(completedCheckpoint), completedDigest);
|
|
1079
|
+
assert.equal(recovered.aggregate.result, 'passed');
|
|
1080
|
+
process.kill(peer.pid, 0);
|
|
1081
|
+
assert.equal(existsSync(writerPath), false);
|
|
1082
|
+
assert.equal(existsSync(`${writerPath}.lock`), false);
|
|
1083
|
+
const survivor = spawnSync('/bin/ps', ['-p', String(ownedPid), '-o', 'stat='], { encoding: 'utf8' });
|
|
1084
|
+
assert.ok((survivor.status === 1 && !survivor.stdout.trim()) ||
|
|
1085
|
+
(survivor.status === 0 && survivor.stdout.trim().startsWith('Z')));
|
|
1086
|
+
console.log('INTERRUPTION_RESUME_RECEIPT', JSON.stringify({
|
|
1087
|
+
completedCheckpoint: completedDigest,
|
|
1088
|
+
firstStageExecutions: 1,
|
|
1089
|
+
interruptedStageExecutions: 2,
|
|
1090
|
+
peerPreserved: true,
|
|
1091
|
+
aggregateId: recovered.aggregate.aggregateId,
|
|
1092
|
+
}));
|
|
1093
|
+
ownedPid = undefined;
|
|
1094
|
+
}
|
|
1095
|
+
finally {
|
|
1096
|
+
for (const [pid, identity] of [
|
|
1097
|
+
[workerPid, workerIdentity],
|
|
1098
|
+
[ownedPid, ownedIdentity],
|
|
1099
|
+
])
|
|
1100
|
+
if (pid !== undefined && identity !== undefined) {
|
|
1101
|
+
const current = spawnSync('ps', ['-p', String(pid), '-o', 'lstart='], { encoding: 'utf8' });
|
|
1102
|
+
if (current.status !== 0 || current.stdout.trim() !== identity)
|
|
1103
|
+
continue;
|
|
1104
|
+
try {
|
|
1105
|
+
process.kill(pid, 'SIGKILL');
|
|
1106
|
+
}
|
|
1107
|
+
catch (error) {
|
|
1108
|
+
assert.equal(error.code, 'ESRCH');
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
for (const processHandle of [peer, child])
|
|
1112
|
+
if (processHandle?.exitCode === null && processHandle.signalCode === null)
|
|
1113
|
+
processHandle.kill('SIGKILL');
|
|
1114
|
+
rmSync(root, { recursive: true, force: true });
|
|
1115
|
+
}
|
|
1116
|
+
});
|
|
650
1117
|
test('issue worktree verification resumes a completed stage and rejects changed inputs', async () => {
|
|
651
1118
|
const { root, counter, runtimeEntryPath } = await fixture();
|
|
652
1119
|
try {
|
|
@@ -1143,7 +1610,7 @@ test('a bounded passing run records measured resources in versioned evidence', a
|
|
|
1143
1610
|
repoRoot: row.path,
|
|
1144
1611
|
resourceBounds: { maxAggregateRssBytes: 1_000_000_000, minFreeDiskBytes: 1 },
|
|
1145
1612
|
});
|
|
1146
|
-
assert.equal(run.schemaVersion, 'ai-delivery.run@
|
|
1613
|
+
assert.equal(run.schemaVersion, 'ai-delivery.run@3');
|
|
1147
1614
|
const resources = run.resources;
|
|
1148
1615
|
assert.ok(resources);
|
|
1149
1616
|
assert.ok(resources.sampleCount > 0);
|
|
@@ -1413,6 +1880,37 @@ test('filesystem output from a sampled detached child crosses its declared root
|
|
|
1413
1880
|
rmSync(root, { recursive: true, force: true });
|
|
1414
1881
|
}
|
|
1415
1882
|
});
|
|
1883
|
+
test('contained npm bin aliases are counted once and target growth still enforces the output bound', async () => {
|
|
1884
|
+
const outside = realpathSync(mkdtempSync(join(tmpdir(), 'ai-delivery-contained-alias-')));
|
|
1885
|
+
const target = join(outside, 'output', 'actual');
|
|
1886
|
+
const { root } = await fixture({
|
|
1887
|
+
firstStageScript: `require('fs').appendFileSync(${JSON.stringify(target)},'x'.repeat(512));`,
|
|
1888
|
+
});
|
|
1889
|
+
try {
|
|
1890
|
+
mkdirSync(dirname(target));
|
|
1891
|
+
writeFileSync(target, 'baseline');
|
|
1892
|
+
symlinkSync('./actual', join(dirname(target), 'alias'));
|
|
1893
|
+
const row = await prepareIssueWorktree({
|
|
1894
|
+
baseRef: 'main',
|
|
1895
|
+
identity: 'synthetic-author',
|
|
1896
|
+
issueNumber: 17,
|
|
1897
|
+
repoRoot: root,
|
|
1898
|
+
});
|
|
1899
|
+
const bounds = {
|
|
1900
|
+
maxAggregateRssBytes: 1_000_000_000,
|
|
1901
|
+
minFreeDiskBytes: 1,
|
|
1902
|
+
maxNewOutputBytes: 600,
|
|
1903
|
+
outputRoots: [dirname(target)],
|
|
1904
|
+
};
|
|
1905
|
+
const run = await verifyIssue({ issueNumber: 17, repoRoot: row.path, resourceBounds: bounds });
|
|
1906
|
+
assert.equal(run.resources?.maxSampledNewOutputBytes, 512);
|
|
1907
|
+
await assert.rejects(verifyIssue({ issueNumber: 17, repoRoot: row.path, resourceBounds: { ...bounds, maxNewOutputBytes: 100 } }), /filesystem output.*exceeded/u);
|
|
1908
|
+
}
|
|
1909
|
+
finally {
|
|
1910
|
+
rmSync(root, { recursive: true, force: true });
|
|
1911
|
+
rmSync(outside, { recursive: true, force: true });
|
|
1912
|
+
}
|
|
1913
|
+
});
|
|
1416
1914
|
test('a symbolic link in declared output roots fails before publishing sampled output evidence', async () => {
|
|
1417
1915
|
const { root } = await fixture();
|
|
1418
1916
|
const outside = mkdtempSync(join(tmpdir(), 'ai-delivery-output-link-'));
|
|
@@ -1738,25 +2236,46 @@ test('public PR dry run retains a configured host author and App reviewer', asyn
|
|
|
1738
2236
|
rmSync(root, { recursive: true, force: true });
|
|
1739
2237
|
}
|
|
1740
2238
|
}, 15_000);
|
|
1741
|
-
test(
|
|
1742
|
-
|
|
2239
|
+
test.each([
|
|
2240
|
+
{ transition: false, entry: 'source', highRisk: false, mcp: false },
|
|
2241
|
+
{ transition: true, entry: 'primary', highRisk: false, mcp: false },
|
|
2242
|
+
{ transition: true, entry: 'source', highRisk: false, mcp: false },
|
|
2243
|
+
{ transition: true, entry: 'primary', highRisk: true, mcp: false },
|
|
2244
|
+
{ transition: true, entry: 'source', highRisk: true, mcp: false },
|
|
2245
|
+
{ transition: true, entry: 'primary', highRisk: true, mcp: true },
|
|
2246
|
+
])('public dispatch publishes and reviews from $entry across policy transition $transition high risk $highRisk MCP $mcp', async ({ transition, entry, highRisk, mcp }) => {
|
|
2247
|
+
const { root, runtimeEntryPath } = await fixture({ personalAuthor: !transition });
|
|
1743
2248
|
const originalPath = process.env.PATH;
|
|
2249
|
+
const driftEnv = 'AI_DELIVERY_TEST_CANDIDATE_CHECK';
|
|
2250
|
+
const previousDrift = process.env[driftEnv];
|
|
2251
|
+
delete process.env[driftEnv];
|
|
2252
|
+
let closeMcp;
|
|
1744
2253
|
try {
|
|
2254
|
+
const admissionPath = join(root, '.git', 'ai-delivery', 'runtime-admission.json');
|
|
2255
|
+
const admissionBefore = readFileSync(admissionPath, 'utf8');
|
|
2256
|
+
const primaryConfiguration = await loadDeliveryConfig(root);
|
|
1745
2257
|
const row = await prepareIssueWorktree({
|
|
1746
2258
|
baseRef: 'main',
|
|
1747
|
-
identity: 'host-author',
|
|
2259
|
+
identity: transition ? 'synthetic-author' : 'host-author',
|
|
1748
2260
|
issueNumber: 17,
|
|
1749
2261
|
repoRoot: root,
|
|
1750
2262
|
});
|
|
2263
|
+
if (transition) {
|
|
2264
|
+
const policyPath = join(row.path, 'policy.mjs');
|
|
2265
|
+
writeFileSync(policyPath, readFileSync(policyPath, 'utf8')
|
|
2266
|
+
.replace(JSON.stringify(primaryConfiguration.config.roles.author), JSON.stringify({
|
|
2267
|
+
authSource: 'personal',
|
|
2268
|
+
credentialEnv: { token: 'AUTHOR_TOKEN' },
|
|
2269
|
+
identity: 'host-author',
|
|
2270
|
+
}))
|
|
2271
|
+
.replace("risk: 'standard'", `risk: '${highRisk ? 'high' : 'standard'}'`) +
|
|
2272
|
+
`
|
|
2273
|
+
Object.defineProperty(deliverySettings.commandPolicy.checks, 'test', { get: () => process.env.${driftEnv} ?? 'REQUIRED' });
|
|
2274
|
+
`);
|
|
2275
|
+
}
|
|
1751
2276
|
writeFileSync(join(row.path, 'change.txt'), 'host author publication\n');
|
|
1752
|
-
git(row.path, 'add', '
|
|
2277
|
+
git(row.path, 'add', '.');
|
|
1753
2278
|
git(row.path, 'commit', '-qm', 'synthetic host publication');
|
|
1754
|
-
const run = await verifyIssue({ issueNumber: 17, repoRoot: row.path });
|
|
1755
|
-
personalRoute.baseSha = run.classification.base.sha;
|
|
1756
|
-
personalRoute.headSha = run.classification.head.sha;
|
|
1757
|
-
personalRoute.reviews.length = 0;
|
|
1758
|
-
personalRoute.submitted = 0;
|
|
1759
|
-
personalRoute.active = true;
|
|
1760
2279
|
const realGit = (originalPath ?? '')
|
|
1761
2280
|
.split(':')
|
|
1762
2281
|
.map((directory) => join(directory, 'git'))
|
|
@@ -1779,24 +2298,36 @@ exec "${realGit}" "$@"
|
|
|
1779
2298
|
`);
|
|
1780
2299
|
chmodSync(shim, 0o700);
|
|
1781
2300
|
process.env.PATH = `${shimDir}:${originalPath ?? ''}`;
|
|
1782
|
-
const execution = { repoRoot: row.path, runtimeEntryPath, identity: 'host-author' };
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
2301
|
+
const execution = { repoRoot: entry === 'primary' ? root : row.path, runtimeEntryPath, identity: 'host-author' };
|
|
2302
|
+
let call = (name, args) => executeTool(name, args, execution);
|
|
2303
|
+
if (mcp) {
|
|
2304
|
+
const server = createAiDeliveryMcpServer(execution);
|
|
2305
|
+
const client = new Client({ name: 'policy-transition-test', version: '1.0.0' });
|
|
2306
|
+
closeMcp = async () => {
|
|
2307
|
+
await client.close();
|
|
2308
|
+
await server.close();
|
|
2309
|
+
};
|
|
2310
|
+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
2311
|
+
await server.connect(serverTransport);
|
|
2312
|
+
await client.connect(clientTransport);
|
|
2313
|
+
call = async (name, args) => {
|
|
2314
|
+
const result = await client.callTool({ name, arguments: args });
|
|
2315
|
+
const text = result.content.find((block) => block.type === 'text').text;
|
|
2316
|
+
if (result.isError)
|
|
2317
|
+
throw new Error(text);
|
|
2318
|
+
return JSON.parse(text);
|
|
2319
|
+
};
|
|
2320
|
+
}
|
|
2321
|
+
const classified = await verifyIssue({ issueNumber: 17, repoRoot: row.path });
|
|
2322
|
+
const reviewContent = {
|
|
1792
2323
|
authorIdentity: 'host-author',
|
|
1793
2324
|
checks: ['verified exact diff'],
|
|
1794
|
-
diffScopeHash: digestValue(
|
|
2325
|
+
diffScopeHash: digestValue(classified.classification.changedPaths),
|
|
1795
2326
|
elapsedMs: 1,
|
|
1796
2327
|
findings: [],
|
|
1797
|
-
head:
|
|
2328
|
+
head: classified.classification.head,
|
|
1798
2329
|
issueNumber: 17,
|
|
1799
|
-
prNumber:
|
|
2330
|
+
prNumber: null,
|
|
1800
2331
|
readOnly: true,
|
|
1801
2332
|
requestedEffort: 'xhigh',
|
|
1802
2333
|
requestedModel: 'gpt-6-astra',
|
|
@@ -1807,19 +2338,112 @@ exec "${realGit}" "$@"
|
|
|
1807
2338
|
summary: 'Independent exact-head review',
|
|
1808
2339
|
verdict: 'approve',
|
|
1809
2340
|
};
|
|
2341
|
+
const prepublicationReview = join(root, '.git', 'prepublication-review.json');
|
|
2342
|
+
if (highRisk) {
|
|
2343
|
+
await assert.rejects(call('issue_verify', { issueNumber: 17 }), /independent exact-head approval/u);
|
|
2344
|
+
writeFileSync(prepublicationReview, JSON.stringify({ ...reviewContent, artifactId: digestValue(reviewContent) }));
|
|
2345
|
+
}
|
|
2346
|
+
const verified = (await call('issue_verify', {
|
|
2347
|
+
issueNumber: 17,
|
|
2348
|
+
...(highRisk ? { prepublicationReview } : {}),
|
|
2349
|
+
}));
|
|
2350
|
+
const run = loadVerifiedRun(row.path, 17);
|
|
2351
|
+
const candidateConfiguration = await loadDeliveryConfig(row.path);
|
|
2352
|
+
assert.equal(run.classification.configDigest, candidateConfiguration.configDigest);
|
|
2353
|
+
assert.equal(run.classification.policyDigest, sha256(join(row.path, 'policy.mjs')));
|
|
2354
|
+
if (transition)
|
|
2355
|
+
assert.notEqual(run.classification.configDigest, primaryConfiguration.configDigest);
|
|
2356
|
+
assert.equal(readFileSync(admissionPath, 'utf8'), admissionBefore);
|
|
2357
|
+
const phaseEvidence = JSON.parse(readFileSync(join(root, '.git', 'ai-delivery', 'receipts', 'delivery@1', run.classification.head.sha, `${verified.evidenceId.slice(7)}.json`), 'utf8'));
|
|
2358
|
+
assert.equal(phaseEvidence.configDigest, candidateConfiguration.configDigest);
|
|
2359
|
+
assert.equal(phaseEvidence.aggregateId, run.aggregate.aggregateId);
|
|
2360
|
+
if (highRisk) {
|
|
2361
|
+
assert.equal(phaseEvidence.approval?.authorIdentity, 'host-author');
|
|
2362
|
+
assert.equal(phaseEvidence.approval?.reviewerIdentity, 'synthetic-reviewer');
|
|
2363
|
+
}
|
|
2364
|
+
personalRoute.baseSha = run.classification.base.sha;
|
|
2365
|
+
personalRoute.headSha = run.classification.head.sha;
|
|
2366
|
+
personalRoute.reviews.length = 0;
|
|
2367
|
+
personalRoute.submitted = 0;
|
|
2368
|
+
personalRoute.created = false;
|
|
2369
|
+
personalRoute.failCreate = false;
|
|
2370
|
+
personalRoute.ready = false;
|
|
2371
|
+
personalRoute.readyPromotions = 0;
|
|
2372
|
+
personalRoute.active = true;
|
|
2373
|
+
if (transition) {
|
|
2374
|
+
await assert.rejects(executeTool('issue_pr_create', { issueNumber: 17 }, { ...execution, identity: 'synthetic-author' }), /configured author identity/u);
|
|
2375
|
+
writeFileSync(join(row.path, 'dirty.txt'), 'unverified');
|
|
2376
|
+
await assert.rejects(executeTool('issue_pr_create', { issueNumber: 17 }, execution), /clean worktree/u);
|
|
2377
|
+
rmSync(join(row.path, 'dirty.txt'));
|
|
2378
|
+
git(row.path, 'commit', '--allow-empty', '-qm', 'unverified head');
|
|
2379
|
+
await assert.rejects(executeTool('issue_pr_create', { issueNumber: 17 }, execution));
|
|
2380
|
+
assert.equal(existsSync(pushMarker), false);
|
|
2381
|
+
git(row.path, 'reset', '--hard', run.classification.head.sha);
|
|
2382
|
+
personalRoute.baseSha = 'f'.repeat(40);
|
|
2383
|
+
await assert.rejects(call('issue_pr_create', { issueNumber: 17 }), /Remote base changed/u);
|
|
2384
|
+
assert.equal(existsSync(pushMarker), false);
|
|
2385
|
+
personalRoute.baseSha = run.classification.base.sha;
|
|
2386
|
+
personalRoute.failCreate = true;
|
|
2387
|
+
await assert.rejects(call('issue_pr_create', { issueNumber: 17 }), /response lost after branch push/u);
|
|
2388
|
+
assert.equal(existsSync(pushMarker), true);
|
|
2389
|
+
assert.equal(personalRoute.created, false);
|
|
2390
|
+
}
|
|
2391
|
+
const published = (await call('issue_pr_create', { issueNumber: 17 }));
|
|
2392
|
+
assert.equal(published.prNumber, 23);
|
|
2393
|
+
assert.equal(existsSync(pushMarker), true);
|
|
2394
|
+
assert.equal(published.reviewRoute.author.actorLogin, 'host-user');
|
|
2395
|
+
assert.equal(published.reviewRoute.author.credentialSource, 'env:AUTHOR_TOKEN');
|
|
2396
|
+
assert.equal(published.reviewRoute.reviewer.actorLogin, 'synthetic-reviewer[bot]');
|
|
2397
|
+
const info = (await call('issue_pr_info', { issueNumber: 17, prNumber: 23 }));
|
|
2398
|
+
assert.equal(info.authorLogin, 'host-user');
|
|
2399
|
+
assert.equal(readFileSync(admissionPath, 'utf8'), admissionBefore);
|
|
2400
|
+
const artifactContent = { ...reviewContent, prNumber: 23 };
|
|
1810
2401
|
const artifact = JSON.stringify({ ...artifactContent, artifactId: digestValue(artifactContent) });
|
|
1811
2402
|
const reviewInput = { issueNumber: 17, prNumber: 23, identity: 'synthetic-reviewer', artifact };
|
|
1812
|
-
|
|
2403
|
+
if (transition) {
|
|
2404
|
+
personalRoute.headSha = 'b'.repeat(40);
|
|
2405
|
+
await assert.rejects(call('issue_pr_review', reviewInput), /head/u);
|
|
2406
|
+
assert.equal(personalRoute.submitted, 0);
|
|
2407
|
+
personalRoute.headSha = run.classification.head.sha;
|
|
2408
|
+
}
|
|
2409
|
+
if (transition && entry === 'source') {
|
|
2410
|
+
process.env[driftEnv] = 'SKIP';
|
|
2411
|
+
assert.notEqual((await loadDeliveryConfig(row.path)).configDigest, candidateConfiguration.configDigest);
|
|
2412
|
+
assert.equal((await loadDeliveryConfig(root)).configDigest, primaryConfiguration.configDigest);
|
|
2413
|
+
await assert.rejects(call('issue_pr_review', reviewInput), /classification is stale against config/u);
|
|
2414
|
+
assert.equal(personalRoute.submitted, 0);
|
|
2415
|
+
delete process.env[driftEnv];
|
|
2416
|
+
}
|
|
2417
|
+
const first = (await call('issue_pr_review', reviewInput));
|
|
1813
2418
|
assert.equal(first.reviewState.status, 'still-required');
|
|
1814
2419
|
assert.equal(first.reviewState.submittedReviewAuthorCanPushToRepository, false);
|
|
1815
2420
|
assert.equal(personalRoute.submitted, 1);
|
|
1816
|
-
|
|
2421
|
+
if (transition && entry === 'primary') {
|
|
2422
|
+
process.env[driftEnv] = 'SKIP';
|
|
2423
|
+
assert.notEqual((await loadDeliveryConfig(row.path)).configDigest, candidateConfiguration.configDigest);
|
|
2424
|
+
assert.equal((await loadDeliveryConfig(root)).configDigest, primaryConfiguration.configDigest);
|
|
2425
|
+
await assert.rejects(call('issue_pr_create', { issueNumber: 17, draft: false }), /classification is stale against config/u);
|
|
2426
|
+
assert.equal(personalRoute.readyPromotions, 0);
|
|
2427
|
+
delete process.env[driftEnv];
|
|
2428
|
+
await call('issue_pr_create', { issueNumber: 17, draft: false });
|
|
2429
|
+
assert.equal(personalRoute.readyPromotions, 1);
|
|
2430
|
+
}
|
|
2431
|
+
const second = (await call('issue_pr_review', reviewInput));
|
|
1817
2432
|
assert.equal(second.receiptId, first.receiptId);
|
|
1818
2433
|
assert.equal(personalRoute.submitted, 1);
|
|
1819
2434
|
}
|
|
1820
2435
|
finally {
|
|
2436
|
+
await closeMcp?.();
|
|
1821
2437
|
personalRoute.active = false;
|
|
2438
|
+
personalRoute.created = false;
|
|
2439
|
+
personalRoute.failCreate = false;
|
|
1822
2440
|
personalRoute.reviews.length = 0;
|
|
2441
|
+
personalRoute.ready = false;
|
|
2442
|
+
personalRoute.readyPromotions = 0;
|
|
2443
|
+
if (previousDrift === undefined)
|
|
2444
|
+
delete process.env[driftEnv];
|
|
2445
|
+
else
|
|
2446
|
+
process.env[driftEnv] = previousDrift;
|
|
1823
2447
|
if (originalPath === undefined)
|
|
1824
2448
|
delete process.env.PATH;
|
|
1825
2449
|
else
|
|
@@ -1827,6 +2451,96 @@ exec "${realGit}" "$@"
|
|
|
1827
2451
|
rmSync(root, { recursive: true, force: true });
|
|
1828
2452
|
}
|
|
1829
2453
|
}, 15_000);
|
|
2454
|
+
test.each(['runtime', 'primary-policy', 'witness', 'sibling', 'foreign-repository', 'missing-source'])('source-bound dispatch rejects invalid %s before running candidate stages', async (invalid) => {
|
|
2455
|
+
const { root, runtimeEntryPath, counter } = await fixture();
|
|
2456
|
+
let foreignRoot;
|
|
2457
|
+
try {
|
|
2458
|
+
const row = await prepareIssueWorktree({
|
|
2459
|
+
baseRef: 'main',
|
|
2460
|
+
identity: 'synthetic-author',
|
|
2461
|
+
issueNumber: 17,
|
|
2462
|
+
repoRoot: root,
|
|
2463
|
+
});
|
|
2464
|
+
let requestedRoot = root;
|
|
2465
|
+
const expected = {
|
|
2466
|
+
runtime: /source, capability or repository admission/u,
|
|
2467
|
+
'primary-policy': /source, capability or repository admission/u,
|
|
2468
|
+
witness: /ownership witness/u,
|
|
2469
|
+
sibling: /primary or exact registered issue checkout/u,
|
|
2470
|
+
'foreign-repository': /primary Git repository/u,
|
|
2471
|
+
'missing-source': /git rev-parse failed/u,
|
|
2472
|
+
}[invalid];
|
|
2473
|
+
if (invalid === 'runtime')
|
|
2474
|
+
writeFileSync(join(dirname(runtimeEntryPath), 'changed.js'), 'unadmitted');
|
|
2475
|
+
if (invalid === 'primary-policy')
|
|
2476
|
+
writeFileSync(join(root, 'policy.mjs'), `${readFileSync(join(root, 'policy.mjs'), 'utf8')}\n// unadmitted controller policy\n`);
|
|
2477
|
+
if (invalid === 'witness')
|
|
2478
|
+
rmSync(join(root, '.git', 'ai-delivery', 'worktree-owners'), { recursive: true });
|
|
2479
|
+
if (invalid === 'sibling')
|
|
2480
|
+
requestedRoot = (await prepareStandaloneWorktree({
|
|
2481
|
+
name: 'sibling',
|
|
2482
|
+
branch: 'scratch/sibling',
|
|
2483
|
+
identity: 'synthetic-author',
|
|
2484
|
+
repoRoot: root,
|
|
2485
|
+
})).path;
|
|
2486
|
+
if (invalid === 'foreign-repository') {
|
|
2487
|
+
foreignRoot = (await fixture()).root;
|
|
2488
|
+
const foreign = await prepareIssueWorktree({
|
|
2489
|
+
baseRef: 'main',
|
|
2490
|
+
identity: 'synthetic-author',
|
|
2491
|
+
issueNumber: 17,
|
|
2492
|
+
repoRoot: foreignRoot,
|
|
2493
|
+
});
|
|
2494
|
+
await removeWorktreeEntry(row.path, root);
|
|
2495
|
+
await addWorktreeEntry(foreign, root);
|
|
2496
|
+
}
|
|
2497
|
+
if (invalid === 'missing-source')
|
|
2498
|
+
git(root, 'worktree', 'remove', row.path);
|
|
2499
|
+
await assert.rejects(executeTool('issue_verify', { issueNumber: 17 }, { repoRoot: requestedRoot, runtimeEntryPath }), expected);
|
|
2500
|
+
assert.equal(existsSync(counter), false);
|
|
2501
|
+
}
|
|
2502
|
+
finally {
|
|
2503
|
+
if (foreignRoot !== undefined)
|
|
2504
|
+
rmSync(foreignRoot, { recursive: true, force: true });
|
|
2505
|
+
rmSync(root, { recursive: true, force: true });
|
|
2506
|
+
}
|
|
2507
|
+
});
|
|
2508
|
+
test('source-bound publication preserves rejection of a legacy personal override for an App author', async () => {
|
|
2509
|
+
const { root, runtimeEntryPath } = await fixture();
|
|
2510
|
+
let restore;
|
|
2511
|
+
try {
|
|
2512
|
+
const row = await prepareIssueWorktree({
|
|
2513
|
+
baseRef: 'main',
|
|
2514
|
+
identity: 'synthetic-author',
|
|
2515
|
+
issueNumber: 17,
|
|
2516
|
+
repoRoot: root,
|
|
2517
|
+
});
|
|
2518
|
+
const run = await verifyIssue({ issueNumber: 17, repoRoot: row.path });
|
|
2519
|
+
const factory = githubClient.createDeliveryGitHubClients;
|
|
2520
|
+
const selected = vi.spyOn(githubClient, 'createDeliveryGitHubClients').mockImplementation(async (input) => {
|
|
2521
|
+
const clients = await factory(input);
|
|
2522
|
+
if (input.role !== 'author')
|
|
2523
|
+
return clients;
|
|
2524
|
+
Object.assign(clients.rest.issues, { get: async () => ({ data: { title: 'Synthetic issue' } }) });
|
|
2525
|
+
Object.assign(clients.rest.git, {
|
|
2526
|
+
getRef: async () => ({ data: { object: { sha: run.classification.base.sha } } }),
|
|
2527
|
+
});
|
|
2528
|
+
return { ...clients, authSource: 'personal', credentialSource: 'legacy:personal' };
|
|
2529
|
+
});
|
|
2530
|
+
restore = () => selected.mockRestore();
|
|
2531
|
+
await assert.rejects(executeTool('issue_pr_create', { issueNumber: 17 }, {
|
|
2532
|
+
repoRoot: root,
|
|
2533
|
+
runtimeEntryPath,
|
|
2534
|
+
identity: 'personal',
|
|
2535
|
+
personalAuth: true,
|
|
2536
|
+
}), /requires the configured author credential/u);
|
|
2537
|
+
assert.equal(git(join(root, '.git', 'remote.git'), 'for-each-ref', '--format=%(refname)', 'refs/heads/issue/17'), '');
|
|
2538
|
+
}
|
|
2539
|
+
finally {
|
|
2540
|
+
restore?.();
|
|
2541
|
+
rmSync(root, { recursive: true, force: true });
|
|
2542
|
+
}
|
|
2543
|
+
});
|
|
1830
2544
|
test('scratch start derives safe names and reports the requested worktree', async () => {
|
|
1831
2545
|
const { root, runtimeEntryPath } = await fixture();
|
|
1832
2546
|
try {
|
|
@@ -1843,8 +2557,228 @@ test('scratch start derives safe names and reports the requested worktree', asyn
|
|
|
1843
2557
|
rmSync(root, { recursive: true, force: true });
|
|
1844
2558
|
}
|
|
1845
2559
|
});
|
|
2560
|
+
test.each(['direct-develop', 'direct-start', 'develop', 'existing-start', 'new-start', 'scratch', 'standalone'])('development access preflight rejects absent reviewer credentials before %s preparation', async (entry) => {
|
|
2561
|
+
const { root, runtimeEntryPath } = await fixture();
|
|
2562
|
+
const admissionPath = join(root, '.git', 'ai-delivery', 'runtime-admission.json');
|
|
2563
|
+
const admissionBefore = readFileSync(admissionPath, 'utf8');
|
|
2564
|
+
const syntheticClients = githubClient.createDeliveryGitHubClients;
|
|
2565
|
+
const actual = await vi.importActual('./github/client.js');
|
|
2566
|
+
const mocked = vi
|
|
2567
|
+
.spyOn(githubClient, 'createDeliveryGitHubClients')
|
|
2568
|
+
.mockImplementation((input) => input.role === 'reviewer' ? actual.createDeliveryGitHubClients({ ...input, env: {} }) : syntheticClients(input));
|
|
2569
|
+
const execution = { repoRoot: root, runtimeEntryPath, identity: 'synthetic-author' };
|
|
2570
|
+
const tracking = {
|
|
2571
|
+
title: 'Synthetic change',
|
|
2572
|
+
body: '## Outcome\nDeliver a verified change.\n\n## Scope\n- `artifact.txt`\n\n## Acceptance Criteria\n- [ ] Change is verified\n- [ ] Review is recorded\n\n## Verification\nRun `node --version`.',
|
|
2573
|
+
points: 2,
|
|
2574
|
+
develop: true,
|
|
2575
|
+
};
|
|
2576
|
+
try {
|
|
2577
|
+
const run = async () => {
|
|
2578
|
+
if (entry === 'direct-develop' || entry === 'direct-start') {
|
|
2579
|
+
const context = await contextFor(execution, 'develop');
|
|
2580
|
+
return entry === 'direct-develop' ? developIssue(context, 17) : startTrackedIssue(context, tracking);
|
|
2581
|
+
}
|
|
2582
|
+
if (entry === 'develop')
|
|
2583
|
+
return executeTool('issue_develop', { issueNumber: 17 }, execution);
|
|
2584
|
+
if (entry === 'existing-start')
|
|
2585
|
+
return executeTool('issue_start', { issueNumber: 17 }, execution);
|
|
2586
|
+
if (entry === 'new-start')
|
|
2587
|
+
return executeTool('issue_start', tracking, execution);
|
|
2588
|
+
if (entry === 'scratch')
|
|
2589
|
+
return executeTool('issue_start', { scratch: true, request: 'Inspect parser' }, execution);
|
|
2590
|
+
return executeTool('issue_worktree_create', { name: 'scratch-access', branch: 'scratch/access' }, execution);
|
|
2591
|
+
};
|
|
2592
|
+
await assert.rejects(run(), /Missing GitHub App credentials for reviewer role/u);
|
|
2593
|
+
assert.equal(existsSync(join(root, '.worktrees')), false);
|
|
2594
|
+
assert.equal(existsSync(join(root, '.issue-cli', 'worktrees.json')), false);
|
|
2595
|
+
assert.equal(readFileSync(admissionPath, 'utf8'), admissionBefore);
|
|
2596
|
+
}
|
|
2597
|
+
finally {
|
|
2598
|
+
mocked.mockRestore();
|
|
2599
|
+
rmSync(root, { recursive: true, force: true });
|
|
2600
|
+
}
|
|
2601
|
+
});
|
|
2602
|
+
test.each(['author-source', 'author-readback', 'same-actor', 'reviewer-repository'])('development access preflight rejects a mismatched %s route before issue reads or writes', async (failure) => {
|
|
2603
|
+
const { root, runtimeEntryPath } = await fixture({ personalAuthor: true });
|
|
2604
|
+
const syntheticClients = githubClient.createDeliveryGitHubClients;
|
|
2605
|
+
const mocked = vi.spyOn(githubClient, 'createDeliveryGitHubClients').mockImplementation(async (input) => {
|
|
2606
|
+
const clients = await syntheticClients(input);
|
|
2607
|
+
if (input.role === 'author' && failure === 'author-source')
|
|
2608
|
+
return { ...clients, authSource: 'app' };
|
|
2609
|
+
if (input.role === 'author' && failure === 'author-readback')
|
|
2610
|
+
return { ...clients, authenticatedAuthor: async () => ({ actorLogin: '', credentialIdentity: '' }) };
|
|
2611
|
+
if (input.role === 'reviewer' && failure === 'same-actor')
|
|
2612
|
+
return { ...clients, appActorLogin: async () => 'host-user' };
|
|
2613
|
+
if (input.role === 'reviewer' && failure === 'reviewer-repository')
|
|
2614
|
+
Object.assign(clients.rest.repos, { get: async () => ({ data: { full_name: 'example/other' } }) });
|
|
2615
|
+
return clients;
|
|
2616
|
+
});
|
|
2617
|
+
try {
|
|
2618
|
+
const context = await contextFor({ repoRoot: root, runtimeEntryPath, identity: 'host-author' }, 'develop');
|
|
2619
|
+
await assert.rejects(developIssue(context, 17), {
|
|
2620
|
+
'author-source': /requires the configured author credential/u,
|
|
2621
|
+
'author-readback': /Author GitHub identity readback is incomplete/u,
|
|
2622
|
+
'same-actor': /same GitHub actor/u,
|
|
2623
|
+
'reviewer-repository': /repository readback disagrees/u,
|
|
2624
|
+
}[failure]);
|
|
2625
|
+
assert.equal(existsSync(join(root, '.worktrees')), false);
|
|
2626
|
+
assert.equal(existsSync(join(root, '.git', 'ai-delivery', 'issue-info')), false);
|
|
2627
|
+
}
|
|
2628
|
+
finally {
|
|
2629
|
+
mocked.mockRestore();
|
|
2630
|
+
rmSync(root, { recursive: true, force: true });
|
|
2631
|
+
}
|
|
2632
|
+
});
|
|
2633
|
+
test('development access preflight permits the configured host author and independent App with unreadable rules', async () => {
|
|
2634
|
+
const { root, runtimeEntryPath } = await fixture({ personalAuthor: true });
|
|
2635
|
+
const admissionPath = join(root, '.git', 'ai-delivery', 'runtime-admission.json');
|
|
2636
|
+
const admissionBefore = readFileSync(admissionPath, 'utf8');
|
|
2637
|
+
try {
|
|
2638
|
+
const row = (await executeTool('issue_worktree_create', { name: 'scratch-host', branch: 'scratch/host' }, { repoRoot: root, runtimeEntryPath, identity: 'host-author' }));
|
|
2639
|
+
assert.equal(row.identity, 'host-author');
|
|
2640
|
+
assert.equal(row.path, join(root, '.worktrees', 'scratch-host'));
|
|
2641
|
+
assert.equal(existsSync(row.path), true);
|
|
2642
|
+
assert.equal(readFileSync(admissionPath, 'utf8'), admissionBefore);
|
|
2643
|
+
}
|
|
2644
|
+
finally {
|
|
2645
|
+
rmSync(root, { recursive: true, force: true });
|
|
2646
|
+
}
|
|
2647
|
+
});
|
|
2648
|
+
test.each([
|
|
2649
|
+
'direct-develop',
|
|
2650
|
+
'direct-start',
|
|
2651
|
+
'develop',
|
|
2652
|
+
'existing-start',
|
|
2653
|
+
'new-start',
|
|
2654
|
+
'scratch',
|
|
2655
|
+
'standalone',
|
|
2656
|
+
'config-resolve',
|
|
2657
|
+
])('development access preflight preserves the explicit personal override for %s', async (entry) => {
|
|
2658
|
+
const { root, runtimeEntryPath } = await fixture();
|
|
2659
|
+
const admissionPath = join(root, '.git', 'ai-delivery', 'runtime-admission.json');
|
|
2660
|
+
const admissionBefore = readFileSync(admissionPath, 'utf8');
|
|
2661
|
+
const factory = githubClient.createDeliveryGitHubClients;
|
|
2662
|
+
const actual = await vi.importActual('./github/client.js');
|
|
2663
|
+
const downstream = new Error('Reached the downstream issue API with the selected author');
|
|
2664
|
+
const selected = vi.spyOn(githubClient, 'createDeliveryGitHubClients').mockImplementation(async (input) => {
|
|
2665
|
+
const clients = await factory(input);
|
|
2666
|
+
if (input.role !== 'author' || input.personalAuth === undefined)
|
|
2667
|
+
return clients;
|
|
2668
|
+
const personal = await actual.createDeliveryGitHubClients({
|
|
2669
|
+
...input,
|
|
2670
|
+
env: { GH_TOKEN: 'synthetic-development-token' },
|
|
2671
|
+
});
|
|
2672
|
+
Object.assign(clients.rest.issues, {
|
|
2673
|
+
get: async () => {
|
|
2674
|
+
throw downstream;
|
|
2675
|
+
},
|
|
2676
|
+
create: async () => {
|
|
2677
|
+
throw downstream;
|
|
2678
|
+
},
|
|
2679
|
+
});
|
|
2680
|
+
Object.assign(clients.rest, { request: async () => Promise.reject(new Error('Rules unavailable')) });
|
|
2681
|
+
return {
|
|
2682
|
+
...personal,
|
|
2683
|
+
graphql: clients.graphql,
|
|
2684
|
+
rest: clients.rest,
|
|
2685
|
+
authenticatedAuthor: async () => ({ actorLogin: 'host-user', credentialIdentity: 'user:37' }),
|
|
2686
|
+
};
|
|
2687
|
+
});
|
|
2688
|
+
const execution = { repoRoot: root, runtimeEntryPath, identity: 'personal', personalAuth: true };
|
|
2689
|
+
const tracking = {
|
|
2690
|
+
title: 'Synthetic change',
|
|
2691
|
+
body: '## Outcome\nDeliver a verified change.\n\n## Scope\n- `artifact.txt`\n\n## Acceptance Criteria\n- [ ] Change is verified\n- [ ] Review is recorded\n\n## Verification\nRun `node --version`.',
|
|
2692
|
+
points: 2,
|
|
2693
|
+
develop: true,
|
|
2694
|
+
};
|
|
2695
|
+
try {
|
|
2696
|
+
if (entry === 'config-resolve') {
|
|
2697
|
+
// The prescribed CLI read must inspect this development selection without admission.
|
|
2698
|
+
rmSync(admissionPath);
|
|
2699
|
+
const originalArgv = process.argv;
|
|
2700
|
+
const originalExitCode = process.exitCode;
|
|
2701
|
+
let stdout = '';
|
|
2702
|
+
let stderr = '';
|
|
2703
|
+
let completed = () => { };
|
|
2704
|
+
const output = new Promise((resolve) => {
|
|
2705
|
+
completed = resolve;
|
|
2706
|
+
});
|
|
2707
|
+
const out = vi.spyOn(process.stdout, 'write').mockImplementation((value) => {
|
|
2708
|
+
stdout += String(value);
|
|
2709
|
+
completed();
|
|
2710
|
+
return true;
|
|
2711
|
+
});
|
|
2712
|
+
const err = vi.spyOn(process.stderr, 'write').mockImplementation((value) => {
|
|
2713
|
+
stderr += String(value);
|
|
2714
|
+
completed();
|
|
2715
|
+
return true;
|
|
2716
|
+
});
|
|
2717
|
+
try {
|
|
2718
|
+
process.argv = [
|
|
2719
|
+
process.execPath,
|
|
2720
|
+
'ai-delivery',
|
|
2721
|
+
'--repo-root',
|
|
2722
|
+
root,
|
|
2723
|
+
'--identity',
|
|
2724
|
+
'personal',
|
|
2725
|
+
'--personal-auth',
|
|
2726
|
+
'config:resolve',
|
|
2727
|
+
];
|
|
2728
|
+
await import('./cli.js');
|
|
2729
|
+
await output;
|
|
2730
|
+
assert.equal(stderr, '');
|
|
2731
|
+
const resolved = JSON.parse(stdout);
|
|
2732
|
+
assert.equal(resolved.reviewRoute.author.identity, 'personal');
|
|
2733
|
+
assert.equal(resolved.reviewRoute.author.authSource, 'personal');
|
|
2734
|
+
assert.equal(resolved.reviewRoute.author.actorLogin, 'host-user');
|
|
2735
|
+
assert.equal(resolved.reviewRoute.approvalEligibility, 'unknown');
|
|
2736
|
+
assert.equal(resolved.reviewRoute.rules.visibility, 'unknown');
|
|
2737
|
+
assert.equal(existsSync(admissionPath), false);
|
|
2738
|
+
assert.equal(existsSync(join(root, '.worktrees')), false);
|
|
2739
|
+
}
|
|
2740
|
+
finally {
|
|
2741
|
+
out.mockRestore();
|
|
2742
|
+
err.mockRestore();
|
|
2743
|
+
process.argv = originalArgv;
|
|
2744
|
+
process.exitCode = originalExitCode;
|
|
2745
|
+
}
|
|
2746
|
+
}
|
|
2747
|
+
else if (entry === 'scratch' || entry === 'standalone') {
|
|
2748
|
+
const row = (entry === 'scratch'
|
|
2749
|
+
? await executeTool('issue_start', { scratch: true, request: 'Inspect parser' }, execution)
|
|
2750
|
+
: await executeTool('issue_worktree_create', { name: 'scratch-override', branch: 'scratch/override' }, execution));
|
|
2751
|
+
if (entry === 'standalone')
|
|
2752
|
+
assert.equal(row.identity, 'personal');
|
|
2753
|
+
assert.equal(existsSync(entry === 'scratch' ? row.worktreePath : row.path), true);
|
|
2754
|
+
}
|
|
2755
|
+
else {
|
|
2756
|
+
const run = async () => {
|
|
2757
|
+
if (entry === 'direct-develop' || entry === 'direct-start') {
|
|
2758
|
+
const context = await contextFor(execution, 'develop');
|
|
2759
|
+
return entry === 'direct-develop' ? developIssue(context, 17) : startTrackedIssue(context, tracking);
|
|
2760
|
+
}
|
|
2761
|
+
if (entry === 'develop')
|
|
2762
|
+
return executeTool('issue_develop', { issueNumber: 17 }, execution);
|
|
2763
|
+
if (entry === 'existing-start')
|
|
2764
|
+
return executeTool('issue_start', { issueNumber: 17 }, execution);
|
|
2765
|
+
return executeTool('issue_start', tracking, execution);
|
|
2766
|
+
};
|
|
2767
|
+
await assert.rejects(run(), (error) => error === downstream);
|
|
2768
|
+
assert.equal(existsSync(join(root, '.worktrees')), false);
|
|
2769
|
+
}
|
|
2770
|
+
if (entry !== 'config-resolve')
|
|
2771
|
+
assert.equal(readFileSync(admissionPath, 'utf8'), admissionBefore);
|
|
2772
|
+
}
|
|
2773
|
+
finally {
|
|
2774
|
+
selected.mockRestore();
|
|
2775
|
+
rmSync(root, { recursive: true, force: true });
|
|
2776
|
+
}
|
|
2777
|
+
});
|
|
1846
2778
|
test('MCP exposes one implementation surface for native lifecycle commands', () => {
|
|
1847
2779
|
assert.deepEqual(AI_DELIVERY_MCP_TOOLS.map((tool) => tool.name), [
|
|
2780
|
+
'runtime_stage',
|
|
2781
|
+
'runtime_admit',
|
|
1848
2782
|
'issue_create',
|
|
1849
2783
|
'issue_start',
|
|
1850
2784
|
'issue_update',
|
|
@@ -1903,7 +2837,11 @@ test('MCP request rejects unsupported legacy options before lifecycle dispatch',
|
|
|
1903
2837
|
}
|
|
1904
2838
|
});
|
|
1905
2839
|
async function syntheticLifecycle(routing) {
|
|
1906
|
-
const
|
|
2840
|
+
const created = await fixture({ ...routing, omitRuntimeAdmission: routing.producerOnboarding === true });
|
|
2841
|
+
const root = created.root;
|
|
2842
|
+
let runtimeEntryPath = created.runtimeEntryPath;
|
|
2843
|
+
const originalPath = process.env.PATH;
|
|
2844
|
+
let restoreTransport;
|
|
1907
2845
|
const remoteName = routing.remote;
|
|
1908
2846
|
assert.equal(defaultBaseRef(root, remoteName), `refs/remotes/${remoteName}/main`);
|
|
1909
2847
|
const remote = join(root, '.git', 'remote.git');
|
|
@@ -1943,7 +2881,7 @@ async function syntheticLifecycle(routing) {
|
|
|
1943
2881
|
let reviewDecision = 'REVIEW_REQUIRED';
|
|
1944
2882
|
const calls = [];
|
|
1945
2883
|
const createPayloads = [];
|
|
1946
|
-
const statuses = { Queued: '
|
|
2884
|
+
const statuses = { Queued: 'STATUS-0', Active: 'STATUS-1', Waiting: 'STATUS-2', Shipped: 'STATUS-3' };
|
|
1947
2885
|
const statusName = (option) => Object.entries(statuses).find(([, id]) => id === option)?.[0];
|
|
1948
2886
|
const pageInfo = { endCursor: null, hasNextPage: false };
|
|
1949
2887
|
const issueFieldValues = (number = issueNumber) => [
|
|
@@ -2264,7 +3202,67 @@ async function syntheticLifecycle(routing) {
|
|
|
2264
3202
|
}),
|
|
2265
3203
|
},
|
|
2266
3204
|
};
|
|
3205
|
+
let restoreDispatchClient;
|
|
2267
3206
|
try {
|
|
3207
|
+
if (routing.producerOnboarding) {
|
|
3208
|
+
assert.equal(existsSync(join(root, 'AGENTS.md')), false);
|
|
3209
|
+
const archiveBase = join(root, '.git', 'producer-fixture');
|
|
3210
|
+
const packageSource = join(archiveBase, 'package');
|
|
3211
|
+
mkdirSync(archiveBase);
|
|
3212
|
+
cpSync(dirname(dirname(created.runtimeEntryPath)), packageSource, { recursive: true });
|
|
3213
|
+
cpSync(join(root, '.git/synthetic-runtime/plugin'), join(packageSource, 'plugins/ai-delivery'), {
|
|
3214
|
+
recursive: true,
|
|
3215
|
+
});
|
|
3216
|
+
const archive = join(archiveBase, 'reviewed.tgz');
|
|
3217
|
+
execFileSync('tar', ['-czf', archive, '-C', archiveBase, 'package']);
|
|
3218
|
+
const shimDir = join(archiveBase, 'bin');
|
|
3219
|
+
mkdirSync(shimDir);
|
|
3220
|
+
const npmShim = join(shimDir, 'npm');
|
|
3221
|
+
// Only installer and GitHub transports are synthetic; the public producer and validator are real.
|
|
3222
|
+
writeFileSync(npmShim, `#!${process.execPath}\nconst fs=require('fs'),path=require('path');const args=process.argv.slice(2);if(args[0]!=='install'||!['--omit=dev','--ignore-scripts','--no-audit','--no-fund'].every(x=>args.includes(x)))process.exit(91);const target=path.join(args[args.indexOf('--prefix')+1],'node_modules/@aviaratech/ai-delivery');fs.mkdirSync(path.dirname(target),{recursive:true});fs.cpSync(${JSON.stringify(packageSource)},target,{recursive:true});\n`);
|
|
3223
|
+
chmodSync(npmShim, 0o700);
|
|
3224
|
+
process.env.PATH = `${shimDir}:${originalPath ?? ''}`;
|
|
3225
|
+
const server = createAiDeliveryMcpServer({ repoRoot: root, identity: 'synthetic-author' });
|
|
3226
|
+
const client = new Client({ name: 'unrelated-public-onboarding', version: '1.0.0' });
|
|
3227
|
+
const [ct, st] = InMemoryTransport.createLinkedPair();
|
|
3228
|
+
await server.connect(st);
|
|
3229
|
+
await client.connect(ct);
|
|
3230
|
+
const setup = {
|
|
3231
|
+
expectedSourceCommit: baseSha,
|
|
3232
|
+
expectedConfigDigest: configuration.configDigest,
|
|
3233
|
+
runtimeDirectory: join(root, '.git', 'public-runtime-stage'),
|
|
3234
|
+
};
|
|
3235
|
+
try {
|
|
3236
|
+
const stageResponse = await client.callTool({
|
|
3237
|
+
name: 'runtime_stage',
|
|
3238
|
+
arguments: {
|
|
3239
|
+
...setup,
|
|
3240
|
+
authority: 'runtime:stage',
|
|
3241
|
+
archivePath: archive,
|
|
3242
|
+
expectedArchiveSha256: sha256(archive),
|
|
3243
|
+
packageVersion: '0.1.0',
|
|
3244
|
+
},
|
|
3245
|
+
});
|
|
3246
|
+
assert.equal(stageResponse.isError, undefined);
|
|
3247
|
+
const stage = JSON.parse(stageResponse.content[0].text);
|
|
3248
|
+
const admitted = await client.callTool({
|
|
3249
|
+
name: 'runtime_admit',
|
|
3250
|
+
arguments: {
|
|
3251
|
+
...setup,
|
|
3252
|
+
authority: 'runtime:admit',
|
|
3253
|
+
stageId: stage.stageId,
|
|
3254
|
+
expectedPriorAdmissionSha256: null,
|
|
3255
|
+
},
|
|
3256
|
+
});
|
|
3257
|
+
assert.equal(admitted.isError, undefined);
|
|
3258
|
+
runtimeEntryPath = stage.admission.cliPath;
|
|
3259
|
+
}
|
|
3260
|
+
finally {
|
|
3261
|
+
await client.close();
|
|
3262
|
+
await server.close();
|
|
3263
|
+
}
|
|
3264
|
+
await assertDeliveryRuntimeAdmitted({ repoRoot: root, runtimeEntryPath });
|
|
3265
|
+
}
|
|
2268
3266
|
await assert.rejects(startTrackedIssue(context, { request: 'Unready task', develop: true }), /New issue is not ready/u);
|
|
2269
3267
|
assert.equal(calls.filter((call) => call === 'issue:create').length, 0);
|
|
2270
3268
|
const unsized = await createIssue(context, { title: 'Synthetic child', parentIssueNumber: 11 });
|
|
@@ -2336,30 +3334,66 @@ async function syntheticLifecycle(routing) {
|
|
|
2336
3334
|
headSha = git(row.path, 'rev-parse', 'HEAD');
|
|
2337
3335
|
const run = await verifyIssue({ issueNumber, repoRoot: row.path });
|
|
2338
3336
|
await assert.rejects(publishPr(context, { issueNumber, body: 'Closes #17' }), /Delivery Impact/);
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
|
|
2362
|
-
|
|
3337
|
+
if (routing.producerOnboarding) {
|
|
3338
|
+
const realGit = execFileSync('/usr/bin/which', ['git'], {
|
|
3339
|
+
encoding: 'utf8',
|
|
3340
|
+
env: { ...process.env, PATH: originalPath },
|
|
3341
|
+
}).trim();
|
|
3342
|
+
const shimDir = join(root, '.git/producer-fixture/bin');
|
|
3343
|
+
git(root, 'config', '--unset', `url.${remote}.insteadOf`);
|
|
3344
|
+
const gitShim = join(shimDir, 'git');
|
|
3345
|
+
writeFileSync(gitShim, `#!/bin/sh
|
|
3346
|
+
if [ "$1" = -c ] && [ "$2" = credential.helper= ]; then
|
|
3347
|
+
[ "$AI_DELIVERY_GIT_TOKEN" = synthetic-selected-author-token ] || exit 92
|
|
3348
|
+
shift 2
|
|
3349
|
+
operation="$1"; shift
|
|
3350
|
+
[ "$1" = https://github.com/example/widget.git ] || exit 93
|
|
3351
|
+
shift
|
|
3352
|
+
exec "${realGit}" -c credential.helper= "$operation" "${remote}" "$@"
|
|
3353
|
+
fi
|
|
3354
|
+
if [ "$1" = push ] && [ "$2" = -q ] && [ "$3" = "${remoteName}" ]; then
|
|
3355
|
+
shift 3
|
|
3356
|
+
exec "${realGit}" push -q "${remote}" "$@"
|
|
3357
|
+
fi
|
|
3358
|
+
exec "${realGit}" "$@"
|
|
3359
|
+
`);
|
|
3360
|
+
chmodSync(gitShim, 0o700);
|
|
3361
|
+
const transport = vi.spyOn(githubClient, 'withAuthorGitToken').mockImplementation(async (clients, callback) => {
|
|
3362
|
+
assert.equal(clients.role, 'author');
|
|
3363
|
+
return callback('synthetic-selected-author-token');
|
|
3364
|
+
});
|
|
3365
|
+
restoreTransport = () => transport.mockRestore();
|
|
3366
|
+
const published = await publishPr(context, { issueNumber });
|
|
3367
|
+
assert.equal(published.prNumber, prNumber);
|
|
3368
|
+
assert.equal(published.reviewRoute?.author.actorLogin, 'synthetic-author[bot]');
|
|
3369
|
+
assert.equal(published.reviewRoute?.approvalEligibility, 'unknown');
|
|
3370
|
+
}
|
|
3371
|
+
else {
|
|
3372
|
+
await assert.rejects(publishPr(context, { issueNumber }), /Git push requires the selected author credential token/);
|
|
3373
|
+
// The public push requires a real author App installation token. Bind a synthetic
|
|
3374
|
+
// publication at that external boundary so the remaining public phases run offline.
|
|
3375
|
+
git(row.path, 'push', '-q', remoteName, `${headSha}:refs/heads/issue/17`);
|
|
3376
|
+
const publishEvidence = await createIssuePhaseEvidence({ issueNumber, phase: 'publish', repoRoot: row.path });
|
|
3377
|
+
const publicationContent = {
|
|
3378
|
+
baseSha,
|
|
3379
|
+
evidenceId: publishEvidence.evidenceId,
|
|
3380
|
+
headSha,
|
|
3381
|
+
issueNumber,
|
|
3382
|
+
prNumber,
|
|
3383
|
+
schemaVersion: 'ai-delivery.publication@1',
|
|
3384
|
+
};
|
|
3385
|
+
const publicationPath = join(gitCommonDir(row.path), 'ai-delivery', 'publications', String(issueNumber), `${headSha}.json`);
|
|
3386
|
+
mkdirSync(dirname(publicationPath), { recursive: true });
|
|
3387
|
+
writeFileSync(publicationPath, JSON.stringify({ ...publicationContent, publicationId: digestValue(publicationContent) }), { mode: 0o600 });
|
|
3388
|
+
await updateIssueWorktreeDelivery({
|
|
3389
|
+
branch: row.branch,
|
|
3390
|
+
issueNumber,
|
|
3391
|
+
path: row.path,
|
|
3392
|
+
prNumber,
|
|
3393
|
+
projectRoot: root,
|
|
3394
|
+
status: 'pr-published',
|
|
3395
|
+
});
|
|
3396
|
+
}
|
|
2363
3397
|
assert.equal((await listPrs(context)).pullRequests[0]?.number, prNumber);
|
|
2364
3398
|
assert.equal((await prInfo(context, { prNumber })).authorLogin, 'synthetic-author[bot]');
|
|
2365
3399
|
assert.equal((await prChecks(context, prNumber)).combinedStatus, 'success');
|
|
@@ -2443,7 +3477,19 @@ async function syntheticLifecycle(routing) {
|
|
|
2443
3477
|
await assert.rejects(finishIssue(context, { issueNumber, prNumber, strategy: 'merge' }), /Delivery record store is invalid/u);
|
|
2444
3478
|
assert.equal(existsSync(row.path), false);
|
|
2445
3479
|
rmSync(blockedRecordPath, { recursive: true });
|
|
2446
|
-
const
|
|
3480
|
+
const dispatchClient = vi.spyOn(githubClient, 'createDeliveryGitHubClients').mockImplementation(async () => ({
|
|
3481
|
+
...context.clients,
|
|
3482
|
+
graphql: (async (query, variables) => query.includes('DeliveryDiscovery') ||
|
|
3483
|
+
query.includes('DeliveryRepository') ||
|
|
3484
|
+
query.includes('ProjectDeliveryConfiguration')
|
|
3485
|
+
? syntheticDiscoveryClients().graphql(query, variables)
|
|
3486
|
+
: graphql(query, variables)),
|
|
3487
|
+
}));
|
|
3488
|
+
restoreDispatchClient = () => dispatchClient.mockRestore();
|
|
3489
|
+
const execution = { repoRoot: root, runtimeEntryPath, identity: 'synthetic-author' };
|
|
3490
|
+
const terminalMerge = (await executeTool('issue_pr_merge', { issueNumber, prNumber, strategy: 'merge' }, execution));
|
|
3491
|
+
assert.equal(terminalMerge.mergeSha, git(remote, 'rev-parse', 'refs/heads/main'));
|
|
3492
|
+
const finished = (await executeTool('issue_finish', { issueNumber, prNumber, strategy: 'merge' }, execution));
|
|
2447
3493
|
assert.equal(finished.issueClosed, true);
|
|
2448
3494
|
assert.equal(finished.cleaned, true);
|
|
2449
3495
|
assert.equal(projectStatus, 'Shipped');
|
|
@@ -2452,7 +3498,9 @@ async function syntheticLifecycle(routing) {
|
|
|
2452
3498
|
assert.equal(getDeliveryRecords(root).length, 1);
|
|
2453
3499
|
assert.equal(getDeliveryRecords(root)[0]?.points, 4);
|
|
2454
3500
|
assert.deepEqual(calls, ['issue:create', 'review:create', 'git:createCommit']);
|
|
2455
|
-
assert.deepEqual(await
|
|
3501
|
+
assert.deepEqual(await executeTool('issue_finish', { issueNumber, prNumber, strategy: 'merge' }, execution), finished);
|
|
3502
|
+
await assert.rejects(executeTool('issue_finish', { issueNumber, prNumber: 24 }, execution), /exact terminal merge receipts/u);
|
|
3503
|
+
dispatchClient.mockRestore();
|
|
2456
3504
|
assert.equal(getDeliveryRecords(root).length, 1);
|
|
2457
3505
|
await assert.rejects(finishIssue(context, { issueNumber, prNumber: 24 }), /exact terminal merge receipts/u);
|
|
2458
3506
|
const newlyStarted = await startTrackedIssue(context, { request: 'New synthetic task' });
|
|
@@ -2491,6 +3539,9 @@ async function syntheticLifecycle(routing) {
|
|
|
2491
3539
|
assert.equal(calls.filter((call) => call === 'issue:create').length, beforeTracking + 1);
|
|
2492
3540
|
}
|
|
2493
3541
|
finally {
|
|
3542
|
+
restoreTransport?.();
|
|
3543
|
+
process.env.PATH = originalPath;
|
|
3544
|
+
restoreDispatchClient?.();
|
|
2494
3545
|
rmSync(root, { recursive: true, force: true });
|
|
2495
3546
|
}
|
|
2496
3547
|
}
|
|
@@ -2499,4 +3550,46 @@ test.each([
|
|
|
2499
3550
|
{ remote: 'upstream', divergentOrigin: false },
|
|
2500
3551
|
{ remote: 'upstream', divergentOrigin: true },
|
|
2501
3552
|
])('synthetic lifecycle finishes with remote $remote and divergent origin $divergentOrigin', syntheticLifecycle, 15_000);
|
|
3553
|
+
test('an unrelated consumer uses public MCP setup through verified configured-author publication, counted review readback, merge and cleanup', async () => {
|
|
3554
|
+
await syntheticLifecycle({ remote: 'origin', divergentOrigin: false, producerOnboarding: true });
|
|
3555
|
+
}, 15_000);
|
|
3556
|
+
test.each(['escape', 'broken', 'cycle'])('output observation rejects %s aliases without running policy commands', async (kind) => {
|
|
3557
|
+
const { root, counter } = await fixture();
|
|
3558
|
+
const base = realpathSync(mkdtempSync(join(tmpdir(), 'ai-delivery-output-alias-')));
|
|
3559
|
+
try {
|
|
3560
|
+
const output = join(base, 'output');
|
|
3561
|
+
mkdirSync(output);
|
|
3562
|
+
if (kind === 'escape') {
|
|
3563
|
+
writeFileSync(join(base, 'outside'), 'outside');
|
|
3564
|
+
symlinkSync('../outside', join(output, 'alias'));
|
|
3565
|
+
}
|
|
3566
|
+
if (kind === 'broken')
|
|
3567
|
+
symlinkSync('./absent', join(output, 'alias'));
|
|
3568
|
+
if (kind === 'cycle') {
|
|
3569
|
+
symlinkSync('./other', join(output, 'alias'));
|
|
3570
|
+
symlinkSync('./alias', join(output, 'other'));
|
|
3571
|
+
}
|
|
3572
|
+
const row = await prepareIssueWorktree({
|
|
3573
|
+
baseRef: 'main',
|
|
3574
|
+
identity: 'synthetic-author',
|
|
3575
|
+
issueNumber: 17,
|
|
3576
|
+
repoRoot: root,
|
|
3577
|
+
});
|
|
3578
|
+
await assert.rejects(verifyIssue({
|
|
3579
|
+
issueNumber: 17,
|
|
3580
|
+
repoRoot: row.path,
|
|
3581
|
+
resourceBounds: {
|
|
3582
|
+
maxAggregateRssBytes: 1_000_000_000,
|
|
3583
|
+
minFreeDiskBytes: 1,
|
|
3584
|
+
maxNewOutputBytes: 1024,
|
|
3585
|
+
outputRoots: [output],
|
|
3586
|
+
},
|
|
3587
|
+
}), /escaping|broken|cyclic/u);
|
|
3588
|
+
assert.equal(existsSync(counter), false);
|
|
3589
|
+
}
|
|
3590
|
+
finally {
|
|
3591
|
+
rmSync(root, { recursive: true, force: true });
|
|
3592
|
+
rmSync(base, { recursive: true, force: true });
|
|
3593
|
+
}
|
|
3594
|
+
});
|
|
2502
3595
|
//# sourceMappingURL=lifecycle.test.js.map
|