@bevel-software/platform-core-backend 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/core/core-ports.d.ts +7 -0
- package/dist/core/core-ports.d.ts.map +1 -1
- package/dist/core/core-ports.js.map +1 -1
- package/dist/core/create-core-server.d.ts.map +1 -1
- package/dist/core/create-core-server.js +24 -11
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts +7 -2
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +39 -18
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/modules/access/access.routes.d.ts +3 -1
- package/dist/modules/access/access.routes.d.ts.map +1 -1
- package/dist/modules/access/access.routes.js +4 -2
- package/dist/modules/access/access.routes.js.map +1 -1
- package/dist/modules/access/render-roles-yaml.d.ts +22 -0
- package/dist/modules/access/render-roles-yaml.d.ts.map +1 -0
- package/dist/modules/access/render-roles-yaml.js +56 -0
- package/dist/modules/access/render-roles-yaml.js.map +1 -0
- package/dist/modules/access/roles-admin.service.d.ts +15 -1
- package/dist/modules/access/roles-admin.service.d.ts.map +1 -1
- package/dist/modules/access/roles-admin.service.js +30 -15
- package/dist/modules/access/roles-admin.service.js.map +1 -1
- package/dist/modules/settings/setup.routes.d.ts +10 -1
- package/dist/modules/settings/setup.routes.d.ts.map +1 -1
- package/dist/modules/settings/setup.routes.js +111 -6
- package/dist/modules/settings/setup.routes.js.map +1 -1
- package/dist/modules/workspace/startup/kb-git.d.ts +23 -0
- package/dist/modules/workspace/startup/kb-git.d.ts.map +1 -0
- package/dist/modules/workspace/startup/kb-git.js +86 -0
- package/dist/modules/workspace/startup/kb-git.js.map +1 -0
- package/dist/modules/workspace/startup/kb-startup-runner.d.ts +74 -0
- package/dist/modules/workspace/startup/kb-startup-runner.d.ts.map +1 -0
- package/dist/modules/workspace/startup/kb-startup-runner.js +528 -0
- package/dist/modules/workspace/startup/kb-startup-runner.js.map +1 -0
- package/dist/modules/workspace/startup/on-server-start.d.ts +105 -0
- package/dist/modules/workspace/startup/on-server-start.d.ts.map +1 -0
- package/dist/modules/workspace/startup/on-server-start.js +21 -0
- package/dist/modules/workspace/startup/on-server-start.js.map +1 -0
- package/dist/modules/workspace/startup/steps/groups-to-plugins.step.d.ts +46 -0
- package/dist/modules/workspace/startup/steps/groups-to-plugins.step.d.ts.map +1 -0
- package/dist/modules/workspace/startup/steps/groups-to-plugins.step.js +492 -0
- package/dist/modules/workspace/startup/steps/groups-to-plugins.step.js.map +1 -0
- package/dist/modules/workspace/startup/steps/roles-yaml.step.d.ts +23 -0
- package/dist/modules/workspace/startup/steps/roles-yaml.step.d.ts.map +1 -0
- package/dist/modules/workspace/startup/steps/roles-yaml.step.js +69 -0
- package/dist/modules/workspace/startup/steps/roles-yaml.step.js.map +1 -0
- package/dist/modules/workspace/startup/steps/seed-tree.d.ts +17 -0
- package/dist/modules/workspace/startup/steps/seed-tree.d.ts.map +1 -0
- package/dist/modules/workspace/startup/steps/seed-tree.js +109 -0
- package/dist/modules/workspace/startup/steps/seed-tree.js.map +1 -0
- package/dist/modules/workspace/startup/steps/template-files.step.d.ts +103 -0
- package/dist/modules/workspace/startup/steps/template-files.step.d.ts.map +1 -0
- package/dist/modules/workspace/startup/steps/template-files.step.js +337 -0
- package/dist/modules/workspace/startup/steps/template-files.step.js.map +1 -0
- package/dist/modules/workspace/workspace.service.d.ts +0 -35
- package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.service.js +2 -101
- package/dist/modules/workspace/workspace.service.js.map +1 -1
- package/kb-template/gitignore.template +16 -0
- package/package.json +3 -3
- package/src/core/core-ports.ts +7 -0
- package/src/core/create-core-server.ts +30 -11
- package/src/core/create-core-services.ts +43 -22
- package/src/modules/access/__tests__/roles-admin.service.test.ts +24 -2
- package/src/modules/access/access.routes.ts +3 -0
- package/src/modules/access/render-roles-yaml.ts +65 -0
- package/src/modules/access/roles-admin.service.ts +32 -14
- package/src/modules/settings/__tests__/setup.routes.test.ts +124 -3
- package/src/modules/settings/setup.routes.ts +112 -5
- package/src/modules/workspace/__tests__/workspace.service.test.ts +5 -94
- package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +495 -0
- package/src/modules/workspace/startup/kb-git.ts +94 -0
- package/src/modules/workspace/startup/kb-startup-runner.ts +597 -0
- package/src/modules/workspace/startup/on-server-start.ts +97 -0
- package/src/modules/workspace/startup/steps/__tests__/steps.test.ts +636 -0
- package/src/modules/workspace/{plugins-migration.ts → startup/steps/groups-to-plugins.step.ts} +342 -260
- package/src/modules/workspace/startup/steps/roles-yaml.step.ts +71 -0
- package/src/modules/workspace/startup/steps/seed-tree.ts +115 -0
- package/src/modules/workspace/startup/steps/template-files.step.ts +360 -0
- package/src/modules/workspace/workspace.service.ts +2 -106
- package/src/modules/workspace/__tests__/kb-seed.service.test.ts +0 -512
- package/src/modules/workspace/__tests__/plugins-migration.test.ts +0 -427
- package/src/modules/workspace/kb-seed.interface.ts +0 -36
- 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
|
+
}
|