amalgm 0.1.246 → 0.1.247
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/README.md +5 -3
- package/lib/cli.js +92 -4
- package/lib/shared-realtime-tunnel.js +82 -19
- package/package.json +1 -1
- package/runtime/scripts/amalgm-mcp/automations/deliveries.js +276 -0
- package/runtime/scripts/amalgm-mcp/automations/rest.js +51 -0
- package/runtime/scripts/amalgm-mcp/automations/scheduler.js +11 -67
- package/runtime/scripts/amalgm-mcp/automations/store.js +67 -20
- package/runtime/scripts/amalgm-mcp/events/ingress.js +69 -215
- package/runtime/scripts/amalgm-mcp/events/processor.js +189 -0
- package/runtime/scripts/amalgm-mcp/lib/email-embeds.js +41 -0
- package/runtime/scripts/amalgm-mcp/lib/email-md.js +107 -1
- package/runtime/scripts/amalgm-mcp/lib/generated/email-render.json +10 -2
- package/runtime/scripts/amalgm-mcp/observer/README.md +8 -6
- package/runtime/scripts/amalgm-mcp/registration/entity-cloud.js +262 -22
- package/runtime/scripts/amalgm-mcp/registration/entity-content.js +86 -0
- package/runtime/scripts/amalgm-mcp/registration/index.js +4 -1
- package/runtime/scripts/amalgm-mcp/registration/service.js +102 -0
- package/runtime/scripts/amalgm-mcp/registration/tree.js +19 -5
- package/runtime/scripts/amalgm-mcp/registry/index.js +16 -1
- package/runtime/scripts/amalgm-mcp/registry/store.js +58 -7
- package/runtime/scripts/amalgm-mcp/server/routes/automations.js +8 -0
- package/runtime/scripts/amalgm-mcp/server/routes/entities.js +56 -0
- package/runtime/scripts/amalgm-mcp/server/routes/workspace.js +14 -0
- package/runtime/scripts/amalgm-mcp/tests/adapters.test.js +1 -1
- package/runtime/scripts/amalgm-mcp/tests/doorbell.matrix.watch.test.js +2 -2
- package/runtime/scripts/amalgm-mcp/tests/email-render-goldens/citations.html +5 -5
- package/runtime/scripts/amalgm-mcp/tests/email-render-goldens/links.html +3 -3
- package/runtime/scripts/amalgm-mcp/tests/email-render-goldens/notification-shell.html +1 -1
- package/runtime/scripts/amalgm-mcp/tests/email-render-goldens/share-invite.html +13 -0
- package/runtime/scripts/amalgm-mcp/tests/email-render-goldens/text.html +1 -1
- package/runtime/scripts/amalgm-mcp/tests/email-render.test.js +17 -1
- package/runtime/scripts/amalgm-mcp/tests/entity-cloud.test.js +189 -0
- package/runtime/scripts/amalgm-mcp/tests/entity-content.test.js +126 -0
- package/runtime/scripts/amalgm-mcp/tests/entity-materialization.test.js +118 -0
- package/runtime/scripts/amalgm-mcp/tests/entity.registry.test.js +126 -11
- package/runtime/scripts/amalgm-mcp/tests/entity.rig.js +20 -6
- package/runtime/scripts/amalgm-mcp/tests/fake-watch.js +8 -0
- package/runtime/scripts/amalgm-mcp/tests/observer.rig.js +8 -1
- package/runtime/scripts/amalgm-mcp/tests/registration.service.test.js +630 -102
- package/runtime/scripts/amalgm-mcp/tests/registration.test.js +45 -10
- package/runtime/scripts/amalgm-mcp/tests/workspace-materialize.test.js +54 -0
- package/runtime/scripts/amalgm-mcp/workspace/materialize.js +292 -0
- package/runtime/scripts/chat-core/contract.js +21 -3
- package/runtime/scripts/chat-core/engine.js +8 -0
- package/runtime/scripts/chat-core/server.js +11 -1
- package/runtime/scripts/chat-core/tests/workspace-preflight.test.js +116 -0
- package/runtime/scripts/chat-core/workspace-preflight.js +89 -0
- package/runtime/scripts/local-gateway.js +0 -1
|
@@ -3,38 +3,72 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* The production-owned vertical slice:
|
|
5
5
|
*
|
|
6
|
-
* amalgm
|
|
6
|
+
* amalgm register [path] → POST /entities/register → registration.register(path)
|
|
7
7
|
* → Watch + Detect + Record
|
|
8
8
|
*
|
|
9
9
|
* The wall already proves detection. What is unproven here is the
|
|
10
10
|
* WIRING — the parts that only exist because production owns the stack:
|
|
11
|
-
* which policies are actually injected, where state lands, that
|
|
12
|
-
*
|
|
13
|
-
* survives a
|
|
14
|
-
*
|
|
11
|
+
* which policies are actually injected, where state lands, that a real
|
|
12
|
+
* doorbell reaches the registry with nobody poking the machinery, that
|
|
13
|
+
* identity survives a genuine close-and-reopen, that the composition
|
|
14
|
+
* root gives back everything it took, and that the endpoint tells a
|
|
15
|
+
* refusal from a fault over a real authenticated socket.
|
|
16
|
+
*
|
|
17
|
+
* Two rules this file holds itself to:
|
|
18
|
+
*
|
|
19
|
+
* NO ASSISTED PASSES. Nothing here calls `scanNow` or `flushIfDirty`
|
|
20
|
+
* to make a rail appear to work. A rail is proven through its own
|
|
21
|
+
* front door or it is not proven — so the timings are production's
|
|
22
|
+
* own (a 200ms settle), and the exams wait for them.
|
|
23
|
+
*
|
|
24
|
+
* NO FAKE TRANSPORT. The endpoint exams go over real HTTP through the
|
|
25
|
+
* real authorizing handler and the real dispatcher. A route proven
|
|
26
|
+
* against a hand-made context object proves the function, not the
|
|
27
|
+
* journey.
|
|
15
28
|
*
|
|
16
29
|
* Everything runs over a temporary AMALGM_DIR, set before config loads,
|
|
17
30
|
* so the singleton this route uses can never touch a real installation.
|
|
18
31
|
*/
|
|
19
32
|
|
|
33
|
+
const crypto = require('crypto');
|
|
34
|
+
const { execFileSync } = require('child_process');
|
|
20
35
|
const fs = require('fs');
|
|
36
|
+
const http = require('http');
|
|
21
37
|
const os = require('os');
|
|
22
38
|
const path = require('path');
|
|
23
39
|
const test = require('node:test');
|
|
24
40
|
const assert = require('node:assert');
|
|
25
41
|
|
|
26
|
-
const tempRoot = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-
|
|
42
|
+
const tempRoot = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-register-')));
|
|
27
43
|
process.env.AMALGM_DIR = path.join(tempRoot, 'home');
|
|
44
|
+
// The runtime reads its token from the environment; the CLI reads the
|
|
45
|
+
// same secret from disk and sends it. Setting it here makes the server
|
|
46
|
+
// below demand authorization exactly as production does.
|
|
47
|
+
process.env.AMALGM_RUNTIME_TOKEN = crypto.randomUUID();
|
|
48
|
+
|
|
49
|
+
const Database = require('better-sqlite3');
|
|
28
50
|
|
|
29
51
|
const { classifyFile } = require('../registration/classify');
|
|
52
|
+
const { isRefusal } = require('../registration/refusal');
|
|
30
53
|
const { ENTITIES_DIR, createRegistrationService, registrationService } = require('../registration/service');
|
|
31
|
-
const {
|
|
54
|
+
const { adapterFor } = require('../adapters');
|
|
55
|
+
const { createRuntimeHttpServer } = require('../server/http-service');
|
|
32
56
|
const { createLocalServiceRouter } = require('../server/local-service-router');
|
|
33
57
|
|
|
34
58
|
const services = [];
|
|
59
|
+
let server;
|
|
60
|
+
let port;
|
|
61
|
+
|
|
62
|
+
test.before(async () => {
|
|
63
|
+
const router = createLocalServiceRouter();
|
|
64
|
+
server = createRuntimeHttpServer((ctx) => router.handle(ctx), { serviceName: 'AmalgmMCP-test' });
|
|
65
|
+
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
66
|
+
port = server.address().port;
|
|
67
|
+
});
|
|
35
68
|
|
|
36
|
-
test.after(() => {
|
|
37
|
-
for (const service of services) service.
|
|
69
|
+
test.after(async () => {
|
|
70
|
+
for (const service of services) service.close();
|
|
71
|
+
if (server) await new Promise((resolve) => server.close(resolve));
|
|
38
72
|
fs.rmSync(tempRoot, { recursive: true, force: true });
|
|
39
73
|
});
|
|
40
74
|
|
|
@@ -51,32 +85,83 @@ function makeGround(name) {
|
|
|
51
85
|
return fs.realpathSync(dir);
|
|
52
86
|
}
|
|
53
87
|
|
|
54
|
-
|
|
88
|
+
/** Exactly the request the CLI makes: JSON body, token in its header. */
|
|
89
|
+
function post(requestPath, body, { token = process.env.AMALGM_RUNTIME_TOKEN } = {}) {
|
|
90
|
+
const payload = JSON.stringify(body);
|
|
91
|
+
return new Promise((resolve, reject) => {
|
|
92
|
+
const req = http.request({
|
|
93
|
+
host: '127.0.0.1',
|
|
94
|
+
port,
|
|
95
|
+
path: requestPath,
|
|
96
|
+
method: 'POST',
|
|
97
|
+
headers: {
|
|
98
|
+
'content-type': 'application/json',
|
|
99
|
+
'content-length': Buffer.byteLength(payload),
|
|
100
|
+
...(token ? { 'x-amalgm-runtime-token': token } : {}),
|
|
101
|
+
},
|
|
102
|
+
}, (res) => {
|
|
103
|
+
let text = '';
|
|
104
|
+
res.setEncoding('utf8');
|
|
105
|
+
res.on('data', (chunk) => { text += chunk; });
|
|
106
|
+
res.on('end', () => {
|
|
107
|
+
let parsed = null;
|
|
108
|
+
try { parsed = JSON.parse(text); } catch { parsed = null; }
|
|
109
|
+
resolve({ status: res.statusCode, payload: parsed });
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
req.on('error', reject);
|
|
113
|
+
req.end(payload);
|
|
114
|
+
});
|
|
115
|
+
}
|
|
55
116
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
117
|
+
/**
|
|
118
|
+
* Make ONE change through the front door and wait for the registry to
|
|
119
|
+
* show it on its own.
|
|
120
|
+
*
|
|
121
|
+
* One change, never repeated, because that is the user: they save once.
|
|
122
|
+
* Nothing here scans, flushes, or repairs; a rail that needs help fails,
|
|
123
|
+
* loudly, at the deadline, which is generous only because the whole wall
|
|
124
|
+
* shares one machine. A converging rail returns the moment it converges.
|
|
125
|
+
*/
|
|
126
|
+
async function arrives(act, seen, what, timeoutMs = 30_000) {
|
|
127
|
+
act();
|
|
128
|
+
const deadline = Date.now() + timeoutMs;
|
|
129
|
+
while (!seen()) {
|
|
130
|
+
if (Date.now() > deadline) assert.fail(`timed out waiting for ${what}`);
|
|
131
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// --- one read, one answer ---------------------------------------------------
|
|
136
|
+
|
|
137
|
+
const fileTruth = adapterFor('file.text').rails.detect.truth;
|
|
61
138
|
|
|
62
|
-
test('
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
139
|
+
test('Detect reports a file\'s type from the same read that fingerprints it', () => {
|
|
140
|
+
const dir = makeGround('one-read');
|
|
141
|
+
const text = path.join(dir, 'notes.md');
|
|
142
|
+
const bytes = path.join(dir, 'blob.bin');
|
|
143
|
+
fs.writeFileSync(text, '# hello\nplain words\n');
|
|
144
|
+
fs.writeFileSync(bytes, Buffer.from([0x89, 0x50, 0x00, 0x4e, 0x47]));
|
|
145
|
+
|
|
146
|
+
const read = fileTruth(text);
|
|
147
|
+
assert.equal(read.truth, 'present');
|
|
148
|
+
assert.ok(read.state.sha256, 'the fingerprint');
|
|
149
|
+
assert.equal(read.state.binary, false, 'and the kind, from the same bytes');
|
|
150
|
+
assert.equal(fileTruth(bytes).state.binary, true);
|
|
66
151
|
});
|
|
67
152
|
|
|
68
|
-
test('
|
|
69
|
-
// The
|
|
70
|
-
//
|
|
71
|
-
|
|
153
|
+
test('a NUL anywhere is seen, not only in an opening window', () => {
|
|
154
|
+
// The whole file is streamed for the hash, so the kind is decided over
|
|
155
|
+
// the whole file too. A sniff window would have called this text and
|
|
156
|
+
// disagreed with the referee, which reads every byte.
|
|
157
|
+
const file = path.join(makeGround('late-nul'), 'long.bin');
|
|
158
|
+
fs.writeFileSync(file, Buffer.concat([Buffer.alloc(200_000, 0x61), Buffer.from([0])]));
|
|
159
|
+
assert.equal(fileTruth(file).state.binary, true);
|
|
72
160
|
});
|
|
73
161
|
|
|
74
|
-
test('
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
const file = path.join(makeGround('classify-late'), 'long.txt');
|
|
78
|
-
fs.writeFileSync(file, Buffer.concat([Buffer.alloc(9000, 0x61), Buffer.from([0])]));
|
|
79
|
-
assert.equal(classifyFile(file), 'file.text');
|
|
162
|
+
test('the type policy is total: it names a type for either report, and cannot fail', () => {
|
|
163
|
+
assert.equal(classifyFile({ binary: false }), 'file.text');
|
|
164
|
+
assert.equal(classifyFile({ binary: true }), 'file.binary');
|
|
80
165
|
});
|
|
81
166
|
|
|
82
167
|
// --- composition: what production actually builds --------------------------
|
|
@@ -94,14 +179,130 @@ test('the singleton lives under the Amalgm home, and is one', () => {
|
|
|
94
179
|
services.push(registrationService());
|
|
95
180
|
});
|
|
96
181
|
|
|
97
|
-
test('
|
|
182
|
+
test('user-home bootstrap creates only declared roots and registers them through the shared registration boundary', async () => {
|
|
183
|
+
const home = path.join(tempRoot, 'user-home');
|
|
184
|
+
const service = createRegistrationService({
|
|
185
|
+
dir: path.join(tempRoot, 'state-user-home'),
|
|
186
|
+
homeDir: home,
|
|
187
|
+
});
|
|
188
|
+
services.push(service);
|
|
189
|
+
|
|
190
|
+
const bootstrapped = await service.bootstrapUserHome();
|
|
191
|
+
assert.equal(bootstrapped.homeDir, home);
|
|
192
|
+
assert.deepEqual(bootstrapped.roots.map((entry) => entry.key), [
|
|
193
|
+
'agents', 'apps', 'automations', 'toolbox', 'workspaces', 'system',
|
|
194
|
+
]);
|
|
195
|
+
for (const entry of bootstrapped.roots) {
|
|
196
|
+
assert.equal(entry.record.parentUUID, null, `${entry.key} is its own registered tree`);
|
|
197
|
+
assert.ok(entry.record.uuid, `${entry.key} received a UUID through registration`);
|
|
198
|
+
assert.equal(service.registration.tree(entry.path).root().uuid, entry.record.uuid);
|
|
199
|
+
}
|
|
200
|
+
assert.deepEqual(
|
|
201
|
+
bootstrapped.exactManaged.map((entry) => [entry.key, entry.scope]),
|
|
202
|
+
[
|
|
203
|
+
['agents-root', 'exact'], ['apps-root', 'exact'], ['automations-root', 'exact'],
|
|
204
|
+
['toolbox-root', 'exact'], ['workspaces-root', 'exact'], ['system-root', 'exact'],
|
|
205
|
+
['home-manifest', 'exact'], ['defaults', 'exact'], ['policy', 'exact'],
|
|
206
|
+
],
|
|
207
|
+
);
|
|
208
|
+
for (const entry of bootstrapped.exactManaged) {
|
|
209
|
+
assert.ok(entry.record.uuid, `${entry.key} is registered before cloud governance`);
|
|
210
|
+
const root = bootstrapped.roots.find((candidate) => candidate.key === entry.rootKey);
|
|
211
|
+
if (entry.relativePath === entry.rootKey) {
|
|
212
|
+
assert.equal(entry.record.uuid, root.record.uuid,
|
|
213
|
+
'a managed root is marked by its own exact entity identity');
|
|
214
|
+
} else {
|
|
215
|
+
assert.notEqual(entry.record.uuid, root.record.uuid,
|
|
216
|
+
'a managed child has its own exact entity identity, never inherited management');
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const repeat = await service.bootstrapUserHome();
|
|
221
|
+
assert.deepEqual(
|
|
222
|
+
repeat.roots.map((entry) => entry.record.uuid),
|
|
223
|
+
bootstrapped.roots.map((entry) => entry.record.uuid),
|
|
224
|
+
'bootstrap is idempotent: existing declared ground is covered by the same trees',
|
|
225
|
+
);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test('a fresh machine binds existing user-home UUIDs before it starts watching', async () => {
|
|
229
|
+
const first = createRegistrationService({
|
|
230
|
+
dir: path.join(tempRoot, 'state-user-home-origin'),
|
|
231
|
+
homeDir: path.join(tempRoot, 'user-home-origin'),
|
|
232
|
+
});
|
|
233
|
+
services.push(first);
|
|
234
|
+
const origin = await first.bootstrapUserHome();
|
|
235
|
+
|
|
236
|
+
const recipient = createRegistrationService({
|
|
237
|
+
dir: path.join(tempRoot, 'state-user-home-recipient'),
|
|
238
|
+
homeDir: path.join(tempRoot, 'user-home-recipient'),
|
|
239
|
+
});
|
|
240
|
+
services.push(recipient);
|
|
241
|
+
const restored = await recipient.bootstrapUserHome({ identitySnapshot: origin.cloudSnapshot });
|
|
242
|
+
|
|
243
|
+
assert.deepEqual(
|
|
244
|
+
restored.roots.map((entry) => entry.record.uuid),
|
|
245
|
+
origin.roots.map((entry) => entry.record.uuid),
|
|
246
|
+
'the cloud graph supplies root UUIDs instead of a new machine minting replacements',
|
|
247
|
+
);
|
|
248
|
+
assert.deepEqual(
|
|
249
|
+
restored.exactManaged.map((entry) => entry.record.uuid),
|
|
250
|
+
origin.exactManaged.map((entry) => entry.record.uuid),
|
|
251
|
+
'exact platform-managed targets retain their own identities too',
|
|
252
|
+
);
|
|
253
|
+
assert.equal(recipient.observer.status().every((root) => root.watching && !root.degraded), true,
|
|
254
|
+
'only the bound identities enter Watch');
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test('a signed-in register call writes every entity delivery intent in identity.db before Watch begins', async () => {
|
|
258
|
+
const service = createRegistrationService({
|
|
259
|
+
dir: path.join(tempRoot, 'state-cloud-register'),
|
|
260
|
+
homeDir: path.join(tempRoot, 'cloud-user-home'),
|
|
261
|
+
cloudUserId: 'user-cloud-register',
|
|
262
|
+
cloudDeviceId: 'computer-cloud-register',
|
|
263
|
+
});
|
|
264
|
+
const home = await service.bootstrapUserHome();
|
|
265
|
+
const bootstrap = service.entityCloud.bootstrap(service.registration.registry);
|
|
266
|
+
service.entityCloud.install({
|
|
267
|
+
registry: service.registration.registry,
|
|
268
|
+
resourceId: bootstrap.resourceId,
|
|
269
|
+
authorityEpoch: 1,
|
|
270
|
+
headVersion: 0,
|
|
271
|
+
snapshotBase64: bootstrap.snapshotBase64,
|
|
272
|
+
snapshotChecksum: bootstrap.snapshotChecksum,
|
|
273
|
+
});
|
|
274
|
+
const ground = makeGround('cloud-register');
|
|
275
|
+
fs.writeFileSync(path.join(ground, 'one.md'), 'one');
|
|
276
|
+
|
|
277
|
+
try {
|
|
278
|
+
const registered = await service.register(ground);
|
|
279
|
+
const db = new Database(path.join(service.dir, 'identity.db'), { readonly: true });
|
|
280
|
+
const rows = db.prepare('SELECT operation_json, mutation_state FROM entity_cloud_mutations ORDER BY sequence').all();
|
|
281
|
+
db.close();
|
|
282
|
+
const emitted = rows.map((row) => JSON.parse(row.operation_json).record.uuid);
|
|
283
|
+
const childUuid = service.registration.tree(ground).registry.activeAt(registered.record.uuid, 'one.md').uuid;
|
|
284
|
+
assert.ok(rows.length >= 2, 'the root and its child each have a durable cloud operation');
|
|
285
|
+
assert.ok(emitted.includes(registered.record.uuid));
|
|
286
|
+
assert.ok(emitted.includes(childUuid));
|
|
287
|
+
assert.equal(rows.every((row) => row.mutation_state === 'pending'), true);
|
|
288
|
+
const childHead = service.registration.tree(ground).registry.syncRecord(childUuid).payloadVersion;
|
|
289
|
+
const captured = JSON.parse(fs.readFileSync(path.join(service.dir, 'content', 'manifests', `${childHead}.json`), 'utf8'));
|
|
290
|
+
assert.equal(captured.bytes, 3, 'Register captures the child bytes before it emits their cloud head');
|
|
291
|
+
assert.equal(captured.chunks.length, 1);
|
|
292
|
+
assert.ok(home.roots.length, 'the bootstrap graph existed only in the initial entity snapshot');
|
|
293
|
+
} finally {
|
|
294
|
+
service.close();
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test('register registers the named ground and every file beneath it', async () => {
|
|
98
299
|
const service = makeService('add');
|
|
99
300
|
const ground = makeGround('add');
|
|
100
301
|
fs.writeFileSync(path.join(ground, 'a.txt'), 'alpha');
|
|
101
302
|
fs.mkdirSync(path.join(ground, 'nested'));
|
|
102
303
|
fs.writeFileSync(path.join(ground, 'nested', 'b.bin'), Buffer.from([0, 1, 2]));
|
|
103
304
|
|
|
104
|
-
const { rootPath, record } = service.
|
|
305
|
+
const { rootPath, record } = await service.register(ground);
|
|
105
306
|
assert.equal(rootPath, ground);
|
|
106
307
|
assert.ok(record.uuid, 'the tree has a permanent identity');
|
|
107
308
|
assert.equal(record.parentUUID, null, 'a tree is a parentless record');
|
|
@@ -116,11 +317,25 @@ test('add registers the named ground and every file beneath it', () => {
|
|
|
116
317
|
assert.equal(nested[0].type, 'file.binary', 'and it distinguishes bytes from text');
|
|
117
318
|
});
|
|
118
319
|
|
|
119
|
-
test('
|
|
320
|
+
test('registerMany registers every selected file or folder through the one registration boundary', async () => {
|
|
321
|
+
const service = makeService('add-many');
|
|
322
|
+
const folder = makeGround('add-many-folder');
|
|
323
|
+
const file = path.join(makeGround('add-many-file-parent'), 'solo.md');
|
|
324
|
+
fs.writeFileSync(path.join(folder, 'inside.md'), 'inside');
|
|
325
|
+
fs.writeFileSync(file, 'solo');
|
|
326
|
+
|
|
327
|
+
const results = await service.registerMany([folder, file]);
|
|
328
|
+
assert.deepEqual(results.map((entry) => entry.rootPath), [folder, file]);
|
|
329
|
+
assert.deepEqual(results.map((entry) => entry.record.parentUUID), [null, null]);
|
|
330
|
+
assert.equal(service.observer.status().filter((root) => root.watching && !root.degraded).length >= 2, true,
|
|
331
|
+
'each selected root passed through Register → Watch');
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
test('watching is armed: register begins the Watch rail, not just the walk', async () => {
|
|
120
335
|
const service = makeService('watching');
|
|
121
336
|
const ground = makeGround('watching');
|
|
122
337
|
fs.writeFileSync(path.join(ground, 'a.txt'), 'alpha');
|
|
123
|
-
service.
|
|
338
|
+
await service.register(ground);
|
|
124
339
|
|
|
125
340
|
const [root] = service.observer.status();
|
|
126
341
|
assert.equal(root.path, ground);
|
|
@@ -128,31 +343,322 @@ test('watching is armed: add begins the Watch rail, not just the walk', () => {
|
|
|
128
343
|
assert.equal(root.degraded, false);
|
|
129
344
|
});
|
|
130
345
|
|
|
131
|
-
test('
|
|
132
|
-
const service = makeService('
|
|
133
|
-
const
|
|
346
|
+
test('adding a repository registers its allowed tree and saves its initial Repocard baseline', async () => {
|
|
347
|
+
const service = makeService('repo-card');
|
|
348
|
+
const repo = makeGround('repo-card');
|
|
349
|
+
execFileSync('git', ['init', '-q'], { cwd: repo });
|
|
350
|
+
fs.writeFileSync(path.join(repo, 'readme.md'), '# initial checkpoint\n');
|
|
351
|
+
fs.mkdirSync(path.join(repo, 'src'));
|
|
352
|
+
fs.writeFileSync(path.join(repo, 'src', 'blob.bin'), Buffer.from([0, 1, 2]));
|
|
353
|
+
fs.mkdirSync(path.join(repo, 'node_modules'));
|
|
354
|
+
fs.writeFileSync(path.join(repo, 'node_modules', 'ignored.js'), 'never register me');
|
|
355
|
+
|
|
356
|
+
const { record: registered } = await service.register(repo);
|
|
357
|
+
const tree = service.registration.tree(repo);
|
|
358
|
+
assert.equal(registered.type, 'repo.git');
|
|
359
|
+
const initialState = service.registration.repoState(registered.uuid);
|
|
360
|
+
assert.ok(initialState?.stateId, 'registration saved the initial Card + Checkpoint');
|
|
361
|
+
assert.ok(initialState.checkpoint.untracked.some((file) => file.path === 'readme.md'),
|
|
362
|
+
'the persisted baseline contains the actual working checkpoint, not only its hash');
|
|
363
|
+
assert.equal(tree.root().version, registered.version,
|
|
364
|
+
'add returned only after the repo entity pointed at its saved baseline');
|
|
365
|
+
const readme = tree.registry.activeAt(registered.uuid, 'readme.md');
|
|
366
|
+
const src = tree.registry.activeAt(registered.uuid, 'src');
|
|
367
|
+
assert.equal(readme.type, 'file.text');
|
|
368
|
+
assert.equal(src.type, 'folder');
|
|
369
|
+
assert.equal(tree.registry.activeAt(src.uuid, 'blob.bin').type, 'file.binary');
|
|
370
|
+
assert.equal(tree.registry.activeAt(registered.uuid, 'node_modules'), null,
|
|
371
|
+
'the same enrollment policy excludes dependency trees inside repos');
|
|
372
|
+
const initial = tree.root();
|
|
373
|
+
|
|
374
|
+
// Only a settled new checkpoint may reconcile child ground. The raw
|
|
375
|
+
// filesystem event itself never writes a repo or child record.
|
|
376
|
+
await arrives(
|
|
377
|
+
() => fs.writeFileSync(path.join(repo, 'later.md'), 'untracked checkpoint change\n'),
|
|
378
|
+
() => tree.root().version !== initial.version
|
|
379
|
+
&& Boolean(tree.registry.activeAt(registered.uuid, 'later.md')),
|
|
380
|
+
'the worktree change to become one new repo state and child entity',
|
|
381
|
+
);
|
|
382
|
+
const after = tree.root();
|
|
383
|
+
assert.equal(after.uuid, initial.uuid, 'the codebase remains one entity');
|
|
384
|
+
assert.equal(after.type, 'repo.git');
|
|
385
|
+
const later = tree.registry.activeAt(registered.uuid, 'later.md');
|
|
386
|
+
assert.equal(later.type, 'file.text');
|
|
387
|
+
|
|
388
|
+
await arrives(
|
|
389
|
+
() => fs.renameSync(path.join(repo, 'later.md'), path.join(repo, 'moved.md')),
|
|
390
|
+
() => tree.registry.activeAt(registered.uuid, 'moved.md')?.uuid === later.uuid,
|
|
391
|
+
'the settled repo diff to preserve a moved child UUID',
|
|
392
|
+
);
|
|
393
|
+
|
|
394
|
+
// Repocard's own wall proves that this is `card.changed`; this composition
|
|
395
|
+
// exam proves the semantic result reaches the repo UUID through the real
|
|
396
|
+
// watcher after Git turns the checkpoint into committed history.
|
|
397
|
+
await arrives(
|
|
398
|
+
() => {
|
|
399
|
+
execFileSync('git', ['add', 'moved.md'], { cwd: repo });
|
|
400
|
+
execFileSync('git', [
|
|
401
|
+
'-c', 'user.name=Amalgm Test',
|
|
402
|
+
'-c', 'user.email=amalgm-test@example.invalid',
|
|
403
|
+
'commit', '-qm', 'record checkpoint',
|
|
404
|
+
], { cwd: repo });
|
|
405
|
+
},
|
|
406
|
+
() => tree.root().version !== after.version,
|
|
407
|
+
'the committed Card state to be recorded on the same repo entity',
|
|
408
|
+
);
|
|
409
|
+
assert.equal(tree.root().uuid, after.uuid);
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
test('an edge-reached repository gets the same complete registration as a directly added repo', async () => {
|
|
413
|
+
const service = makeService('edge-repo-card');
|
|
414
|
+
const holder = makeGround('edge-repo-holder');
|
|
415
|
+
const target = makeGround('edge-repo-target');
|
|
416
|
+
execFileSync('git', ['init', '-q'], { cwd: holder });
|
|
417
|
+
execFileSync('git', ['init', '-q'], { cwd: target });
|
|
418
|
+
fs.mkdirSync(path.join(target, 'src'));
|
|
419
|
+
fs.writeFileSync(path.join(target, 'src', 'entry.js'), 'export const ready = true;\n');
|
|
420
|
+
fs.symlinkSync(target, path.join(holder, 'linked-repo'));
|
|
421
|
+
|
|
422
|
+
await service.register(holder);
|
|
423
|
+
|
|
424
|
+
const targetTree = service.registration.tree(target);
|
|
425
|
+
assert.ok(targetTree, 'the reachable repo is a registered tree, not merely an observer root');
|
|
426
|
+
const targetRoot = targetTree.root();
|
|
427
|
+
assert.equal(targetRoot.type, 'repo.git');
|
|
428
|
+
assert.ok(service.registration.repoState(targetRoot.uuid)?.stateId,
|
|
429
|
+
'the repo has its Card + Checkpoint baseline before add returns');
|
|
430
|
+
|
|
431
|
+
const src = targetTree.registry.activeAt(targetRoot.uuid, 'src');
|
|
432
|
+
assert.equal(src?.type, 'folder', 'the repo worktree shape was structurally registered');
|
|
433
|
+
assert.equal(targetTree.registry.activeAt(src.uuid, 'entry.js')?.type, 'file.text');
|
|
434
|
+
|
|
435
|
+
const observerRoot = service.observer.status().find((row) => row.path === target);
|
|
436
|
+
assert.equal(observerRoot?.watching, true, 'its Watch rail is live too');
|
|
437
|
+
assert.equal(observerRoot?.degraded, false);
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
test('a nested repository has its own baseline and watcher, while its tree remains registered', async () => {
|
|
441
|
+
const service = makeService('nested-repo-card');
|
|
442
|
+
const repo = makeGround('nested-repo-card');
|
|
443
|
+
execFileSync('git', ['init', '-q'], { cwd: repo });
|
|
444
|
+
const nested = path.join(repo, 'vendor', 'library');
|
|
445
|
+
fs.mkdirSync(path.join(nested, 'src'), { recursive: true });
|
|
446
|
+
execFileSync('git', ['init', '-q'], { cwd: nested });
|
|
447
|
+
fs.writeFileSync(path.join(nested, 'src', 'entry.js'), 'export const answer = 42;\n');
|
|
448
|
+
|
|
449
|
+
const { record: root } = await service.register(repo);
|
|
450
|
+
const tree = service.registration.tree(repo);
|
|
451
|
+
const vendor = tree.registry.activeAt(root.uuid, 'vendor');
|
|
452
|
+
const library = tree.registry.activeAt(vendor.uuid, 'library');
|
|
453
|
+
assert.equal(library.type, 'repo.git');
|
|
454
|
+
const baseline = service.registration.repoState(library.uuid);
|
|
455
|
+
assert.ok(baseline?.stateId,
|
|
456
|
+
'nested repo registration captured its own Card + Checkpoint');
|
|
457
|
+
const src = tree.registry.activeAt(library.uuid, 'src');
|
|
458
|
+
assert.equal(tree.registry.activeAt(src.uuid, 'entry.js').type, 'file.text');
|
|
459
|
+
const identity = new Database(path.join(service.dir, 'identity.db'), { readonly: true });
|
|
460
|
+
const nestedRow = identity.prepare('SELECT payload_version FROM entities WHERE uuid = ?').get(library.uuid);
|
|
461
|
+
identity.close();
|
|
462
|
+
assert.equal(nestedRow.payload_version, baseline.stateId,
|
|
463
|
+
'the nested repo entity points at the baseline it just saved');
|
|
464
|
+
assert.equal(
|
|
465
|
+
service.observer.status().some((row) => row.path === nested && row.watching && !row.degraded),
|
|
466
|
+
true,
|
|
467
|
+
'the nested repo has a live semantic watcher of its own',
|
|
468
|
+
);
|
|
469
|
+
|
|
470
|
+
await arrives(
|
|
471
|
+
() => fs.writeFileSync(path.join(nested, 'src', 'live.js'), 'export const live = true;\n'),
|
|
472
|
+
() => Boolean(tree.registry.activeAt(src.uuid, 'live.js'))
|
|
473
|
+
&& service.registration.repoState(library.uuid).stateId !== baseline.stateId,
|
|
474
|
+
'a nested repo change to record the new child under its own repo boundary',
|
|
475
|
+
);
|
|
476
|
+
assert.equal(tree.registry.activeAt(src.uuid, 'live.js').type, 'file.text');
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
test('a reopened repo watcher resumes from the saved Card + Checkpoint baseline', async () => {
|
|
480
|
+
const service = makeService('repo-reopen');
|
|
481
|
+
const repo = makeGround('repo-reopen');
|
|
482
|
+
execFileSync('git', ['init', '-q'], { cwd: repo });
|
|
483
|
+
fs.writeFileSync(path.join(repo, 'readme.md'), '# stable\n');
|
|
484
|
+
|
|
485
|
+
const { record: first } = await service.register(repo);
|
|
486
|
+
const before = service.registration.repoState(first.uuid);
|
|
487
|
+
service.close();
|
|
488
|
+
|
|
489
|
+
const reopened = createRegistrationService({ dir: service.dir });
|
|
490
|
+
services.push(reopened);
|
|
491
|
+
const { record: again } = await reopened.register(repo);
|
|
492
|
+
assert.equal(again.uuid, first.uuid, 'the repo identity is durable');
|
|
493
|
+
assert.equal(reopened.registration.repoState(again.uuid).stateId, before.stateId,
|
|
494
|
+
'Watch resumed from the registered Card + Checkpoint, not a new birth capture');
|
|
495
|
+
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
496
|
+
assert.equal(reopened.registration.tree(repo).root().version, again.version,
|
|
497
|
+
'an unchanged repo remains silent after reopen');
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
test('register registers every entity before it starts Watch', async () => {
|
|
501
|
+
const service = makeService('ordered');
|
|
502
|
+
const ground = makeGround('ordered');
|
|
503
|
+
fs.mkdirSync(path.join(ground, 'nested'));
|
|
504
|
+
fs.writeFileSync(path.join(ground, 'nested', 'entry.txt'), 'alpha');
|
|
505
|
+
|
|
506
|
+
const start = service.observer.start.bind(service.observer);
|
|
507
|
+
let checked = false;
|
|
508
|
+
service.observer.start = (rootId) => {
|
|
509
|
+
// Register is durable identity, not the later-created Record lens.
|
|
510
|
+
// The observer's first census now happens before evidence reconciliation
|
|
511
|
+
// so a pre-registered nested repo is never mistaken for absent ground.
|
|
512
|
+
const identity = new Database(path.join(service.dir, 'identity.db'), { readonly: true });
|
|
513
|
+
const root = identity.prepare(
|
|
514
|
+
"SELECT uuid FROM entities WHERE parent_uuid IS NULL AND status = 'active'",
|
|
515
|
+
).get();
|
|
516
|
+
const nested = identity.prepare(
|
|
517
|
+
"SELECT uuid FROM entities WHERE parent_uuid = ? AND name = 'nested' AND status = 'active'",
|
|
518
|
+
).get(root.uuid);
|
|
519
|
+
const file = identity.prepare(
|
|
520
|
+
"SELECT uuid FROM entities WHERE parent_uuid = ? AND name = 'entry.txt' AND status = 'active'",
|
|
521
|
+
).get(nested.uuid);
|
|
522
|
+
identity.close();
|
|
523
|
+
assert.ok(nested, 'the folder already has a durable identity');
|
|
524
|
+
assert.ok(file, 'so does the file within it');
|
|
525
|
+
assert.equal(service.observer.status()[0].watching, false,
|
|
526
|
+
'the observer has not begun watching while registration is in progress');
|
|
527
|
+
checked = true;
|
|
528
|
+
return start(rootId);
|
|
529
|
+
};
|
|
530
|
+
|
|
531
|
+
const { rootPath } = await service.register(ground);
|
|
532
|
+
assert.equal(rootPath, ground);
|
|
533
|
+
assert.equal(checked, true, 'Watch begins only after the structural walk commits');
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
test('register never reserves or mutates a user filename for watching', async () => {
|
|
537
|
+
const service = makeService('ordinary-name');
|
|
538
|
+
const ground = makeGround('ordinary-name');
|
|
539
|
+
const name = '.amalgm-doorbell';
|
|
540
|
+
const file = path.join(ground, name);
|
|
541
|
+
fs.writeFileSync(file, 'this is user ground');
|
|
542
|
+
|
|
543
|
+
const { record } = await service.register(ground);
|
|
544
|
+
const tree = service.registration.tree(ground);
|
|
545
|
+
assert.equal(fs.readFileSync(file, 'utf8'), 'this is user ground');
|
|
546
|
+
assert.ok(tree.registry.activeAt(record.uuid, name),
|
|
547
|
+
'it remains an ordinary enrolled file, not Amalgm control state');
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
// --- the rail, through its own front door ----------------------------------
|
|
551
|
+
|
|
552
|
+
test('a real doorbell reaches the registry with nobody poking the machinery', async () => {
|
|
553
|
+
const service = makeService('doorbell');
|
|
554
|
+
const ground = makeGround('doorbell');
|
|
134
555
|
fs.writeFileSync(path.join(ground, 'a.txt'), 'alpha');
|
|
135
|
-
const { record } = service.
|
|
556
|
+
const { record } = await service.register(ground);
|
|
557
|
+
const tree = service.registration.tree(ground);
|
|
558
|
+
|
|
559
|
+
// Ground that appears after registration. From here on NOTHING in this
|
|
560
|
+
// test touches the observer or the boundary: the watcher notices, the
|
|
561
|
+
// scan concludes, the boundary records. Cut the emit wire in
|
|
562
|
+
// service.js, or never start the observer, and this never converges.
|
|
563
|
+
await arrives(
|
|
564
|
+
() => fs.writeFileSync(path.join(ground, 'later.md'), 'arrived after'),
|
|
565
|
+
() => tree.registry.children(record.uuid).some((row) => row.name === 'later.md'),
|
|
566
|
+
'the new file to reach the registry on its own',
|
|
567
|
+
);
|
|
568
|
+
|
|
569
|
+
const arrived = tree.registry.children(record.uuid).find((row) => row.name === 'later.md');
|
|
570
|
+
assert.equal(arrived.type, 'file.text', 'typed by the read that detected it');
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
test('a later change reaches the registry as a new version on the same UUID', async () => {
|
|
574
|
+
const service = makeService('change');
|
|
575
|
+
const ground = makeGround('change');
|
|
576
|
+
const file = path.join(ground, 'a.txt');
|
|
577
|
+
fs.writeFileSync(file, 'alpha');
|
|
578
|
+
const { record } = await service.register(ground);
|
|
136
579
|
const tree = service.registration.tree(ground);
|
|
580
|
+
const before = tree.registry.children(record.uuid).find((row) => row.name === 'a.txt');
|
|
137
581
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
582
|
+
await arrives(
|
|
583
|
+
() => fs.writeFileSync(file, 'beta — different bytes entirely'),
|
|
584
|
+
() => tree.registry.children(record.uuid)
|
|
585
|
+
.find((row) => row.name === 'a.txt').version !== before.version,
|
|
586
|
+
'the edit to reach the registry on its own',
|
|
587
|
+
);
|
|
143
588
|
|
|
144
|
-
const
|
|
145
|
-
assert.
|
|
589
|
+
const after = tree.registry.children(record.uuid).find((row) => row.name === 'a.txt');
|
|
590
|
+
assert.equal(after.uuid, before.uuid, 'the same thing, changed — never a new identity');
|
|
146
591
|
});
|
|
147
592
|
|
|
148
|
-
test('
|
|
593
|
+
test('bytes replacing text re-type the same UUID, through the same one doorbell', async () => {
|
|
594
|
+
const service = makeService('retype');
|
|
595
|
+
const ground = makeGround('retype');
|
|
596
|
+
const file = path.join(ground, 'a.txt');
|
|
597
|
+
fs.writeFileSync(file, 'plain words');
|
|
598
|
+
const { record } = await service.register(ground);
|
|
599
|
+
const tree = service.registration.tree(ground);
|
|
600
|
+
const born = tree.registry.children(record.uuid).find((row) => row.name === 'a.txt');
|
|
601
|
+
assert.equal(born.type, 'file.text');
|
|
602
|
+
|
|
603
|
+
await arrives(
|
|
604
|
+
() => fs.writeFileSync(file, Buffer.from([0x89, 0x50, 0x00, 0x4e, 0x47])),
|
|
605
|
+
() => tree.registry.children(record.uuid)
|
|
606
|
+
.find((row) => row.name === 'a.txt').type === 'file.binary',
|
|
607
|
+
'the new bytes to re-type the record on their own',
|
|
608
|
+
);
|
|
609
|
+
const after = tree.registry.children(record.uuid).find((row) => row.name === 'a.txt');
|
|
610
|
+
assert.equal(after.uuid, born.uuid, 'the same thing, now bytes — never a new identity');
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
// --- lifetime ---------------------------------------------------------------
|
|
614
|
+
|
|
615
|
+
test('close() is silent: no queued pass runs after the databases shut', async () => {
|
|
616
|
+
// A shutdown signal can land between a doorbell and the pass it
|
|
617
|
+
// scheduled. Every stage that owns a pending pass — the boundary AND
|
|
618
|
+
// each of its lenses — must be stopped before the handles close, or a
|
|
619
|
+
// shutdown that went exactly right complains about itself.
|
|
620
|
+
const service = createRegistrationService({ dir: path.join(tempRoot, 'state-quiet') });
|
|
621
|
+
const ground = makeGround('quiet');
|
|
622
|
+
fs.writeFileSync(path.join(ground, 'a.txt'), 'alpha');
|
|
623
|
+
await service.register(ground);
|
|
624
|
+
|
|
625
|
+
const warnings = [];
|
|
626
|
+
const realWarn = console.warn;
|
|
627
|
+
console.warn = (...args) => warnings.push(args.join(' '));
|
|
628
|
+
try {
|
|
629
|
+
const [root] = service.observer.listRoots();
|
|
630
|
+
service.registration.onEvent({ type: 'file.changed', rootId: root.rootId, path: ground });
|
|
631
|
+
service.close();
|
|
632
|
+
// Drain the immediates the doorbell scheduled, and then some.
|
|
633
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
634
|
+
} finally {
|
|
635
|
+
console.warn = realWarn;
|
|
636
|
+
}
|
|
637
|
+
assert.deepEqual(warnings, [], 'a clean shutdown says nothing');
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
test('close() gives back everything the root took, and twice is not an error', async () => {
|
|
641
|
+
const service = createRegistrationService({ dir: path.join(tempRoot, 'state-close') });
|
|
642
|
+
const ground = makeGround('close');
|
|
643
|
+
fs.writeFileSync(path.join(ground, 'a.txt'), 'alpha');
|
|
644
|
+
await service.register(ground);
|
|
645
|
+
|
|
646
|
+
service.close();
|
|
647
|
+
service.close(); // shutdown is reached from more than one direction
|
|
648
|
+
|
|
649
|
+
// A closed handle refuses to answer — the honest proof that the
|
|
650
|
+
// databases are shut, not merely unwatched.
|
|
651
|
+
assert.throws(() => service.observer.listRoots(), /database connection is not open/i);
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
test('identity survives a real close-and-reopen over the same directory', async () => {
|
|
149
655
|
const dir = path.join(tempRoot, 'state-restart');
|
|
150
656
|
const ground = makeGround('restart');
|
|
151
657
|
fs.writeFileSync(path.join(ground, 'a.txt'), 'alpha');
|
|
152
658
|
|
|
153
659
|
const first = createRegistrationService({ dir });
|
|
154
|
-
const before = first.
|
|
155
|
-
first.
|
|
660
|
+
const before = (await first.register(ground)).record.uuid;
|
|
661
|
+
first.close(); // every handle released: the next stack is genuinely alone
|
|
156
662
|
|
|
157
663
|
const second = createRegistrationService({ dir });
|
|
158
664
|
services.push(second);
|
|
@@ -162,95 +668,117 @@ test('identity survives a restart over the same directory', () => {
|
|
|
162
668
|
|
|
163
669
|
// --- the policies are the real ones ---------------------------------------
|
|
164
670
|
|
|
165
|
-
test("amalgm's enrollment policy is in force, and refuses loudly", () => {
|
|
671
|
+
test("amalgm's enrollment policy is in force, and refuses loudly", async () => {
|
|
166
672
|
const service = makeService('policy');
|
|
167
673
|
const excluded = path.join(makeGround('policy'), 'node_modules');
|
|
168
674
|
fs.mkdirSync(excluded);
|
|
169
|
-
assert.
|
|
675
|
+
await assert.rejects(
|
|
676
|
+
() => service.register(excluded),
|
|
677
|
+
(error) => isRefusal(error) && /enrollment policy excludes it/.test(error.message),
|
|
678
|
+
'a refusal, named as one',
|
|
679
|
+
);
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
test('ground that is not there is a refusal, not a fault', async () => {
|
|
683
|
+
const service = makeService('missing');
|
|
684
|
+
await assert.rejects(
|
|
685
|
+
() => service.register(path.join(tempRoot, 'no-such-ground')),
|
|
686
|
+
(error) => isRefusal(error) && /no ground at that address/.test(error.message),
|
|
687
|
+
);
|
|
170
688
|
});
|
|
171
689
|
|
|
172
|
-
test('
|
|
690
|
+
test('a refusal is what the boundary RAISED, never what an error says', () => {
|
|
691
|
+
// The whole point of naming refusals: an unlucky message must not
|
|
692
|
+
// promote a fault into "your path is bad".
|
|
693
|
+
assert.equal(isRefusal(new Error('refusing to add /x: enrollment policy excludes it')), false);
|
|
694
|
+
assert.equal(isRefusal(null), false);
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
test('adding ground already inside a registered tree returns that tree', async () => {
|
|
173
698
|
const service = makeService('covered');
|
|
174
699
|
const ground = makeGround('covered');
|
|
175
700
|
fs.mkdirSync(path.join(ground, 'inner'));
|
|
176
701
|
fs.writeFileSync(path.join(ground, 'inner', 'a.txt'), 'alpha');
|
|
177
702
|
|
|
178
|
-
const tree = service.
|
|
179
|
-
const inner = service.
|
|
703
|
+
const tree = await service.register(ground);
|
|
704
|
+
const inner = await service.register(path.join(ground, 'inner'));
|
|
180
705
|
assert.equal(inner.rootPath, ground, 'one owner per path — no second tree is born');
|
|
181
706
|
assert.equal(inner.record.uuid, tree.record.uuid);
|
|
182
707
|
});
|
|
183
708
|
|
|
184
|
-
// --- the endpoint
|
|
709
|
+
// --- the endpoint, over real authenticated HTTP ----------------------------
|
|
185
710
|
|
|
186
|
-
|
|
187
|
-
const
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
method,
|
|
192
|
-
readJsonBody: async () => body,
|
|
193
|
-
sendJson: (status, payload) => Object.assign(sent, { status, payload }),
|
|
194
|
-
},
|
|
195
|
-
sent,
|
|
196
|
-
};
|
|
197
|
-
}
|
|
711
|
+
test('an unauthorized request never reaches the boundary', async () => {
|
|
712
|
+
const ground = makeGround('unauthorized');
|
|
713
|
+
const answer = await post('/entities/register', { path: ground }, { token: null });
|
|
714
|
+
assert.equal(answer.status, 401);
|
|
715
|
+
});
|
|
198
716
|
|
|
199
|
-
test('POST /entities/
|
|
717
|
+
test('POST /entities/register registers and answers with the tree', async () => {
|
|
200
718
|
const ground = makeGround('route');
|
|
201
719
|
fs.writeFileSync(path.join(ground, 'a.txt'), 'alpha');
|
|
202
720
|
|
|
203
|
-
const
|
|
204
|
-
assert.equal(
|
|
205
|
-
assert.equal(
|
|
206
|
-
assert.
|
|
207
|
-
|
|
721
|
+
const answer = await post('/entities/register', { path: ground });
|
|
722
|
+
assert.equal(answer.status, 200);
|
|
723
|
+
assert.equal(answer.payload.rootPath, ground);
|
|
724
|
+
assert.ok(answer.payload.record.uuid);
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
test('POST /entities/register accepts paths for one batch of selected ground', async () => {
|
|
728
|
+
const first = makeGround('route-many-first');
|
|
729
|
+
const second = path.join(makeGround('route-many-second-parent'), 'second.txt');
|
|
730
|
+
fs.writeFileSync(second, 'second');
|
|
731
|
+
|
|
732
|
+
const answer = await post('/entities/register', { paths: [first, second] });
|
|
733
|
+
assert.equal(answer.status, 200);
|
|
734
|
+
assert.deepEqual(answer.payload.results.map((entry) => entry.rootPath), [first, second]);
|
|
735
|
+
assert.deepEqual(answer.payload.results.map((entry) => entry.record.parentUUID), [null, null]);
|
|
208
736
|
});
|
|
209
737
|
|
|
210
738
|
test('the endpoint reports a refusal as a refusal, with the reason', async () => {
|
|
211
739
|
const excluded = path.join(makeGround('route-refused'), '.ssh');
|
|
212
740
|
fs.mkdirSync(excluded);
|
|
213
741
|
|
|
214
|
-
const
|
|
215
|
-
assert.equal(
|
|
216
|
-
assert.
|
|
217
|
-
assert.match(sent.payload.error, /enrollment policy excludes it/);
|
|
742
|
+
const answer = await post('/entities/register', { path: excluded });
|
|
743
|
+
assert.equal(answer.status, 400, 'the request named ground the boundary refuses');
|
|
744
|
+
assert.match(answer.payload.error, /enrollment policy excludes it/);
|
|
218
745
|
});
|
|
219
746
|
|
|
220
747
|
test('a missing path is answered, not registered', async () => {
|
|
221
|
-
const
|
|
222
|
-
assert.equal(
|
|
223
|
-
assert.
|
|
224
|
-
assert.match(sent.payload.error, /path is required/);
|
|
748
|
+
const answer = await post('/entities/register', {});
|
|
749
|
+
assert.equal(answer.status, 400);
|
|
750
|
+
assert.match(answer.payload.error, /path.*required/);
|
|
225
751
|
});
|
|
226
752
|
|
|
227
753
|
test('ground that is not there is a refusal, never a crash', async () => {
|
|
228
|
-
const
|
|
229
|
-
assert.equal(
|
|
230
|
-
assert.
|
|
231
|
-
|
|
754
|
+
const answer = await post('/entities/register', { path: path.join(tempRoot, 'no-such-ground') });
|
|
755
|
+
assert.equal(answer.status, 400);
|
|
756
|
+
assert.match(answer.payload.error, /no ground at that address/);
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
test('the route group claims only its own request', async () => {
|
|
760
|
+
assert.equal((await post('/entities', {})).status, 404);
|
|
232
761
|
});
|
|
233
762
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
763
|
+
// --- the fault, last: it convicts the installation's singleton -------------
|
|
764
|
+
|
|
765
|
+
test('a torn identity store is a FAULT, never the caller\'s bad path', async () => {
|
|
766
|
+
// The sharpest case the split exists for. The tree is registered and
|
|
767
|
+
// its identity is intact; the EVIDENCE that witnesses it is destroyed
|
|
768
|
+
// underneath the runtime. The loss law convicts — loudly, by design —
|
|
769
|
+
// and that conviction must never reach the user as "your path is bad".
|
|
770
|
+
//
|
|
771
|
+
// (The server's fault handler logs the error; the noise below is that
|
|
772
|
+
// handler doing its job.)
|
|
773
|
+
const ground = makeGround('torn');
|
|
238
774
|
fs.writeFileSync(path.join(ground, 'a.txt'), 'alpha');
|
|
775
|
+
assert.equal((await post('/entities/register', { path: ground })).status, 200);
|
|
239
776
|
|
|
240
|
-
const
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
assert.equal(sent.payload.rootPath, ground);
|
|
244
|
-
});
|
|
777
|
+
const evidence = new Database(path.join(ENTITIES_DIR, 'observer.db'));
|
|
778
|
+
evidence.prepare('DELETE FROM observer_roots').run();
|
|
779
|
+
evidence.close();
|
|
245
780
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
fakeCtx('/workspace/projects', 'POST', {}),
|
|
250
|
-
fakeCtx('/entities', 'POST', {}),
|
|
251
|
-
];
|
|
252
|
-
for (const { ctx, sent } of others) {
|
|
253
|
-
assert.equal(await handleEntityRoutes(ctx), false);
|
|
254
|
-
assert.equal(sent.status, undefined, 'an unclaimed request is never answered');
|
|
255
|
-
}
|
|
781
|
+
const answer = await post('/entities/register', { path: makeGround('torn-next') });
|
|
782
|
+
assert.equal(answer.status, 500, 'an installation fault, reported as one');
|
|
783
|
+
assert.doesNotMatch(String(answer.payload?.error), /refusing to add/);
|
|
256
784
|
});
|