@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
@@ -0,0 +1,597 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { isBranchModelConfigured } from '@bevel-software/platform-shared';
4
+ import { workspaceIdForBranch } from '../workspace.service.js';
5
+ import type { KbBranch, OnServerStart, ServerStartContext } from './on-server-start.js';
6
+ import { git, lsRemoteHeads, redactSecret, stampIdentity, withTempDir } from './kb-git.js';
7
+
8
+ /**
9
+ * The KB startup phase: run every registered {@link OnServerStart} step, in
10
+ * order, against lazily-cloned branch handles, then land one commit per
11
+ * dirty branch. Invoked at the deployment's two quiet moments — boot (before
12
+ * routes mount) and first-time setup completion (the app is gated shut until
13
+ * then) — and never again while the process serves.
14
+ *
15
+ * Fully fail-closed: any failure this phase cannot DECLARE (an unhandled
16
+ * step throw, an unreachable remote, a clone that will not come down, a
17
+ * refused write) throws out of `runAll` and stops the boot. The container's
18
+ * restart policy is the retry — each attempt at boot time on quiet trees —
19
+ * so an environmental failure converges without a human the moment the
20
+ * environment returns. The one carve-out: a push rejected because a
21
+ * concurrent replica won rolls back and continues; the winner already landed
22
+ * the same idempotent changes, and stopping the loser would make every
23
+ * multi-replica deploy flappy by design.
24
+ *
25
+ * `KB_SAFE_BOOT=1` is the break-glass demotion: on the first failure the
26
+ * phase resets every uncommitted tree, abandons the rest of the phase, and
27
+ * lets the server boot UNMAINTAINED so an admin can get in and rescue —
28
+ * loudly, at boot and in the log, because an env var outlives the emergency.
29
+ */
30
+
31
+ export interface KbStartupRunnerOptions {
32
+ kbRepoUrl: () => string;
33
+ gitUsername: () => string;
34
+ workspacesRoot: string;
35
+ kbDirName: string;
36
+ templateDir: string;
37
+ defaultBranch: () => string;
38
+ protectedBranches: () => readonly string[];
39
+ /** Admins written into a freshly-seeded repo's roles.yaml (`ADMIN_EMAIL`). */
40
+ seedAdminEmails: readonly string[];
41
+ /** The ordered step chain — core's steps plus whatever the distribution appends. */
42
+ steps: readonly OnServerStart[];
43
+ /** The empty-remote seed commit builder (template tree + roles.yaml), injected
44
+ so the runner stays free of template knowledge. Receives the temp dir to
45
+ fill; the runner handles init/commit/push around it. Resolves to the
46
+ repo-relative paths the builder GENERATED itself (rather than copied from
47
+ the template) — the runner force-adds them after `git add -A`, so a
48
+ template `.gitignore` rule can never silently drop a required seed file
49
+ from the commit. */
50
+ buildSeedTree: (dir: string) => Promise<string[]>;
51
+ }
52
+
53
+ export class KbStartupRunner {
54
+ constructor(private readonly opts: KbStartupRunnerOptions) {}
55
+
56
+ /**
57
+ * Run the whole phase. Throws to stop the boot; returns normally when the
58
+ * KB is fully maintained (or safe boot abandoned the phase, loudly).
59
+ */
60
+ async runAll(): Promise<void> {
61
+ if (!isBranchModelConfigured()) {
62
+ console.log('[kb-startup] branch model not configured yet — phase skipped until setup completes.');
63
+ return;
64
+ }
65
+ // A branch model without a repository URL is a PARTIALLY set-up deployment
66
+ // (the two can arrive on different saves, and a restart can land between
67
+ // them). There is nothing to maintain yet, and running anyway would
68
+ // `ls-remote ''` — a boot that fails forever while the setup screen it
69
+ // needs stays unreachable. The setup-completion invocation catches up the
70
+ // moment the URL exists.
71
+ if (this.opts.kbRepoUrl().trim() === '') {
72
+ console.log('[kb-startup] KB repository URL not configured yet — phase skipped until setup completes.');
73
+ return;
74
+ }
75
+ const safeBoot = process.env.KB_SAFE_BOOT === '1';
76
+ if (safeBoot) {
77
+ console.warn(
78
+ '[kb-startup] KB_SAFE_BOOT=1 — failures will abandon maintenance instead of stopping the boot. ' +
79
+ 'Remove the variable once the rescue is done.',
80
+ );
81
+ }
82
+
83
+ const phaseStart = Date.now();
84
+ const handles = new Map<string, BranchHandle>();
85
+ // ONE safe-boot boundary around the whole phase — remote preparation, the
86
+ // step loop, AND the finalize commits. Rescue mode must be able to reset
87
+ // and boot whichever of them fails; a boundary around the step loop alone
88
+ // would let an ensureRemote or finalize failure stop the very boot
89
+ // KB_SAFE_BOOT exists to allow.
90
+ try {
91
+ // A URL carrying userinfo (`https://user:token@host/…`) is operator
92
+ // error, and fail-closed means THROWING, not skipping: the embedded
93
+ // credential would ride into argv on every git invocation and be
94
+ // visible in process listings. Checked INSIDE the boundary so
95
+ // KB_SAFE_BOOT can still bring the server up over a persisted bad URL
96
+ // — the rescue never invokes git. (The message never quotes the URL.)
97
+ if (/\/\/[^/]*@/.test(this.opts.kbRepoUrl())) {
98
+ throw new Error(
99
+ 'The KB repository URL embeds credentials (user:token@host), which would be visible in ' +
100
+ 'process listings. Remove them from the URL and configure the token via the setup ' +
101
+ 'screen or GITHUB_TOKEN instead.',
102
+ );
103
+ }
104
+ const heads = await this.ensureRemote();
105
+ const ctx = this.buildContext(heads, handles);
106
+
107
+ for (const step of this.opts.steps) {
108
+ const started = Date.now();
109
+ const result = await step.run(ctx).catch((err: unknown) => {
110
+ const msg = err instanceof Error ? err.message : String(err);
111
+ throw new Error(redactSecret(`KB startup step "${step.name}" failed: ${msg}`));
112
+ });
113
+ const took = `${((Date.now() - started) / 1000).toFixed(1)}s`;
114
+ if (result.outcome === 'stopBoot') {
115
+ // Redacted like every other exit: the message travels beyond logs
116
+ // (the setup status endpoint surfaces it to admins).
117
+ throw new Error(
118
+ redactSecret(`KB startup step "${step.name}" stopped the boot: ${result.message}`),
119
+ );
120
+ }
121
+ if (result.outcome === 'skipped') {
122
+ console.warn(`[kb-startup] ${step.name}: skipped — ${result.reason} (${took})`);
123
+ for (const h of handles.values()) h.discardBuffer();
124
+ continue;
125
+ }
126
+ // Counted before applying — applyBuffer drains the buffers.
127
+ let changes = 0;
128
+ let branches = 0;
129
+ for (const h of handles.values()) {
130
+ const n = h.pendingOpCount();
131
+ if (n > 0) {
132
+ changes += n;
133
+ branches++;
134
+ }
135
+ }
136
+ const scope =
137
+ changes === 0
138
+ ? 'no changes'
139
+ : `${changes} change${changes === 1 ? '' : 's'} on ${branches} branch${branches === 1 ? '' : 'es'}`;
140
+ if (result.outcome === 'partial') {
141
+ console.warn(`[kb-startup] ${step.name}: partial — ${result.reason} (${scope}, ${took})`);
142
+ } else {
143
+ // One line per step even when nothing happened: a silent phase and a
144
+ // step that never ran look identical from the boot log otherwise.
145
+ console.log(`[kb-startup] ${step.name}: ok — ${scope} (${took})`);
146
+ }
147
+ for (const h of handles.values()) await h.applyBuffer();
148
+ }
149
+
150
+ for (const h of handles.values()) {
151
+ await this.finalize(h);
152
+ }
153
+ } catch (err) {
154
+ if (!safeBoot) throw err;
155
+ console.error(
156
+ '[kb-startup] SAFE BOOT: abandoning the phase after a failure — the KB is UNMAINTAINED this run.',
157
+ redactSecret(err instanceof Error ? err.message : String(err)),
158
+ );
159
+ // Reset only DIRTY handles — ones an apply at least began on (the mark
160
+ // is set before the first op, so a mid-apply failure is covered). A
161
+ // clone a step merely read must NOT be swept: sweeping it would disturb
162
+ // pre-existing state in a surviving working clone that no op ever
163
+ // touched. Each reset targets the handle's recorded pre-phase sha, so
164
+ // even a created-but-unpushed finalize commit is rolled back.
165
+ for (const h of handles.values()) await h.resetUncommitted().catch(() => {});
166
+ return;
167
+ }
168
+ console.log(`[kb-startup] phase complete (${((Date.now() - phaseStart) / 1000).toFixed(1)}s).`);
169
+ }
170
+
171
+ /**
172
+ * Remote preparation — runner machinery, not a step, because every remote
173
+ * failure mode here is the runner's to own: an EMPTY remote gets the full
174
+ * seed commit pushed to every protected branch; missing protected refs are
175
+ * created from the best base. Returns the remote's head names (post-seed).
176
+ */
177
+ private async ensureRemote(): Promise<Set<string>> {
178
+ const url = this.opts.kbRepoUrl();
179
+ const user = this.opts.gitUsername();
180
+ const heads = await lsRemoteHeads(url, user);
181
+ const protectedBranches = this.opts.protectedBranches();
182
+ const defaultBranch = this.opts.defaultBranch();
183
+
184
+ if (heads.size === 0) {
185
+ if (this.opts.seedAdminEmails.length === 0) {
186
+ throw new Error(
187
+ 'KB remote is empty and cannot be seeded: no initial Admin was supplied (ADMIN_EMAIL).',
188
+ );
189
+ }
190
+ const seededByOther = await withTempDir(async (dir) => {
191
+ await git(dir, user, ['init', '-b', defaultBranch]);
192
+ await stampIdentity(dir, user);
193
+ const generated = await this.opts.buildSeedTree(dir);
194
+ await git(dir, user, ['add', '-A']);
195
+ // The template may ship a `.gitignore` whose rules happen to match a
196
+ // GENERATED seed file (roles.yaml, a reserved root's .gitkeep) —
197
+ // `add -A` would silently drop it from the seed commit. Force-add
198
+ // exactly what the builder generated; `-f` on an already-staged path
199
+ // is a no-op.
200
+ if (generated.length > 0) await git(dir, user, ['add', '-f', '--', ...generated]);
201
+ await git(dir, user, ['commit', '-m', 'Seed knowledge base from Bevel template']);
202
+ for (const b of protectedBranches) {
203
+ if (b !== defaultBranch) await git(dir, user, ['branch', b]);
204
+ }
205
+ await git(dir, user, ['remote', 'add', 'origin', url]);
206
+ try {
207
+ await git(dir, user, ['push', '-u', 'origin', ...protectedBranches]);
208
+ return null;
209
+ } catch (err) {
210
+ // Two replicas racing to seed the same empty remote: both saw it
211
+ // empty, one push landed first, the loser's is rejected. ONE re-read
212
+ // decides — if every protected branch now exists, the loser accepts
213
+ // the winner's work; anything less is a real push failure and
214
+ // rethrows. Ref EXISTENCE is deliberately the whole test — content
215
+ // identity is not required, because the steps that follow enforce
216
+ // the required scaffolding on every protected branch regardless of
217
+ // who seeded. A foreign seed is just an "existing remote"
218
+ // discovered late, the same contract as a repo populated before
219
+ // boot.
220
+ const reread = await lsRemoteHeads(url, user);
221
+ if (protectedBranches.every((b) => reread.has(b))) return reread;
222
+ throw err;
223
+ }
224
+ });
225
+ if (seededByOther) {
226
+ console.log(
227
+ '[kb-startup] seed push rejected — another replica seeded the remote first; continuing with its branches.',
228
+ );
229
+ return seededByOther;
230
+ }
231
+ console.log(`[kb-startup] seeded empty KB remote with branches: ${protectedBranches.join(', ')}`);
232
+ return new Set(protectedBranches);
233
+ }
234
+
235
+ const base = heads.has(defaultBranch)
236
+ ? defaultBranch
237
+ : (protectedBranches.find((b) => heads.has(b)) ?? [...heads].sort()[0]!);
238
+ for (const b of protectedBranches) {
239
+ if (heads.has(b)) continue;
240
+ await withTempDir(async (dir) => {
241
+ await git(dir, user, ['clone', '--depth', '1', '-b', base, url, 'seed']);
242
+ await git(path.join(dir, 'seed'), user, ['push', 'origin', `HEAD:refs/heads/${b}`]);
243
+ });
244
+ heads.add(b);
245
+ console.log(`[kb-startup] created missing protected branch "${b}" from "${base}"`);
246
+ }
247
+ return heads;
248
+ }
249
+
250
+ private buildContext(heads: Set<string>, handles: Map<string, BranchHandle>): ServerStartContext {
251
+ const protectedSet = new Set(this.opts.protectedBranches());
252
+ const handleFor = (branch: string): BranchHandle => {
253
+ let h = handles.get(branch);
254
+ if (!h) {
255
+ h = new BranchHandle(branch, protectedSet.has(branch), () => this.ensureClone(branch));
256
+ handles.set(branch, h);
257
+ }
258
+ return h;
259
+ };
260
+ return {
261
+ templateDir: this.opts.templateDir,
262
+ defaultBranch: async () => handleFor(this.opts.defaultBranch()),
263
+ protectedBranches: async () => this.opts.protectedBranches().map(handleFor),
264
+ allBranches: async () => [...heads].sort().map(handleFor),
265
+ };
266
+ }
267
+
268
+ /**
269
+ * The branch's working copy at the runtime layout
270
+ * (`<workspacesRoot>/<id>/<kbDirName>`), so the workspace service finds it
271
+ * on disk afterwards. A surviving clone is fast-forwarded to origin when
272
+ * that is a pure fast-forward; a clone that is AHEAD (a crash before push
273
+ * left committed work) is left alone — maintenance lands on top and the
274
+ * push either carries both or rejects and rolls back, and the pending-
275
+ * commit recovery owns that work, not this phase.
276
+ */
277
+ private async ensureClone(branch: string): Promise<string> {
278
+ const user = this.opts.gitUsername();
279
+ const workspaceDir = path.join(this.opts.workspacesRoot, workspaceIdForBranch(branch));
280
+ const repoDir = path.join(workspaceDir, this.opts.kbDirName);
281
+ const hasGit = await fs.access(path.join(repoDir, '.git')).then(() => true, () => false);
282
+ if (!hasGit) {
283
+ await fs.mkdir(workspaceDir, { recursive: true });
284
+ await fs.rm(repoDir, { recursive: true, force: true });
285
+ await git(workspaceDir, user, ['clone', '-b', branch, this.opts.kbRepoUrl(), repoDir]);
286
+ await git(repoDir, user, ['config', 'core.longpaths', 'true']);
287
+ await stampIdentity(repoDir, user);
288
+ return repoDir;
289
+ }
290
+ await git(repoDir, user, ['fetch', 'origin', branch]);
291
+ const local = (await git(repoDir, user, ['rev-parse', 'HEAD'])).trim();
292
+ const remote = (await git(repoDir, user, ['rev-parse', `origin/${branch}`])).trim();
293
+ if (local !== remote) {
294
+ const mergeBase = (await git(repoDir, user, ['merge-base', 'HEAD', `origin/${branch}`])).trim();
295
+ if (mergeBase === local) {
296
+ await git(repoDir, user, ['reset', '--hard', `origin/${branch}`]);
297
+ }
298
+ // Ahead or diverged: committed-but-unpushed work lives here; not ours to discard.
299
+ }
300
+ return repoDir;
301
+ }
302
+
303
+ /** One commit per dirty branch; push; the replica carve-out on rejection. */
304
+ private async finalize(h: BranchHandle): Promise<void> {
305
+ if (!h.dirty) return;
306
+ const repoDir = await h.repoDir();
307
+ const user = this.opts.gitUsername();
308
+ await stampIdentity(repoDir, user);
309
+ // Drop any PRE-EXISTING index state first (a crashed tool may have left
310
+ // edits staged): `git commit` publishes the whole index, and the phase
311
+ // must commit exactly its own staged set. The edits stay in the working
312
+ // tree, unstaged and unpublished — preserved, not adopted.
313
+ await git(repoDir, user, ['reset', '-q']);
314
+ // Stage ONLY the paths the phase's ops touched — sources and targets both
315
+ // (a move's `from` and a remove's path stage as deletions; `add -A -- <path>`
316
+ // handles a deleted path, `-f` handles one a branch `.gitignore` matches).
317
+ // Deliberately NOT `add -A` on the whole tree: pre-existing uncommitted
318
+ // dirt in a reused clone is not this phase's work — it stays out of the
319
+ // phase's commit and remains in the tree, untouched.
320
+ //
321
+ // A pathspec matching nothing is an error, so a path is included only when
322
+ // it exists on disk OR git knows it (`ls-files` non-empty — a tracked path
323
+ // whose deletion must be staged). A path failing both was never tracked
324
+ // and no longer exists: nothing to stage.
325
+ const touched: string[] = [];
326
+ for (const rel of h.appliedPaths()) {
327
+ const onDisk = await fs.access(path.join(repoDir, rel)).then(() => true, () => false);
328
+ if (!onDisk) {
329
+ const known = (await git(repoDir, user, ['ls-files', '--', `:(literal)${rel}`])).trim();
330
+ if (known === '') continue;
331
+ }
332
+ touched.push(rel);
333
+ }
334
+ // `:(literal)` — these are file paths, not pathspecs; chunked so a large
335
+ // migration cannot overflow the platform's argv limit.
336
+ for (let i = 0; i < touched.length; i += 100) {
337
+ await git(repoDir, user, [
338
+ 'add',
339
+ '-A',
340
+ '-f',
341
+ '--',
342
+ ...touched.slice(i, i + 100).map((rel) => `:(literal)${rel}`),
343
+ ]);
344
+ }
345
+ // Exit 0 = nothing staged: the ops converged to no byte changes. (An
346
+ // errored diff reads as "something staged"; a genuinely broken repo then
347
+ // fails loudly at commit rather than being silently skipped here.)
348
+ const nothingStaged = await git(repoDir, user, ['diff', '--cached', '--quiet']).then(
349
+ () => true,
350
+ () => false,
351
+ );
352
+ if (nothingStaged) return;
353
+ // The commit this phase is about to add, remembered so the rollback below
354
+ // can undo exactly it — and ONLY it. Resetting to origin/<name> instead
355
+ // would also nuke a pre-existing committed-but-unpushed (AHEAD) commit
356
+ // that ensureClone deliberately preserved.
357
+ const preCommit = (await git(repoDir, user, ['rev-parse', 'HEAD'])).trim();
358
+ await git(repoDir, user, ['commit', '-m', h.commitMessage()]);
359
+ try {
360
+ await git(repoDir, user, ['push', 'origin', `HEAD:refs/heads/${h.name}`]);
361
+ console.log(`[kb-startup] ${h.name}: ${h.commitSubject()}`);
362
+ // Committed AND pushed: nothing of the phase remains uncommitted here,
363
+ // so a LATER branch's failure under KB_SAFE_BOOT must not rewind this
364
+ // clone to its pre-phase sha — that would leave it behind what origin
365
+ // already holds.
366
+ h.dirty = false;
367
+ } catch (err) {
368
+ const msg = err instanceof Error ? err.message : String(err);
369
+ // The carve-out is ONLY for a push the remote refused as stale — a
370
+ // concurrent replica won the race, and its pass made the same idempotent
371
+ // changes. Git spells that refusal `! [rejected] … (non-fast-forward)`
372
+ // or `… (fetch first)`; the regex matches exactly those two markers.
373
+ // Deliberately NOT the generic `failed to push some refs` / `[rejected]`
374
+ // trailers: a pre-receive hook decline (branch protection on the KB
375
+ // repo, say) prints those too, and that is a policy refusal every boot
376
+ // would hit — it re-throws like auth or network failures (stopping the
377
+ // boot, or demoted to abandon under KB_SAFE_BOOT like every failure).
378
+ if (!/non-fast-forward|fetch first/i.test(msg)) {
379
+ throw err;
380
+ }
381
+ console.warn(
382
+ `[kb-startup] ${h.name}: push rejected (concurrent replica?) — rolling back local commit.`,
383
+ redactSecret(msg),
384
+ );
385
+ await git(repoDir, user, ['reset', '--hard', preCommit]).catch(() => {});
386
+ }
387
+ }
388
+ }
389
+
390
+ type BufferedOp =
391
+ | { kind: 'write'; path: string; content: string | Uint8Array }
392
+ | { kind: 'move'; from: string; to: string }
393
+ | { kind: 'remove'; path: string };
394
+
395
+ /**
396
+ * The {@link KbBranch} implementation: a lazy clone plus an op buffer. Ops AND
397
+ * notes accumulate while a step runs; the RUNNER applies both (`applyBuffer`)
398
+ * on `ok`/`partial` and drops both (`discardBuffer`) on `skipped` — a skipped
399
+ * step's notes must never decorate a commit made of other steps' changes.
400
+ * Applied ops mark the handle dirty; kept notes accumulate across steps into
401
+ * one commit.
402
+ */
403
+ class BranchHandle implements KbBranch {
404
+ constructor(
405
+ readonly name: string,
406
+ readonly isProtected: boolean,
407
+ cloneOnce: () => Promise<string>,
408
+ ) {
409
+ this.clone = lazyOnce(async () => {
410
+ const dir = await cloneOnce();
411
+ // The rollback anchor, recorded ONCE as the clone materializes: a fresh
412
+ // clone's HEAD, or a surviving clone's pre-phase state (post the
413
+ // fast-forward ensureClone may have applied). For the empty-remote seed
414
+ // the handle clones AFTER seeding, so HEAD is the seed commit — correct.
415
+ // resetUncommitted resets to THIS sha rather than HEAD, so a finalize
416
+ // commit that was created but failed to push rolls back too instead of
417
+ // surviving as a stranded local commit no later boot would ever push.
418
+ this.prePhaseSha = (await git(dir, 'x-access-token', ['rev-parse', 'HEAD'])).trim();
419
+ return dir;
420
+ });
421
+ }
422
+
423
+ private readonly clone: () => Promise<string>;
424
+ /** HEAD as of clone time — the safe-boot rollback's anchor (see the constructor). */
425
+ private prePhaseSha: string | null = null;
426
+ private buffer: BufferedOp[] = [];
427
+ /** The CURRENT step's notes — kept or discarded with its ops. */
428
+ private noteBuffer: string[] = [];
429
+ /** Notes of applied steps, in order — the commit message's material. */
430
+ private notes: string[] = [];
431
+ /**
432
+ * Repo-relative paths the ops touched — SOURCES and TARGETS both: writes
433
+ * recorded BEFORE executing (a failed write can leave a partial file the
434
+ * rollback must clean), move destinations AFTER (a failed rename leaves its
435
+ * target untouched — see the note at the rename), and a move's `from` and a
436
+ * remove's path unconditionally at apply time — finalize stages exactly
437
+ * this set, and staging a deletion is what `add -A -- <path>` does.
438
+ */
439
+ private readonly applied = new Set<string>();
440
+ dirty = false;
441
+
442
+ repoDir(): Promise<string> {
443
+ return this.clone();
444
+ }
445
+ write(p: string, content: string | Uint8Array): void {
446
+ this.buffer.push({ kind: 'write', path: p, content });
447
+ }
448
+ move(from: string, to: string): void {
449
+ this.buffer.push({ kind: 'move', from, to });
450
+ }
451
+ remove(p: string): void {
452
+ this.buffer.push({ kind: 'remove', path: p });
453
+ }
454
+ note(line: string): void {
455
+ this.noteBuffer.push(line);
456
+ }
457
+
458
+ discardBuffer(): void {
459
+ this.buffer = [];
460
+ this.noteBuffer = [];
461
+ }
462
+
463
+ /** Apply buffered ops in declaration order, each path contained to the clone. */
464
+ async applyBuffer(): Promise<void> {
465
+ // The step's notes are kept even when it declared no ops — an advisory
466
+ // note (e.g. "both roots exist, merge by hand") surfaces in a commit only
467
+ // if a later step dirties the branch, exactly as before.
468
+ if (this.noteBuffer.length > 0) {
469
+ this.notes.push(...this.noteBuffer);
470
+ this.noteBuffer = [];
471
+ }
472
+ if (this.buffer.length === 0) return;
473
+ const repoDir = await this.repoDir();
474
+ // Dirty from the FIRST op, not the last: a mid-apply failure must leave
475
+ // the handle marked so the safe-boot rollback sweeps its partial writes.
476
+ this.dirty = true;
477
+ const ops = this.buffer;
478
+ this.buffer = [];
479
+ for (const op of ops) {
480
+ if (op.kind === 'write') {
481
+ this.applied.add(op.path);
482
+ const abs = await containedPath(repoDir, op.path);
483
+ await fs.mkdir(path.dirname(abs), { recursive: true });
484
+ await fs.writeFile(abs, op.content);
485
+ } else if (op.kind === 'move') {
486
+ // The SOURCE is recorded unconditionally: finalize must stage its
487
+ // disappearance. (Harmless to the rollback — a tracked source is
488
+ // restored by the reset, and `clean` never touches tracked paths.)
489
+ this.applied.add(op.from);
490
+ const from = await containedPath(repoDir, op.from);
491
+ const to = await containedPath(repoDir, op.to);
492
+ await fs.mkdir(path.dirname(to), { recursive: true });
493
+ await fs.rename(from, to);
494
+ // Recorded AFTER the rename, unlike a write's pre-record: rename
495
+ // cannot leave a partial destination (it either happened or errored
496
+ // with the target untouched), and pre-recording would let the
497
+ // rollback delete a pre-existing ignored file at an untouched target.
498
+ this.applied.add(op.to);
499
+ } else {
500
+ // Recorded unconditionally, like a move's source: finalize must stage
501
+ // the deletion of a removed tracked file.
502
+ this.applied.add(op.path);
503
+ const abs = await containedPath(repoDir, op.path);
504
+ await fs.rm(abs, { force: true });
505
+ }
506
+ }
507
+ }
508
+
509
+ /** Ops declared by the current step and not yet applied — log material. */
510
+ pendingOpCount(): number {
511
+ return this.buffer.length;
512
+ }
513
+
514
+ /**
515
+ * Every path the applied ops touched — sources and targets. Finalize stages
516
+ * exactly this set (force-added past any branch `.gitignore`); the safe-boot
517
+ * rollback scopes its `clean` to it.
518
+ */
519
+ appliedPaths(): readonly string[] {
520
+ return [...this.applied];
521
+ }
522
+
523
+ /**
524
+ * Discard everything the phase did (safe boot's abandonment). Keyed on
525
+ * `dirty`, which is set BEFORE the first op applies, so a mid-apply failure
526
+ * is covered — while a clone a step only read is never swept.
527
+ *
528
+ * `reset --hard` targets the PRE-PHASE sha recorded at clone time, not
529
+ * HEAD: a finalize commit that was created but failed to push must roll
530
+ * back too, or it survives as a stranded local commit no later boot would
531
+ * ever push. The reset restores everything tracked; the SCOPED
532
+ * `clean -fdx -- <op paths>` then removes files the ops created (including
533
+ * a partial write whose path a branch `.gitignore` happens to match). No
534
+ * global `clean`: it would delete pre-existing untracked files in a
535
+ * surviving working clone that the phase never touched.
536
+ */
537
+ async resetUncommitted(): Promise<void> {
538
+ if (!this.dirty) return;
539
+ const repoDir = await this.repoDir();
540
+ await git(repoDir, 'x-access-token', ['reset', '--hard', this.prePhaseSha ?? 'HEAD']).catch(() => {});
541
+ if (this.applied.size > 0) {
542
+ // `:(literal)` — these are file paths, not pathspecs: a name that
543
+ // happens to contain glob or magic characters must match itself only,
544
+ // never broaden the cleanup.
545
+ await git(repoDir, 'x-access-token', [
546
+ 'clean',
547
+ '-fdx',
548
+ '--',
549
+ ...[...this.applied].map((rel) => `:(literal)${rel}`),
550
+ ]).catch(() => {});
551
+ }
552
+ }
553
+
554
+ commitSubject(): string {
555
+ return this.notes[0] ?? "Bring the knowledge base up to this build's expectations";
556
+ }
557
+ commitMessage(): string {
558
+ if (this.notes.length <= 1) return this.commitSubject();
559
+ return `${this.commitSubject()}\n\n${this.notes.slice(1).map((n) => `- ${n}`).join('\n')}`;
560
+ }
561
+ }
562
+
563
+ function lazyOnce<T>(fn: () => Promise<T>): () => Promise<T> {
564
+ let p: Promise<T> | undefined;
565
+ return () => (p ??= fn());
566
+ }
567
+
568
+ /**
569
+ * Resolve a repo-relative op path and refuse everything the write layer
570
+ * refuses: absolute paths, `..` escapes, and any SYMLINK among the existing
571
+ * components (a link is a second path to other content — the two can
572
+ * disagree about what a write actually touched).
573
+ */
574
+ async function containedPath(repoDir: string, rel: string): Promise<string> {
575
+ if (!rel || path.isAbsolute(rel)) {
576
+ throw new Error(`op path "${rel}" must be a non-empty repo-relative path`);
577
+ }
578
+ const abs = path.resolve(repoDir, rel);
579
+ const rootRel = path.relative(repoDir, abs);
580
+ if (rootRel.startsWith('..') || path.isAbsolute(rootRel)) {
581
+ throw new Error(`op path "${rel}" escapes the repository`);
582
+ }
583
+ // Walk the EXISTING ancestry; every present component must be a real
584
+ // file/dir. (`.git` is off-limits outright.)
585
+ if (rootRel === '.git' || rootRel.startsWith(`.git${path.sep}`)) {
586
+ throw new Error(`op path "${rel}" targets .git`);
587
+ }
588
+ let probe = abs;
589
+ while (probe !== repoDir) {
590
+ const stat = await fs.lstat(probe).catch(() => null);
591
+ if (stat?.isSymbolicLink()) {
592
+ throw new Error(`op path "${rel}" traverses a symlink at "${path.relative(repoDir, probe)}"`);
593
+ }
594
+ probe = path.dirname(probe);
595
+ }
596
+ return abs;
597
+ }