@bevel-software/platform-core-backend 0.8.0 → 0.9.1

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.
Files changed (86) hide show
  1. package/dist/core/core-ports.d.ts +7 -0
  2. package/dist/core/core-ports.d.ts.map +1 -1
  3. package/dist/core/core-ports.js.map +1 -1
  4. package/dist/core/create-core-server.d.ts.map +1 -1
  5. package/dist/core/create-core-server.js +24 -11
  6. package/dist/core/create-core-server.js.map +1 -1
  7. package/dist/core/create-core-services.d.ts +7 -2
  8. package/dist/core/create-core-services.d.ts.map +1 -1
  9. package/dist/core/create-core-services.js +39 -18
  10. package/dist/core/create-core-services.js.map +1 -1
  11. package/dist/modules/access/access.routes.d.ts +3 -1
  12. package/dist/modules/access/access.routes.d.ts.map +1 -1
  13. package/dist/modules/access/access.routes.js +4 -2
  14. package/dist/modules/access/access.routes.js.map +1 -1
  15. package/dist/modules/access/render-roles-yaml.d.ts +22 -0
  16. package/dist/modules/access/render-roles-yaml.d.ts.map +1 -0
  17. package/dist/modules/access/render-roles-yaml.js +56 -0
  18. package/dist/modules/access/render-roles-yaml.js.map +1 -0
  19. package/dist/modules/access/roles-admin.service.d.ts +15 -1
  20. package/dist/modules/access/roles-admin.service.d.ts.map +1 -1
  21. package/dist/modules/access/roles-admin.service.js +30 -15
  22. package/dist/modules/access/roles-admin.service.js.map +1 -1
  23. package/dist/modules/settings/setup.routes.d.ts +10 -1
  24. package/dist/modules/settings/setup.routes.d.ts.map +1 -1
  25. package/dist/modules/settings/setup.routes.js +111 -6
  26. package/dist/modules/settings/setup.routes.js.map +1 -1
  27. package/dist/modules/workspace/startup/kb-git.d.ts +23 -0
  28. package/dist/modules/workspace/startup/kb-git.d.ts.map +1 -0
  29. package/dist/modules/workspace/startup/kb-git.js +86 -0
  30. package/dist/modules/workspace/startup/kb-git.js.map +1 -0
  31. package/dist/modules/workspace/startup/kb-startup-runner.d.ts +74 -0
  32. package/dist/modules/workspace/startup/kb-startup-runner.d.ts.map +1 -0
  33. package/dist/modules/workspace/startup/kb-startup-runner.js +528 -0
  34. package/dist/modules/workspace/startup/kb-startup-runner.js.map +1 -0
  35. package/dist/modules/workspace/startup/on-server-start.d.ts +105 -0
  36. package/dist/modules/workspace/startup/on-server-start.d.ts.map +1 -0
  37. package/dist/modules/workspace/startup/on-server-start.js +21 -0
  38. package/dist/modules/workspace/startup/on-server-start.js.map +1 -0
  39. package/dist/modules/workspace/startup/steps/groups-to-plugins.step.d.ts +46 -0
  40. package/dist/modules/workspace/startup/steps/groups-to-plugins.step.d.ts.map +1 -0
  41. package/dist/modules/workspace/startup/steps/groups-to-plugins.step.js +492 -0
  42. package/dist/modules/workspace/startup/steps/groups-to-plugins.step.js.map +1 -0
  43. package/dist/modules/workspace/startup/steps/roles-yaml.step.d.ts +23 -0
  44. package/dist/modules/workspace/startup/steps/roles-yaml.step.d.ts.map +1 -0
  45. package/dist/modules/workspace/startup/steps/roles-yaml.step.js +69 -0
  46. package/dist/modules/workspace/startup/steps/roles-yaml.step.js.map +1 -0
  47. package/dist/modules/workspace/startup/steps/seed-tree.d.ts +17 -0
  48. package/dist/modules/workspace/startup/steps/seed-tree.d.ts.map +1 -0
  49. package/dist/modules/workspace/startup/steps/seed-tree.js +109 -0
  50. package/dist/modules/workspace/startup/steps/seed-tree.js.map +1 -0
  51. package/dist/modules/workspace/startup/steps/template-files.step.d.ts +103 -0
  52. package/dist/modules/workspace/startup/steps/template-files.step.d.ts.map +1 -0
  53. package/dist/modules/workspace/startup/steps/template-files.step.js +337 -0
  54. package/dist/modules/workspace/startup/steps/template-files.step.js.map +1 -0
  55. package/dist/modules/workspace/workspace.service.d.ts +0 -35
  56. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  57. package/dist/modules/workspace/workspace.service.js +2 -101
  58. package/dist/modules/workspace/workspace.service.js.map +1 -1
  59. package/kb-template/AGENTS.md +11 -5
  60. package/kb-template/access.md +40 -30
  61. package/kb-template/gitignore.template +16 -0
  62. package/package.json +3 -3
  63. package/src/core/core-ports.ts +7 -0
  64. package/src/core/create-core-server.ts +30 -11
  65. package/src/core/create-core-services.ts +43 -22
  66. package/src/modules/access/__tests__/roles-admin.service.test.ts +24 -2
  67. package/src/modules/access/access.routes.ts +3 -0
  68. package/src/modules/access/render-roles-yaml.ts +65 -0
  69. package/src/modules/access/roles-admin.service.ts +32 -14
  70. package/src/modules/settings/__tests__/setup.routes.test.ts +124 -3
  71. package/src/modules/settings/setup.routes.ts +112 -5
  72. package/src/modules/workspace/__tests__/workspace.service.test.ts +5 -94
  73. package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +495 -0
  74. package/src/modules/workspace/startup/kb-git.ts +94 -0
  75. package/src/modules/workspace/startup/kb-startup-runner.ts +597 -0
  76. package/src/modules/workspace/startup/on-server-start.ts +97 -0
  77. package/src/modules/workspace/startup/steps/__tests__/steps.test.ts +636 -0
  78. package/src/modules/workspace/{plugins-migration.ts → startup/steps/groups-to-plugins.step.ts} +342 -260
  79. package/src/modules/workspace/startup/steps/roles-yaml.step.ts +71 -0
  80. package/src/modules/workspace/startup/steps/seed-tree.ts +115 -0
  81. package/src/modules/workspace/startup/steps/template-files.step.ts +360 -0
  82. package/src/modules/workspace/workspace.service.ts +2 -106
  83. package/src/modules/workspace/__tests__/kb-seed.service.test.ts +0 -512
  84. package/src/modules/workspace/__tests__/plugins-migration.test.ts +0 -427
  85. package/src/modules/workspace/kb-seed.interface.ts +0 -36
  86. package/src/modules/workspace/kb-seed.service.ts +0 -584
@@ -435,32 +435,9 @@ describe('WorkspaceService — clone bootstrap & sibling reference', () => {
435
435
  await svc.getOrCreateForBranch('alice/draft');
436
436
  expect(cloned).toEqual([workspaceIdForBranch('alice/draft')]);
437
437
  });
438
-
439
- // An upgraded deployment REUSES the persistent clone, so a top-up bound to
440
- // fresh clones alone never runs a new build's scaffolding or migrations —
441
- // the Groups→Plugins rename sat out an upgrade exactly this way. Every boot
442
- // must offer the top-up to an existing clone once.
443
- it('offers the scaffolding top-up to an existing clone once per process', async () => {
444
- const seed = {
445
- ensureRemoteSeeded: vi.fn(async () => {}),
446
- topUpWorkspace: vi.fn(async () => {}),
447
- };
448
- const firstBoot = new WorkspaceService(workspacesRoot, upstream, 'knowledge-base');
449
- firstBoot.setSeedService(seed);
450
- await firstBoot.getOrCreateForBranch('target-company-state');
451
- expect(seed.topUpWorkspace).toHaveBeenCalledTimes(1); // the fresh clone
452
-
453
- // A new process over the same workspaces dir — the deployed-upgrade case:
454
- // the clone exists, and the top-up must still run, once, not per access.
455
- const nextBoot = new WorkspaceService(workspacesRoot, upstream, 'knowledge-base');
456
- nextBoot.setSeedService(seed);
457
- await nextBoot.getOrCreateForBranch('target-company-state');
458
- await nextBoot.getOrCreateForBranch('target-company-state');
459
- expect(seed.topUpWorkspace).toHaveBeenCalledTimes(2);
460
- });
461
438
  });
462
439
 
463
- describe('WorkspaceService — scaffolding top-up on restart-survivor clones', () => {
440
+ describe('WorkspaceService.sweepOrphanedWorkspaces', () => {
464
441
  let root: string;
465
442
 
466
443
  beforeEach(async () => {
@@ -475,81 +452,15 @@ describe('WorkspaceService — scaffolding top-up on restart-survivor clones', (
475
452
  await fs.rm(root, { recursive: true, force: true });
476
453
  });
477
454
 
478
- // The top-up MOVES files (the Groups→Plugins migration runs inside it), so a
479
- // caller handed the workspace mid-run reads a tree with both halves missing.
480
- it('makes a concurrent opener wait out the in-flight top-up instead of returning mid-migration', async () => {
481
- await seedBranchWorkspace(root, 'target-company-state');
482
- const svc = new WorkspaceService(root, 'https://github.com/Bevel-Software/knowledge-base.git', 'knowledge-base');
483
- let release!: () => void;
484
- const gate = new Promise<void>((r) => { release = r; });
485
- const seed = {
486
- ensureRemoteSeeded: vi.fn(async () => {}),
487
- topUpWorkspace: vi.fn(() => gate),
488
- };
489
- svc.setSeedService(seed);
490
-
491
- let aDone = false;
492
- let bDone = false;
493
- const a = svc.getOrCreateForBranch('target-company-state').then((v) => { aDone = true; return v; });
494
- const b = svc.getOrCreateForBranch('target-company-state').then((v) => { bDone = true; return v; });
495
- // Wait until the top-up has started (the fake clone makes the preceding
496
- // git-config repair fail slowly, so poll rather than sleep), then give
497
- // both callers time to settle against the gate.
498
- await vi.waitFor(() => expect(seed.topUpWorkspace).toHaveBeenCalledTimes(1));
499
- await new Promise((r) => setTimeout(r, 30));
500
- // Neither caller has been handed the workspace while the top-up runs —
501
- // and the second did not start a rival run.
502
- expect(aDone).toBe(false);
503
- expect(bDone).toBe(false);
504
- expect(seed.topUpWorkspace).toHaveBeenCalledTimes(1);
505
-
506
- release();
507
- const [infoA, infoB] = await Promise.all([a, b]);
508
- expect(infoA.id).toBe(infoB.id);
509
- });
510
-
511
- it('re-offers the top-up after deleteWorkspace — the claim died with the clone', async () => {
512
- await seedBranchWorkspace(root, 'target-company-state');
513
- const svc = new WorkspaceService(root, 'https://github.com/Bevel-Software/knowledge-base.git', 'knowledge-base');
514
- const seed = {
515
- ensureRemoteSeeded: vi.fn(async () => {}),
516
- topUpWorkspace: vi.fn(async () => {}),
517
- };
518
- svc.setSeedService(seed);
519
-
520
- await svc.getOrCreateForBranch('target-company-state');
521
- expect(seed.topUpWorkspace).toHaveBeenCalledTimes(1);
522
-
523
- await svc.deleteWorkspace(workspaceIdForBranch('target-company-state'));
524
- // A later bootstrap re-creates the clone (seeded by hand here); it must
525
- // be offered the top-up afresh — the old claim was about a clone that no
526
- // longer exists.
527
- await seedBranchWorkspace(root, 'target-company-state');
528
- await svc.getOrCreateForBranch('target-company-state');
529
- expect(seed.topUpWorkspace).toHaveBeenCalledTimes(2);
530
- });
531
-
532
- it('re-offers the top-up after the orphan sweep removes the clone — same eviction rule', async () => {
533
- await seedBranchWorkspace(root, 'target-company-state');
455
+ it('removes the clone of a branch that is not in the known set', async () => {
456
+ const { workspaceId, workspaceDir } = await seedBranchWorkspace(root, 'target-company-state');
534
457
  const svc = new WorkspaceService(root, 'https://github.com/Bevel-Software/knowledge-base.git', 'knowledge-base');
535
- const seed = {
536
- ensureRemoteSeeded: vi.fn(async () => {}),
537
- topUpWorkspace: vi.fn(async () => {}),
538
- };
539
- svc.setSeedService(seed);
540
-
541
458
  await svc.getOrCreateForBranch('target-company-state');
542
- expect(seed.topUpWorkspace).toHaveBeenCalledTimes(1);
543
459
 
544
460
  // The branch vanishes from the known set; the sweep reclaims its clone.
545
461
  const { removed } = await svc.sweepOrphanedWorkspaces([]);
546
- expect(removed).toContain(workspaceIdForBranch('target-company-state'));
547
-
548
- // Re-created before any restart: the fresh clone must still get its one
549
- // top-up — the claim died with the directory the sweep removed.
550
- await seedBranchWorkspace(root, 'target-company-state');
551
- await svc.getOrCreateForBranch('target-company-state');
552
- expect(seed.topUpWorkspace).toHaveBeenCalledTimes(2);
462
+ expect(removed).toContain(workspaceId);
463
+ await expect(fs.stat(workspaceDir)).rejects.toThrow();
553
464
  });
554
465
  });
555
466
 
@@ -0,0 +1,495 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import { execFile } from 'node:child_process';
3
+ import { promisify } from 'node:util';
4
+ import fs from 'node:fs/promises';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+ import { KbStartupRunner } from '../kb-startup-runner.js';
8
+ import { redactSecret } from '../kb-git.js';
9
+ import type { OnServerStart, ServerStartContext, StepResult } from '../on-server-start.js';
10
+
11
+ const execFileAsync = promisify(execFile);
12
+
13
+ const PROTECTED = ['current-company-state', 'target-company-state'];
14
+ const DEFAULT_BRANCH = 'current-company-state';
15
+
16
+ async function git(cwd: string, args: string[]): Promise<string> {
17
+ const { stdout } = await execFileAsync('git', args, {
18
+ cwd,
19
+ env: {
20
+ ...process.env,
21
+ GIT_AUTHOR_NAME: 'Test', GIT_AUTHOR_EMAIL: 't@x.com',
22
+ GIT_COMMITTER_NAME: 'Test', GIT_COMMITTER_EMAIL: 't@x.com',
23
+ },
24
+ });
25
+ return stdout.toString();
26
+ }
27
+
28
+ let root: string;
29
+ let upstream: string;
30
+ let workspacesRoot: string;
31
+
32
+ beforeEach(async () => {
33
+ root = await fs.mkdtemp(path.join(os.tmpdir(), 'kb-startup-'));
34
+ workspacesRoot = path.join(root, 'workspaces');
35
+ await fs.mkdir(workspacesRoot, { recursive: true });
36
+ upstream = path.join(root, 'upstream.git');
37
+ await git(root, ['init', '--bare', '-b', DEFAULT_BRANCH, upstream]);
38
+ delete process.env.KB_SAFE_BOOT;
39
+ });
40
+
41
+ afterEach(async () => {
42
+ delete process.env.KB_SAFE_BOOT;
43
+ await fs.rm(root, { recursive: true, force: true });
44
+ });
45
+
46
+ /** A populated upstream: one commit on the default branch, both protected refs. */
47
+ async function populatedUpstream(): Promise<void> {
48
+ const seed = path.join(root, '.seed');
49
+ await fs.mkdir(seed);
50
+ await git(seed, ['init', '-b', DEFAULT_BRANCH]);
51
+ await fs.writeFile(path.join(seed, 'marker.txt'), 'seeded', 'utf8');
52
+ await git(seed, ['add', '-A']);
53
+ await git(seed, ['commit', '-m', 'init']);
54
+ await git(seed, ['branch', PROTECTED[1]!]);
55
+ await git(seed, ['remote', 'add', 'origin', upstream]);
56
+ await git(seed, ['push', 'origin', ...PROTECTED]);
57
+ }
58
+
59
+ function makeRunner(steps: OnServerStart[], overrides: Partial<Parameters<typeof runnerOpts>[1]> = {}) {
60
+ return new KbStartupRunner(runnerOpts(steps, overrides));
61
+ }
62
+
63
+ function runnerOpts(steps: OnServerStart[], overrides: Record<string, unknown> = {}) {
64
+ return {
65
+ kbRepoUrl: () => upstream,
66
+ gitUsername: () => 'x-access-token',
67
+ workspacesRoot,
68
+ kbDirName: 'knowledge-base',
69
+ templateDir: path.join(root, 'template'),
70
+ defaultBranch: () => DEFAULT_BRANCH,
71
+ protectedBranches: () => PROTECTED,
72
+ seedAdminEmails: ['admin@example.com'],
73
+ steps,
74
+ buildSeedTree: async (dir: string) => {
75
+ await fs.writeFile(path.join(dir, 'seeded.md'), 'from template', 'utf8');
76
+ return [];
77
+ },
78
+ ...overrides,
79
+ };
80
+ }
81
+
82
+ function step(name: string, run: (ctx: ServerStartContext) => Promise<StepResult>): OnServerStart {
83
+ return { name, run };
84
+ }
85
+
86
+ async function checkout(branch: string): Promise<string> {
87
+ const dir = path.join(root, `checkout-${branch}-${Math.random().toString(36).slice(2)}`);
88
+ await git(root, ['clone', '-b', branch, upstream, dir]);
89
+ return dir;
90
+ }
91
+
92
+ describe('KbStartupRunner', () => {
93
+ it('seeds an empty remote with every protected branch before any step runs', async () => {
94
+ const seen: string[] = [];
95
+ await makeRunner([
96
+ step('probe', async (ctx) => {
97
+ for (const b of await ctx.protectedBranches()) seen.push(b.name);
98
+ return { outcome: 'ok' };
99
+ }),
100
+ ]).runAll();
101
+ expect(seen.sort()).toEqual([...PROTECTED].sort());
102
+ for (const b of PROTECTED) {
103
+ const dir = await checkout(b);
104
+ expect(await fs.readFile(path.join(dir, 'seeded.md'), 'utf8')).toBe('from template');
105
+ }
106
+ });
107
+
108
+ it('rejects a repository URL with embedded credentials — operator error, fail-closed', async () => {
109
+ let stepRan = false;
110
+ await expect(
111
+ makeRunner(
112
+ [step('never', async () => { stepRan = true; return { outcome: 'ok' }; })],
113
+ { kbRepoUrl: () => 'https://alice:hunter2@example.com/kb.git' },
114
+ ).runAll(),
115
+ ).rejects.toThrow(/embeds credentials.*process listings/s);
116
+ expect(stepRan).toBe(false);
117
+ });
118
+
119
+ it('KB_SAFE_BOOT boots over a persisted credentials-bearing URL — the rescue never invokes git', async () => {
120
+ process.env.KB_SAFE_BOOT = '1';
121
+ vi.spyOn(console, 'error').mockImplementation(() => {});
122
+ try {
123
+ await makeRunner(
124
+ [step('never', async () => ({ outcome: 'ok' }))],
125
+ { kbRepoUrl: () => 'https://alice:hunter2@example.com/kb.git' },
126
+ ).runAll(); // resolves — the server boots unmaintained
127
+ } finally {
128
+ vi.restoreAllMocks();
129
+ }
130
+ });
131
+
132
+ it('accepts a concurrent replica\'s seed when the empty-remote seed push loses the race', async () => {
133
+ // The WINNER lands its seed between this replica's empty ls-remote and
134
+ // its push — buildSeedTree runs exactly in that window, which makes the
135
+ // race deterministic with real git.
136
+ const seen: string[] = [];
137
+ vi.spyOn(console, 'log').mockImplementation(() => {});
138
+ try {
139
+ await makeRunner(
140
+ [
141
+ step('probe', async (ctx) => {
142
+ for (const b of await ctx.protectedBranches()) seen.push(b.name);
143
+ return { outcome: 'ok' };
144
+ }),
145
+ ],
146
+ {
147
+ buildSeedTree: async (dir: string) => {
148
+ const winner = path.join(root, 'winner-seed');
149
+ await fs.mkdir(winner);
150
+ await git(winner, ['init', '-b', DEFAULT_BRANCH]);
151
+ await fs.writeFile(path.join(winner, 'seeded.md'), 'winner content', 'utf8');
152
+ await git(winner, ['add', '-A']);
153
+ await git(winner, ['commit', '-m', 'winner seed']);
154
+ await git(winner, ['branch', PROTECTED[1]!]);
155
+ await git(winner, ['remote', 'add', 'origin', upstream]);
156
+ await git(winner, ['push', 'origin', ...PROTECTED]);
157
+ await fs.writeFile(path.join(dir, 'seeded.md'), 'loser content', 'utf8');
158
+ return [];
159
+ },
160
+ },
161
+ ).runAll(); // resolves — the loser accepts the winner's identical work
162
+ } finally {
163
+ vi.restoreAllMocks();
164
+ }
165
+ expect(seen.sort()).toEqual([...PROTECTED].sort());
166
+ // The remote carries the WINNER's seed; the loser's never landed.
167
+ for (const b of PROTECTED) {
168
+ const dir = await checkout(b);
169
+ expect(await fs.readFile(path.join(dir, 'seeded.md'), 'utf8')).toBe('winner content');
170
+ }
171
+ });
172
+
173
+ it('applies a step\'s buffered ops before the next step runs, and commits once per branch with the notes', async () => {
174
+ await populatedUpstream();
175
+ const secondSaw: string[] = [];
176
+ await makeRunner([
177
+ step('first', async (ctx) => {
178
+ const b = await ctx.defaultBranch();
179
+ b.write('a/one.md', 'one');
180
+ b.note('Add one.md');
181
+ return { outcome: 'ok' };
182
+ }),
183
+ step('second', async (ctx) => {
184
+ const b = await ctx.defaultBranch();
185
+ // The previous step's op is REAL on disk by now.
186
+ secondSaw.push(await fs.readFile(path.join(await b.repoDir(), 'a/one.md'), 'utf8'));
187
+ b.write('b/two.md', 'two');
188
+ b.note('Add two.md');
189
+ return { outcome: 'ok' };
190
+ }),
191
+ ]).runAll();
192
+ expect(secondSaw).toEqual(['one']);
193
+ const dir = await checkout(DEFAULT_BRANCH);
194
+ expect(await fs.readFile(path.join(dir, 'a/one.md'), 'utf8')).toBe('one');
195
+ expect(await fs.readFile(path.join(dir, 'b/two.md'), 'utf8')).toBe('two');
196
+ const log = await git(dir, ['log', '--format=%s%n%b', '-1']);
197
+ expect(log).toContain('Add one.md');
198
+ expect(log).toContain('Add two.md');
199
+ // ONE commit carried both steps' changes.
200
+ const count = (await git(dir, ['rev-list', '--count', 'HEAD'])).trim();
201
+ expect(count).toBe('2'); // init + the one startup commit
202
+ });
203
+
204
+ it('discards a skipped step\'s ops unapplied — the tree is untouched by construction', async () => {
205
+ await populatedUpstream();
206
+ await makeRunner([
207
+ step('flaky', async (ctx) => {
208
+ (await ctx.defaultBranch()).write('never.md', 'x');
209
+ return { outcome: 'skipped', reason: 'precondition unmet' };
210
+ }),
211
+ step('after', async (ctx) => {
212
+ const repo = await (await ctx.defaultBranch()).repoDir();
213
+ expect(await fs.access(path.join(repo, 'never.md')).then(() => true, () => false)).toBe(false);
214
+ return { outcome: 'ok' };
215
+ }),
216
+ ]).runAll();
217
+ const dir = await checkout(DEFAULT_BRANCH);
218
+ await expect(fs.access(path.join(dir, 'never.md'))).rejects.toThrow();
219
+ });
220
+
221
+ it('an unhandled step throw stops the boot with the step named; nothing commits', async () => {
222
+ await populatedUpstream();
223
+ await expect(
224
+ makeRunner([
225
+ step('good', async (ctx) => {
226
+ const b = await ctx.defaultBranch();
227
+ b.write('good.md', 'ok');
228
+ return { outcome: 'ok' };
229
+ }),
230
+ step('broken', async () => {
231
+ throw new Error('ECONNREFUSED somewhere');
232
+ }),
233
+ ]).runAll(),
234
+ ).rejects.toThrow(/step "broken" failed: .*ECONNREFUSED/);
235
+ // The good step's APPLIED but uncommitted work never reached the remote.
236
+ const dir = await checkout(DEFAULT_BRANCH);
237
+ await expect(fs.access(path.join(dir, 'good.md'))).rejects.toThrow();
238
+ });
239
+
240
+ it('stopBoot is the deliberate kill switch and carries the message', async () => {
241
+ await populatedUpstream();
242
+ await expect(
243
+ makeRunner([
244
+ step('guard', async () => ({ outcome: 'stopBoot', message: 'KB layout is newer than this build' })),
245
+ ]).runAll(),
246
+ ).rejects.toThrow(/stopped the boot: KB layout is newer/);
247
+ });
248
+
249
+ it('KB_SAFE_BOOT=1 abandons the phase on failure, resets uncommitted work, and boots', async () => {
250
+ await populatedUpstream();
251
+ const originSha = (await git(path.join(root, '.seed'), ['rev-parse', 'HEAD'])).trim();
252
+ process.env.KB_SAFE_BOOT = '1';
253
+ await makeRunner([
254
+ step('good', async (ctx) => {
255
+ (await ctx.defaultBranch()).write('good.md', 'ok');
256
+ return { outcome: 'ok' };
257
+ }),
258
+ step('broken', async () => {
259
+ throw new Error('boom');
260
+ }),
261
+ ]).runAll(); // resolves — the server boots
262
+ // The applied-but-uncommitted change was reset; the remote never saw it.
263
+ const local = path.join(workspacesRoot, DEFAULT_BRANCH, 'knowledge-base');
264
+ await expect(fs.access(path.join(local, 'good.md'))).rejects.toThrow();
265
+ // The rollback lands EXACTLY on the pre-phase sha recorded at clone time.
266
+ expect((await git(local, ['rev-parse', 'HEAD'])).trim()).toBe(originSha);
267
+ const dir = await checkout(DEFAULT_BRANCH);
268
+ await expect(fs.access(path.join(dir, 'good.md'))).rejects.toThrow();
269
+ });
270
+
271
+ it('KB_SAFE_BOOT rollback undoes even a created-but-unpushed finalize commit (reset to the pre-phase sha)', async () => {
272
+ await populatedUpstream();
273
+ const originSha = (await git(path.join(root, '.seed'), ['rev-parse', 'HEAD'])).trim();
274
+ // A pre-receive hook that refuses every push: a POLICY refusal, not the
275
+ // concurrent-replica carve-out — finalize rethrows AFTER committing, and
276
+ // safe boot must roll that commit back or it strands locally forever.
277
+ const hookPath = path.join(upstream, 'hooks', 'pre-receive');
278
+ await fs.mkdir(path.dirname(hookPath), { recursive: true });
279
+ await fs.writeFile(hookPath, '#!/bin/sh\necho "policy says no" >&2\nexit 1\n', { mode: 0o755 });
280
+ process.env.KB_SAFE_BOOT = '1';
281
+ vi.spyOn(console, 'error').mockImplementation(() => {});
282
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
283
+ try {
284
+ await makeRunner([
285
+ step('write', async (ctx) => {
286
+ const b = await ctx.defaultBranch();
287
+ b.write('managed.md', 'phase');
288
+ b.note('Add managed.md');
289
+ return { outcome: 'ok' };
290
+ }),
291
+ ]).runAll(); // resolves — safe boot abandons after the push refusal
292
+ } finally {
293
+ vi.restoreAllMocks();
294
+ }
295
+ const local = path.join(workspacesRoot, DEFAULT_BRANCH, 'knowledge-base');
296
+ expect((await git(local, ['rev-parse', 'HEAD'])).trim()).toBe(originSha);
297
+ await expect(fs.access(path.join(local, 'managed.md'))).rejects.toThrow();
298
+ });
299
+
300
+ it("finalize stages only the phase's paths — pre-existing dirt in a surviving clone stays out of the commit and in the tree", async () => {
301
+ await populatedUpstream();
302
+ // Materialize the surviving clone, then drop operator dirt into it.
303
+ await makeRunner([
304
+ step('touch', async (ctx) => {
305
+ await (await ctx.defaultBranch()).repoDir();
306
+ return { outcome: 'ok' };
307
+ }),
308
+ ]).runAll();
309
+ const local = path.join(workspacesRoot, DEFAULT_BRANCH, 'knowledge-base');
310
+ await fs.writeFile(path.join(local, 'stray.md'), 'operator dirt', 'utf8');
311
+ // A crashed tool even STAGED it — the pre-existing index state must be
312
+ // dropped, not adopted into the phase's commit.
313
+ await git(local, ['add', '--', 'stray.md']);
314
+
315
+ await makeRunner([
316
+ step('write', async (ctx) => {
317
+ const b = await ctx.defaultBranch();
318
+ b.write('managed.md', 'phase');
319
+ b.note('Add managed.md');
320
+ return { outcome: 'ok' };
321
+ }),
322
+ ]).runAll();
323
+
324
+ // The commit carries ONLY the phase's path.
325
+ const committed = (await git(local, ['show', '--name-only', '--format=', 'HEAD'])).trim();
326
+ expect(committed.split('\n')).toEqual(['managed.md']);
327
+ const dir = await checkout(DEFAULT_BRANCH);
328
+ expect(await fs.readFile(path.join(dir, 'managed.md'), 'utf8')).toBe('phase');
329
+ await expect(fs.access(path.join(dir, 'stray.md'))).rejects.toThrow();
330
+ // The dirt remains in the working tree, uncommitted — not this phase's to touch.
331
+ expect(await fs.readFile(path.join(local, 'stray.md'), 'utf8')).toBe('operator dirt');
332
+ expect((await git(local, ['status', '--porcelain', '--', 'stray.md'])).trim()).toBe('?? stray.md');
333
+ });
334
+
335
+ it('draft branches are reachable AND writable through allBranches()', async () => {
336
+ await populatedUpstream();
337
+ const seed = path.join(root, '.seed');
338
+ await git(seed, ['checkout', '-b', 'alice/draft']);
339
+ await fs.writeFile(path.join(seed, 'draft.md'), 'draft', 'utf8');
340
+ await git(seed, ['add', '-A']);
341
+ await git(seed, ['commit', '-m', 'draft work']);
342
+ await git(seed, ['push', 'origin', 'alice/draft']);
343
+
344
+ await makeRunner([
345
+ step('uniform', async (ctx) => {
346
+ for (const b of await ctx.allBranches()) {
347
+ expect(typeof b.isProtected).toBe('boolean');
348
+ b.write('uniform.md', `maintained ${b.name}`);
349
+ b.note(`Maintain ${b.name}`);
350
+ }
351
+ return { outcome: 'ok' };
352
+ }),
353
+ ]).runAll();
354
+
355
+ const draft = await checkout('alice/draft');
356
+ expect(await fs.readFile(path.join(draft, 'uniform.md'), 'utf8')).toBe('maintained alice/draft');
357
+ expect(await fs.readFile(path.join(draft, 'draft.md'), 'utf8')).toBe('draft');
358
+ });
359
+
360
+ it('refuses op paths that escape, target .git, or traverse a symlink', async () => {
361
+ await populatedUpstream();
362
+ for (const bad of ['../outside.md', '.git/hooks/x', '']) {
363
+ await expect(
364
+ makeRunner([
365
+ step('hostile', async (ctx) => {
366
+ (await ctx.defaultBranch()).write(bad, 'x');
367
+ return { outcome: 'ok' };
368
+ }),
369
+ ]).runAll(),
370
+ ).rejects.toThrow(/op path/);
371
+ }
372
+ });
373
+
374
+ it('self-skips when the branch model is set but the repository URL is not', async () => {
375
+ let stepRan = false;
376
+ // A partially set-up deployment (branch model saved, repo URL not yet):
377
+ // running would `ls-remote ''` and fail forever, with the setup screen
378
+ // that could fix it unreachable behind the failed boot.
379
+ await makeRunner(
380
+ [step('never', async () => { stepRan = true; return { outcome: 'ok' }; })],
381
+ { kbRepoUrl: () => '' },
382
+ ).runAll(); // resolves — nothing to maintain yet
383
+ expect(stepRan).toBe(false);
384
+ });
385
+
386
+ it("discards a skipped step's notes with its ops — a later commit never carries them", async () => {
387
+ await populatedUpstream();
388
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
389
+ try {
390
+ await makeRunner([
391
+ step('flaky', async (ctx) => {
392
+ const b = await ctx.defaultBranch();
393
+ b.write('never.md', 'x');
394
+ b.note('Never say this');
395
+ return { outcome: 'skipped', reason: 'precondition unmet' };
396
+ }),
397
+ step('after', async (ctx) => {
398
+ const b = await ctx.defaultBranch();
399
+ b.write('after.md', 'y');
400
+ b.note('Add after.md');
401
+ return { outcome: 'ok' };
402
+ }),
403
+ ]).runAll();
404
+ } finally {
405
+ vi.restoreAllMocks();
406
+ }
407
+ const dir = await checkout(DEFAULT_BRANCH);
408
+ const log = await git(dir, ['log', '--format=%s%n%b', '-1']);
409
+ expect(log).toContain('Add after.md');
410
+ expect(log).not.toContain('Never say this');
411
+ });
412
+
413
+ it('the push-race rollback undoes exactly the startup commit — a pre-existing AHEAD commit survives', async () => {
414
+ await populatedUpstream();
415
+ // Materialize the clone, then give it a committed-but-unpushed (AHEAD)
416
+ // commit — the crash-before-push state ensureClone deliberately preserves.
417
+ await makeRunner([
418
+ step('touch', async (ctx) => {
419
+ await (await ctx.defaultBranch()).repoDir();
420
+ return { outcome: 'ok' };
421
+ }),
422
+ ]).runAll();
423
+ const local = path.join(workspacesRoot, DEFAULT_BRANCH, 'knowledge-base');
424
+ await fs.writeFile(path.join(local, 'unpushed.md'), 'precious', 'utf8');
425
+ await git(local, ['add', '-A']);
426
+ await git(local, ['commit', '-m', 'committed but unpushed']);
427
+
428
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
429
+ try {
430
+ await makeRunner([
431
+ step('dirty-and-race', async (ctx) => {
432
+ const b = await ctx.defaultBranch();
433
+ b.write('startup.md', 'maintenance');
434
+ b.note('Add startup.md');
435
+ // A concurrent replica lands its own commit upstream AFTER this
436
+ // runner's fetch, so the finalize push is rejected non-fast-forward.
437
+ const other = await checkout(DEFAULT_BRANCH);
438
+ await fs.writeFile(path.join(other, 'winner.md'), 'replica won', 'utf8');
439
+ await git(other, ['add', '-A']);
440
+ await git(other, ['commit', '-m', 'replica commit']);
441
+ await git(other, ['push', 'origin', DEFAULT_BRANCH]);
442
+ return { outcome: 'ok' };
443
+ }),
444
+ ]).runAll(); // resolves — the carve-out rolls back and continues
445
+ } finally {
446
+ vi.restoreAllMocks();
447
+ }
448
+ // The rollback undid the startup commit and ONLY it: the AHEAD commit is
449
+ // still HEAD (a reset to origin/<branch> would have discarded it too).
450
+ expect(await fs.readFile(path.join(local, 'unpushed.md'), 'utf8')).toBe('precious');
451
+ expect((await git(local, ['log', '--format=%s', '-1'])).trim()).toBe('committed but unpushed');
452
+ expect(await fs.access(path.join(local, 'startup.md')).then(() => true, () => false)).toBe(false);
453
+ });
454
+
455
+ it('a surviving clone that is AHEAD keeps its unpushed commit — not this phase\'s to discard', async () => {
456
+ await populatedUpstream();
457
+ // First pass touches the branch so the clone exists (handles are lazy —
458
+ // a pass that never asks for repoDir never clones).
459
+ await makeRunner([
460
+ step('touch', async (ctx) => {
461
+ await (await ctx.defaultBranch()).repoDir();
462
+ return { outcome: 'ok' };
463
+ }),
464
+ ]).runAll();
465
+ const local = path.join(workspacesRoot, DEFAULT_BRANCH, 'knowledge-base');
466
+ // A crash-before-push scenario: a committed local change origin never got.
467
+ await fs.writeFile(path.join(local, 'unpushed.md'), 'precious', 'utf8');
468
+ await git(local, ['add', '-A']);
469
+ await git(local, ['commit', '-m', 'committed but unpushed']);
470
+
471
+ await makeRunner([step('noop', async () => ({ outcome: 'ok' }))]).runAll();
472
+ expect(await fs.readFile(path.join(local, 'unpushed.md'), 'utf8')).toBe('precious');
473
+ });
474
+ });
475
+
476
+ describe('redactSecret', () => {
477
+ it('scrubs the configured token wherever it appears', () => {
478
+ const prev = process.env.GITHUB_TOKEN;
479
+ process.env.GITHUB_TOKEN = 'ghp_supersecret';
480
+ try {
481
+ expect(redactSecret('fatal: ghp_supersecret was rejected')).toBe('fatal: *** was rejected');
482
+ } finally {
483
+ if (prev === undefined) delete process.env.GITHUB_TOKEN;
484
+ else process.env.GITHUB_TOKEN = prev;
485
+ }
486
+ });
487
+
488
+ it('scrubs URL userinfo — a user:pass@ remote must not leak the password', () => {
489
+ expect(redactSecret("fetch of 'https://alice:hunter2@example.com/kb.git' failed")).toBe(
490
+ "fetch of 'https://***@example.com/kb.git' failed",
491
+ );
492
+ // A plain URL is untouched.
493
+ expect(redactSecret('https://example.com/kb.git')).toBe('https://example.com/kb.git');
494
+ });
495
+ });