@bongos/core 1.19.975 → 1.19.977

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.
@@ -8,6 +8,7 @@
8
8
  import { strict as assert } from 'node:assert';
9
9
  import { test } from 'node:test';
10
10
  import { createRequire } from 'node:module';
11
+ import fs from 'node:fs';
11
12
  import path from 'node:path';
12
13
  import { fileURLToPath } from 'node:url';
13
14
 
@@ -139,10 +140,9 @@ test('getUserRepo: 404 → typed repo_not_found (GitHub\'s own no-leak shape, fo
139
140
  );
140
141
  });
141
142
 
142
- test('makeRepoPublic: PATCHes exactly {private:false} — the private direction is structurally impossible', async () => {
143
- // Like createUserRepo's private:false, the direction is deliberately not a
144
- // parameter: the deploy pull runs as the shared deploy key that cannot read a
145
- // private repo (N4), so nothing in the codebase may flip a repo private.
143
+ test('makeRepoPublic: PATCHes exactly {private:false} — the name is the direction', async () => {
144
+ // Like createUserRepo's private:false, the direction is not a parameter: each
145
+ // direction is its own named function, so no caller can send one it did not mean.
146
146
  let sentMethod = '';
147
147
  let sentBody = null;
148
148
  const fetchImpl = async (url, init) => {
@@ -164,6 +164,57 @@ test('makeRepoPublic: 403 → typed not_repo_admin', async () => {
164
164
  );
165
165
  });
166
166
 
167
+ // ---- the private direction (task 1004193, split (b) of task 1003078) ----
168
+
169
+ test('makeRepoPrivate: PATCHes exactly {private:true} to the same endpoint', async () => {
170
+ // Arrange
171
+ let sentUrl = '';
172
+ let sentMethod = '';
173
+ let sentBody = null;
174
+ const fetchImpl = async (url, init) => {
175
+ sentUrl = url; sentMethod = init.method; sentBody = JSON.parse(init.body);
176
+ return res(200, { full_name: 'me/mercury', html_url: 'https://github.com/me/mercury', private: true, permissions: { admin: true } });
177
+ };
178
+ // Act
179
+ const out = await gh.makeRepoPrivate('tok', { owner: 'me', name: 'mercury', fetchImpl });
180
+ // Assert
181
+ assert.equal(sentMethod, 'PATCH');
182
+ assert.match(sentUrl, /\/repos\/me\/mercury$/);
183
+ assert.deepEqual(sentBody, { private: true });
184
+ assert.deepEqual(out, { full_name: 'me/mercury', html_url: 'https://github.com/me/mercury', private: true, admin: true });
185
+ });
186
+
187
+ test('makeRepoPrivate: 403 → typed not_repo_admin, the same mapping as the public direction', async () => {
188
+ const fetchImpl = async () => res(403, { message: 'Must have admin rights' });
189
+ await assert.rejects(
190
+ () => gh.makeRepoPrivate('tok', { owner: 'other', name: 'repo', fetchImpl }),
191
+ (e) => e.code === 'not_repo_admin' && e.status === 403,
192
+ );
193
+ });
194
+
195
+ test('the shared visibility PATCH is not exported — only the two named directions are', () => {
196
+ assert.equal(typeof gh.makeRepoPublic, 'function');
197
+ assert.equal(typeof gh.makeRepoPrivate, 'function');
198
+ const visibilityExports = Object.keys(gh).filter((k) => /private|public|visibility/i.test(k)).sort();
199
+ assert.deepEqual(visibilityExports, ['makeRepoPrivate', 'makeRepoPublic'],
200
+ 'a direction parameter on an export would let a caller flip private without the deploy-key wiring');
201
+ });
202
+
203
+ test('no web route may call makeRepoPrivate — only the runner flips a repo private, after wiring its key', () => {
204
+ // ADR 0176 / walk finding N4: flipping before the project's own deploy key is wired
205
+ // breaks its next deploy pull, and only the control-plane runner can wire that key.
206
+ const routeDirs = ['src/bongos/routes', 'modules/provisioning/routes'];
207
+ const offenders = [];
208
+ for (const dir of routeDirs) {
209
+ const abs = path.join(ROOT, dir);
210
+ if (!fs.existsSync(abs)) continue;
211
+ for (const f of fs.readdirSync(abs).filter((n) => n.endsWith('.js'))) {
212
+ if (/makeRepoPrivate/.test(fs.readFileSync(path.join(abs, f), 'utf8'))) offenders.push(`${dir}/${f}`);
213
+ }
214
+ }
215
+ assert.deepEqual(offenders, []);
216
+ });
217
+
167
218
  // ---- deploy-key helpers (task 1002735 / ADR 0176) ----
168
219
 
169
220
  test('listDeployKeys: GETs /repos/{owner}/{name}/keys, maps id/title/key/read_only', async () => {
@@ -0,0 +1,108 @@
1
+ // tests/missing_dep_classify.mjs — the PURE missing-dependency detector core
2
+ // (task 1002827), moved out of scripts/gds/audit-deps.js so a live route can
3
+ // reuse it without pulling that script's CLI-only machinery into the server
4
+ // process.
5
+ //
6
+ // tests/audit_deps.mjs already pins findMissingDeps/classify's BEHAVIOR
7
+ // (unchanged by the move — it still imports them from audit-deps.js, which
8
+ // now re-exports this module's names). This file instead pins the three
9
+ // things that changed or were added: the module boundary (a module may reach
10
+ // touchesOverlap only through the src/module-api.js doorway, never a deep
11
+ // core require), the pure surface being intact post-move, and `candidateEdge`
12
+ // — the ONE per-pair predicate findMissingDeps and dependency-advisory.js's
13
+ // live adviseDependencies both call now, so neither re-types it.
14
+ //
15
+ // Run: node tests/missing_dep_classify.mjs
16
+
17
+ import { strict as assert } from 'node:assert';
18
+ import { createRequire } from 'node:module';
19
+ import { readFileSync } from 'node:fs';
20
+ import { makeRunner } from './helpers.mjs';
21
+
22
+ const require = createRequire(import.meta.url);
23
+ const { OPEN, isUnshippedBlocker, HOTSPOTS, classify, candidateEdge, findMissingDeps } =
24
+ require('../modules/lifecycle/missing-dep-classify.js');
25
+
26
+ const { test, summary } = makeRunner();
27
+
28
+ await test('OPEN names exactly the four auditable statuses', () => {
29
+ assert.deepEqual([...OPEN].sort(), ['active', 'backlog', 'blocked', 'ready']);
30
+ });
31
+
32
+ await test('isUnshippedBlocker: only shipped/abandoned are terminal', () => {
33
+ assert.equal(isUnshippedBlocker('shipped'), false);
34
+ assert.equal(isUnshippedBlocker('abandoned'), false);
35
+ assert.equal(isUnshippedBlocker('ready'), true);
36
+ assert.equal(isUnshippedBlocker('backlog'), true);
37
+ });
38
+
39
+ await test('classify: an exact non-hotspot file match is high confidence', () => {
40
+ const c = classify(['modules/foo/bar.js'], ['modules/foo/bar.js']);
41
+ assert.equal(c.confidence, 'high');
42
+ });
43
+
44
+ await test('classify: a hotspot-only overlap is low confidence', () => {
45
+ const c = classify(['CLAUDE.md'], ['CLAUDE.md']);
46
+ assert.equal(c.confidence, 'low');
47
+ });
48
+
49
+ await test('classify: no overlap at all returns null', () => {
50
+ assert.equal(classify(['a.js'], ['b.js']), null);
51
+ });
52
+
53
+ await test('findMissingDeps re-exported unchanged: a live example still flags', () => {
54
+ const tasks = [
55
+ { id: 1, status: 'ready', touches: ['modules/x/y.js'], dependencies: [] },
56
+ { id: 2, status: 'backlog', touches: ['modules/x/y.js'], dependencies: [] },
57
+ ];
58
+ assert.equal(findMissingDeps(tasks).length, 2);
59
+ });
60
+
61
+ // ---- candidateEdge: the ONE per-pair predicate, shared by findMissingDeps
62
+ // and dependency-advisory.js's adviseDependencies (grade round 1 finding) ----
63
+
64
+ await test('candidateEdge: an overlapping open non-dep is a real edge', () => {
65
+ const e = candidateEdge(
66
+ { id: 1, touches: ['modules/x/y.js'] },
67
+ { id: 2, status: 'ready', touches: ['modules/x/y.js'] },
68
+ );
69
+ assert.equal(e.task_id, 1);
70
+ assert.equal(e.dep_id, 2);
71
+ assert.equal(e.confidence, 'high');
72
+ });
73
+
74
+ await test('candidateEdge: null on self, terminal status, no touches, or an already-declared dep', () => {
75
+ const t = { id: 1, touches: ['a.js'], dependencies: ['2'] };
76
+ assert.equal(candidateEdge(t, { id: 1, status: 'ready', touches: ['a.js'] }), null, 'self');
77
+ assert.equal(candidateEdge(t, { id: 3, status: 'shipped', touches: ['a.js'] }), null, 'terminal');
78
+ assert.equal(candidateEdge(t, { id: 3, status: 'ready', touches: [] }), null, 'no touches');
79
+ assert.equal(candidateEdge(t, { id: 2, status: 'ready', touches: ['a.js'] }), null, 'already declared');
80
+ });
81
+
82
+ await test('candidateEdge: an explicit `declared` set is used instead of re-deriving from t.dependencies', () => {
83
+ const t = { id: 1, touches: ['a.js'], dependencies: [] };
84
+ const e = candidateEdge(t, { id: 2, status: 'ready', touches: ['a.js'] }, { declared: new Set(['2']) });
85
+ assert.equal(e, null, 'the passed-in declared set wins over the empty t.dependencies');
86
+ });
87
+
88
+ await test('candidateEdge runs classify exactly once — no separate touchesOverlap pre-check to duplicate', () => {
89
+ const src = readFileSync(new URL('../modules/lifecycle/missing-dep-classify.js', import.meta.url), 'utf8');
90
+ const body = src.slice(src.indexOf('function candidateEdge'), src.indexOf('function findMissingDeps'));
91
+ assert.equal((body.match(/classify\(/g) || []).length, 1, 'exactly one classify() call in candidateEdge');
92
+ assert.equal(/touchesOverlap\(/.test(body), false, 'no redundant overlap pre-check — classify already answers that');
93
+ });
94
+
95
+ await test('the module reaches touchesOverlap through the module-api doorway, never a deep core require', () => {
96
+ const src = readFileSync(new URL('../modules/lifecycle/missing-dep-classify.js', import.meta.url), 'utf8');
97
+ assert.match(src, /require\('\.\.\/\.\.\/src\/module-api'\)/, 'reaches core via the doorway');
98
+ assert.equal(/require\(['"]\.\.\/\.\.\/src\/bongos\//.test(src), false, 'never a direct src/bongos/* require (ADR 0083)');
99
+ });
100
+
101
+ await test('scripts/gds/audit-deps.js delegates to this module rather than redefining the pure core', () => {
102
+ const src = readFileSync(new URL('../scripts/gds/audit-deps.js', import.meta.url), 'utf8');
103
+ assert.match(src, /require\('\.\.\/\.\.\/modules\/lifecycle\/missing-dep-classify\.js'\)/);
104
+ assert.equal(/^function classify/m.test(src), false, 'classify is no longer redefined here');
105
+ assert.equal(/^function findMissingDeps/m.test(src), false, 'findMissingDeps is no longer redefined here');
106
+ });
107
+
108
+ summary();
@@ -0,0 +1,260 @@
1
+ // tests/provision_repo_private.mjs
2
+ //
3
+ // Split (b) of task 1003078 (task 1004193, ADR 0176): the `repo-private` provisioning
4
+ // intent — its queue seam (modules/provisioning/repo-private.js), its runner leg
5
+ // (scripts/gds/provision-repo-private.js) and the run-intents dispatch that reaches it.
6
+ // Hermetic — exec, db and GitHub are recording fakes; no shell, no DB, no network.
7
+ //
8
+ // The load-bearing assertions:
9
+ // • ORDER: the project's own deploy key is registered and the checkout re-pointed at it
10
+ // BEFORE the repository is flipped private. Flip first and the next deploy pull fails
11
+ // with the shared key (walk finding N4) — this is the whole point of the task
12
+ // • the wiring is asked for as a PRE-flip wiring (intendedPrivate), because GitHub still
13
+ // answers "public" at that moment and the un-told wiring would do nothing
14
+ // • any failure BEFORE the flip leaves the repository untouched — no PATCH is sent
15
+ // • every expected failure is ok:false (retried), never a throw, so a running project is
16
+ // never relabelled 'error' by a flip that could not happen
17
+ // • non-standalone and not-yet-live projects are refused without touching GitHub
18
+ // • dry-run calls GitHub not at all
19
+ // • the queue seam: idempotent on its own kind, conflicts with any other open intent
20
+ //
21
+ // Run: node tests/provision_repo_private.mjs
22
+
23
+ import { strict as assert } from 'node:assert';
24
+ import { createRequire } from 'node:module';
25
+
26
+ const require = createRequire(import.meta.url);
27
+ const P = require('../scripts/gds/provision.js');
28
+ const { repoPrivateInstance } = require('../scripts/gds/provision-repo-private.js');
29
+ const provisioning = require('../modules/provisioning/provisioning.js');
30
+ const { enqueueRepoPrivate } = require('../modules/provisioning/repo-private.js');
31
+
32
+ let passed = 0, failed = 0;
33
+ async function ta(name, fn) {
34
+ try { await fn(); passed++; console.log(` PASS ${name}`); }
35
+ catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
36
+ }
37
+
38
+ const liveStandalone = { id: 18, slug: 'demo', hosting_shape: 'standalone', status: 'active', owner_builder_id: 7, target_ref: 'example-owner/demo' };
39
+
40
+ // One ordered timeline across the shell AND GitHub, so the wire-before-flip order is
41
+ // observable as positions in a single list rather than inferred from two.
42
+ function recorder({ apply = true, token = 'gho_TOK', flipFails = null, getFails = null } = {}) {
43
+ const timeline = [], events = [], logs = [];
44
+ const deps = {
45
+ apply, log: (l) => logs.push(String(l)),
46
+ db: { query: async () => ({ rows: [] }) },
47
+ provisioning: { recordEvent: async (_db, ev) => { events.push(ev); } },
48
+ exec: (cmd) => { timeline.push(['exec', cmd]); return { ok: true, stdout: '' }; },
49
+ getGithubToken: async () => (token ? { token, scope: 'read:user,repo' } : null),
50
+ fsImpl: { existsSync: () => false, readFileSync: () => 'ssh-ed25519 AAAAlocal cloudbongos-deploy-demo\n' },
51
+ githubRepos: {
52
+ getUserRepo: async (_t, a) => {
53
+ timeline.push(['gh', 'get']);
54
+ if (getFails) throw getFails;
55
+ return { full_name: `${a.owner}/${a.name}`, private: false, admin: true };
56
+ },
57
+ listDeployKeys: async () => { timeline.push(['gh', 'list']); return []; },
58
+ addDeployKey: async (_t, a) => { timeline.push(['gh', 'add', a.readOnly]); return { id: 99, title: a.title, read_only: true }; },
59
+ deleteDeployKey: async () => { timeline.push(['gh', 'del']); return true; },
60
+ makeRepoPrivate: async (_t, a) => {
61
+ timeline.push(['gh', 'flip-private', `${a.owner}/${a.name}`]);
62
+ if (flipFails) throw flipFails;
63
+ return { full_name: `${a.owner}/${a.name}`, private: true, admin: true };
64
+ },
65
+ },
66
+ };
67
+ const at = (pred) => timeline.findIndex(pred);
68
+ return { deps, timeline, events, logs, at };
69
+ }
70
+ const isFlip = (e) => e[0] === 'gh' && e[1] === 'flip-private';
71
+
72
+ console.log('\nrunner leg — wire FIRST, flip SECOND:');
73
+
74
+ await ta('the deploy key is registered and the checkout re-pointed BEFORE the repository is flipped private', async () => {
75
+ // Arrange
76
+ const r = recorder();
77
+ // Act
78
+ const out = await repoPrivateInstance(liveStandalone, r.deps);
79
+ // Assert
80
+ assert.equal(out.ok, true, JSON.stringify(out));
81
+ const flip = r.at(isFlip);
82
+ const add = r.at((e) => e[0] === 'gh' && e[1] === 'add');
83
+ const sshCmd = r.at((e) => e[0] === 'exec' && /config core\.sshCommand/.test(e[1]));
84
+ const origin = r.at((e) => e[0] === 'exec' && /remote set-url origin git@github\.com:example-owner\/demo\.git/.test(e[1]));
85
+ assert.ok(flip >= 0, 'the repository is flipped');
86
+ assert.ok(add >= 0 && add < flip, 'the key is registered before the flip');
87
+ assert.ok(origin >= 0 && origin < flip, 'origin points at the plain GitHub URL before the flip');
88
+ assert.ok(sshCmd >= 0 && sshCmd < flip, 'core.sshCommand uses the project key before the flip');
89
+ assert.equal(r.timeline.filter(isFlip).length, 1, 'exactly one flip');
90
+ assert.equal(r.timeline[flip][2], 'example-owner/demo', 'the flip targets the project repository');
91
+ });
92
+
93
+ await ta('the wiring is a PRE-flip wiring — it runs although GitHub still reports the repository public', async () => {
94
+ const r = recorder();
95
+ await repoPrivateInstance(liveStandalone, r.deps);
96
+ // Without intendedPrivate, ensurePrivateRepoAccess reads private:false and registers nothing.
97
+ const add = r.timeline.find((e) => e[0] === 'gh' && e[1] === 'add');
98
+ assert.ok(add, 'a key was registered for a repo GitHub still calls public');
99
+ assert.equal(add[2], true, 'and it is read-only');
100
+ });
101
+
102
+ await ta('the flip records an event on the project, after it happens', async () => {
103
+ const r = recorder();
104
+ await repoPrivateInstance(liveStandalone, r.deps);
105
+ assert.equal(r.events.length, 1);
106
+ assert.equal(r.events[0].event, 'repo-private');
107
+ assert.equal(r.events[0].instanceId, 18);
108
+ assert.equal(r.events[0].ownerBuilderId, 7);
109
+ assert.equal(r.events[0].actor, 'runner');
110
+ assert.match(r.events[0].detail, /example-owner\/demo made private/);
111
+ });
112
+
113
+ console.log('\nrunner leg — a failure before the flip leaves the repository alone:');
114
+
115
+ await ta('no stored credential: ok:false, no key and NO flip — the repository stays public', async () => {
116
+ const r = recorder({ token: null });
117
+ const out = await repoPrivateInstance(liveStandalone, r.deps);
118
+ assert.equal(out.ok, false);
119
+ assert.match(out.error, /cannot be made private[\s\S]*credential/i);
120
+ assert.equal(r.at(isFlip), -1, 'nothing was flipped');
121
+ });
122
+
123
+ await ta('GitHub unreachable during the wiring: ok:false (a retry), never a throw, and NO flip', async () => {
124
+ const r = recorder({ getFails: new Error('ECONNRESET') });
125
+ const out = await repoPrivateInstance(liveStandalone, r.deps);
126
+ assert.equal(out.ok, false);
127
+ assert.match(out.error, /could not be reached/);
128
+ assert.equal(r.at(isFlip), -1);
129
+ });
130
+
131
+ await ta('the flip itself refused (403): ok:false, says the repository is STILL public and a retry is safe', async () => {
132
+ const e = Object.assign(new Error('GitHub refused the change for this account'), { code: 'not_repo_admin', status: 403 });
133
+ const r = recorder({ flipFails: e });
134
+ const out = await repoPrivateInstance(liveStandalone, r.deps);
135
+ assert.equal(out.ok, false);
136
+ assert.match(out.error, /still public/);
137
+ assert.match(out.error, /cannot change this repository/);
138
+ assert.match(out.error, /trying again is safe/);
139
+ assert.equal(r.events.length, 0, 'no "made private" event for a flip that did not happen');
140
+ });
141
+
142
+ console.log('\nrunner leg — refusals that never reach GitHub:');
143
+
144
+ for (const [label, inst, why] of [
145
+ ['a co-tenant project', { ...liveStandalone, hosting_shape: 'co-tenant' }, /shares its code checkout/],
146
+ ['a project that is not live yet', { ...liveStandalone, status: 'provisioning' }, /is provisioning, not running/],
147
+ ['a project with no repository on record', { ...liveStandalone, target_ref: null }, /no GitHub repository on record/],
148
+ ]) {
149
+ await ta(`${label}: ok:false with a plain reason, and no shell or GitHub call at all`, async () => {
150
+ const r = recorder();
151
+ const out = await repoPrivateInstance(inst, r.deps);
152
+ assert.equal(out.ok, false);
153
+ assert.match(out.error, why);
154
+ assert.deepEqual(r.timeline, []);
155
+ });
156
+ }
157
+
158
+ await ta('dry-run: plans the wire and the flip, and calls neither GitHub nor the shell', async () => {
159
+ const r = recorder({ apply: false });
160
+ const out = await repoPrivateInstance(liveStandalone, r.deps);
161
+ assert.equal(out.ok, true);
162
+ assert.equal(out.planned, true);
163
+ assert.deepEqual(r.timeline, []);
164
+ assert.ok(r.logs.some((l) => /ABOUT TO BE MADE PRIVATE/.test(l)), 'the plan names the pre-flip wiring');
165
+ assert.ok(r.logs.some((l) => /would then make example-owner\/demo private/.test(l)), 'and the flip after it');
166
+ });
167
+
168
+ console.log('\nrun-intents dispatch:');
169
+
170
+ function drainDeps({ flipFails = null } = {}) {
171
+ let claims = 0; const resolved = [], statusWrites = [], updates = [];
172
+ const r = recorder({ flipFails });
173
+ const deps = {
174
+ ...r.deps, sleep: async () => {},
175
+ db: { query: async (sql, params) => { if (/UPDATE provisioning_intents/.test(sql)) updates.push({ sql, params }); return { rows: [] }; } },
176
+ provisioning: {
177
+ claimNextIntent: async () => (claims++ === 0 ? { id: 31, instance_id: 18, action: 'repo-private', attempts: 1 } : null),
178
+ getInstanceById: async () => ({ ...liveStandalone }),
179
+ resolveIntent: async (_db, id, state, err) => { resolved.push({ id, state, err }); },
180
+ setInstanceStatus: async (_db, _id, s, patch) => { statusWrites.push({ s, patch }); },
181
+ recordEvent: async () => {},
182
+ claimNextOAuthManifest: async () => null, resolveOAuthManifest: async () => {},
183
+ },
184
+ writeFile: () => ({ ok: true }), doApi: null,
185
+ };
186
+ return { deps, r, resolved, statusWrites, updates };
187
+ }
188
+
189
+ await ta("cmdRunIntents (--apply) routes 'repo-private' to the leg and resolves it done", async () => {
190
+ const d = drainDeps();
191
+ const out = await P.cmdRunIntents(d.deps);
192
+ assert.equal(out.errors, 0, JSON.stringify(out));
193
+ assert.deepEqual(d.resolved, [{ id: 31, state: 'done', err: null }]);
194
+ assert.equal(d.r.timeline.filter(isFlip).length, 1, 'it really reached the flip');
195
+ });
196
+
197
+ await ta('cmdRunIntents: a flip that fails re-pends with the reason, and never marks the running project broken', async () => {
198
+ const d = drainDeps({ flipFails: Object.assign(new Error('boom'), { code: 'github_error' }) });
199
+ const out = await P.cmdRunIntents(d.deps);
200
+ assert.equal(out.errors, 1);
201
+ assert.equal(d.resolved.length, 0, 'not resolved — it re-pends for a later tick');
202
+ assert.match(d.updates[0].sql, /state='pending'/);
203
+ assert.match(d.updates[0].params[1], /still public/, 'the owner-facing reason rides last_error');
204
+ assert.equal(d.statusWrites.length, 0, 'a running project is not relabelled error');
205
+ });
206
+
207
+ console.log('\nqueue seam — modules/provisioning/repo-private.js:');
208
+
209
+ async function withEnqueue(fake, fn) {
210
+ const orig = provisioning.enqueueIntent;
211
+ provisioning.enqueueIntent = fake;
212
+ try { await fn(); } finally { provisioning.enqueueIntent = orig; }
213
+ }
214
+
215
+ await ta('a fresh request is queued as repo-private, carrying the actor', async () => {
216
+ const calls = [];
217
+ await withEnqueue(async (_db, id, action, by) => { calls.push({ id, action, by }); return { intent: { id: 1, action }, created: true }; }, async () => {
218
+ const out = await enqueueRepoPrivate({}, 18, 'api:self');
219
+ assert.equal(out.created, true);
220
+ assert.equal(out.conflict, undefined);
221
+ assert.deepEqual(calls, [{ id: 18, action: 'repo-private', by: 'api:self' }]);
222
+ });
223
+ });
224
+
225
+ await ta('an open repo-private makes a repeat idempotent — not a conflict', async () => {
226
+ await withEnqueue(async () => ({ intent: { id: 1, action: 'repo-private', state: 'running' }, created: false }), async () => {
227
+ const out = await enqueueRepoPrivate({}, 18, 'api:self');
228
+ assert.equal(out.created, false);
229
+ assert.equal(out.conflict, undefined);
230
+ });
231
+ });
232
+
233
+ await ta('any OTHER open intent refuses it with open_intent — it never races a teardown or standup', async () => {
234
+ for (const action of ['teardown', 'provision', 'settings-apply', 'disconnect']) {
235
+ await withEnqueue(async () => ({ intent: { id: 2, action, state: 'pending' }, created: false }), async () => {
236
+ const out = await enqueueRepoPrivate({}, 18, 'api:self');
237
+ assert.equal(out.conflict, 'open_intent', action);
238
+ });
239
+ }
240
+ });
241
+
242
+ await ta('a teardown supersedes a PENDING repo-private — a queued flip must never hold the owner\'s delete hostage', async () => {
243
+ // Arrange — every intent shares one open slot per project, so an un-superseded pending
244
+ // flip would refuse the teardown until the runner worked through it (the 1002691 class).
245
+ const calls = [];
246
+ const db = { query: async (sql, params) => {
247
+ calls.push(sql);
248
+ if (/UPDATE provisioning_intents/.test(sql)) return { rows: [{ id: 5 }] };
249
+ return { rows: [{ id: 6, action: 'teardown', state: 'pending', instance_id: params[0] }] };
250
+ } };
251
+ // Act
252
+ const r = await provisioning.enqueueTeardown(db, 18, 'api:self');
253
+ // Assert
254
+ assert.equal(r.superseded, true);
255
+ assert.match(calls[0], /'repo-private'/, 'repo-private is on the supersedable list');
256
+ assert.match(calls[0], /state = 'pending'/, 'pending only — a RUNNING flip is mid-wiring and never yanked');
257
+ });
258
+
259
+ console.log(`\nprovision_repo_private: ${passed} passed, ${failed} failed`);
260
+ process.exit(failed ? 1 : 0);
@@ -131,7 +131,7 @@ await ta('enqueueTeardown supersedes a PENDING settings push (a push into a proj
131
131
  const r = await provisioning.enqueueTeardown(db, 18, 'api:self');
132
132
  assert.equal(r.created, true);
133
133
  assert.equal(r.superseded, true, 'the queued push was cancelled so the teardown could take the slot');
134
- assert.match(calls[0], /action IN \('provision', 'settings-apply', 'restart'\) AND state = 'pending'/, 'pending-only, and only the supersedable actions');
134
+ assert.match(calls[0], /action IN \('provision', 'settings-apply', 'restart', 'repo-private'\) AND state = 'pending'/, 'pending-only, and only the supersedable actions');
135
135
  });
136
136
 
137
137
  // ── the route, over a real mounted router (stubbed data access) ─────────────────
@@ -31,7 +31,7 @@ const require = createRequire(import.meta.url);
31
31
  const provisioning = require('../modules/provisioning/provisioning.js');
32
32
 
33
33
  // The actions a teardown supersedes, mirroring enqueueTeardown's SQL.
34
- const SUPERSEDABLE = new Set(['provision', 'settings-apply', 'restart']);
34
+ const SUPERSEDABLE = new Set(['provision', 'settings-apply', 'restart', 'repo-private']);
35
35
 
36
36
  let passed = 0, failed = 0;
37
37
  function t(name, fn) {
@@ -79,7 +79,7 @@ function fakeIntentDb(seed = []) {
79
79
  // dead work in front of a teardown (task 1003140 widened it to the settings
80
80
  // push, task 1003238 to the restart; an open teardown is left alone for the
81
81
  // idempotent path).
82
- assert.match(sql, /action IN \('provision', 'settings-apply', 'restart'\) AND state = 'pending'/);
82
+ assert.match(sql, /action IN \('provision', 'settings-apply', 'restart', 'repo-private'\) AND state = 'pending'/);
83
83
  const hit = rows.filter((r) =>
84
84
  String(r.instance_id) === String(params[0]) && SUPERSEDABLE.has(r.action) && r.state === 'pending');
85
85
  for (const r of hit) { r.state = 'error'; r.last_error = 'superseded: teardown requested'; }