@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.
- 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/AGENTS.md +11 -5
- package/kb-template/access.md +40 -30
- 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
|
@@ -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
|
|
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
|
-
|
|
479
|
-
|
|
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(
|
|
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
|
+
});
|