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
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const test = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
const crypto = require('crypto');
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const os = require('os');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
|
|
10
|
+
const Database = require('better-sqlite3');
|
|
11
|
+
const { createRegistry } = require('../registry');
|
|
12
|
+
const { createEntityCloud } = require('../registration/entity-cloud');
|
|
13
|
+
const { CHUNK_BYTES, createEntityContent } = require('../registration/entity-content');
|
|
14
|
+
|
|
15
|
+
function sha256(bytes) {
|
|
16
|
+
return crypto.createHash('sha256').update(bytes).digest('hex');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
test('file bytes are captured as bounded immutable chunks, then acknowledged before their entity record is claimable', () => {
|
|
20
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-entity-content-'));
|
|
21
|
+
const source = path.join(dir, 'notes.bin');
|
|
22
|
+
const bytes = Buffer.concat([Buffer.alloc(CHUNK_BYTES, 0x61), Buffer.from('tail')]);
|
|
23
|
+
const contentHash = sha256(bytes);
|
|
24
|
+
fs.writeFileSync(source, bytes);
|
|
25
|
+
|
|
26
|
+
const content = createEntityContent({ dir });
|
|
27
|
+
const captured = content.captureFile({ filePath: source, contentHash });
|
|
28
|
+
assert.equal(captured.chunks.length, 2, 'transport chunks bound frame size without changing the file head');
|
|
29
|
+
assert.equal(captured.bytes, bytes.length);
|
|
30
|
+
|
|
31
|
+
const db = new Database(':memory:');
|
|
32
|
+
const cloud = createEntityCloud({ db, userId: 'user-1', deviceId: 'device-1', content });
|
|
33
|
+
const registry = createRegistry({ db, ephemeral: true, emit: () => {}, journal: cloud.journal });
|
|
34
|
+
const root = registry.enrollRoot({ type: 'workspace', name: 'ground' });
|
|
35
|
+
const bootstrap = cloud.bootstrap(registry);
|
|
36
|
+
cloud.install({
|
|
37
|
+
registry,
|
|
38
|
+
resourceId: bootstrap.resourceId,
|
|
39
|
+
authorityEpoch: 1,
|
|
40
|
+
headVersion: 0,
|
|
41
|
+
snapshotBase64: bootstrap.snapshotBase64,
|
|
42
|
+
snapshotChecksum: bootstrap.snapshotChecksum,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const file = registry.create({
|
|
46
|
+
type: 'file.binary', parentUUID: root.uuid, name: 'notes.bin', payloadVersion: contentHash,
|
|
47
|
+
});
|
|
48
|
+
assert.deepEqual(cloud.claim(), [], 'the cloud record is held until its immutable bytes are acknowledged');
|
|
49
|
+
|
|
50
|
+
const first = cloud.claimContent();
|
|
51
|
+
assert.equal(first.kind, 'chunk');
|
|
52
|
+
assert.equal(first.partIndex, 0);
|
|
53
|
+
assert.equal(first.bytes, CHUNK_BYTES);
|
|
54
|
+
cloud.acknowledgeContent({ ...first, contentHash, resourceId: bootstrap.resourceId });
|
|
55
|
+
|
|
56
|
+
const second = cloud.claimContent();
|
|
57
|
+
assert.equal(second.kind, 'chunk');
|
|
58
|
+
assert.equal(second.partIndex, 1);
|
|
59
|
+
assert.equal(Buffer.from(second.dataBase64, 'base64').toString(), 'tail');
|
|
60
|
+
cloud.acknowledgeContent({ ...second, contentHash, resourceId: bootstrap.resourceId });
|
|
61
|
+
|
|
62
|
+
const manifest = cloud.claimContent();
|
|
63
|
+
assert.equal(manifest.kind, 'manifest');
|
|
64
|
+
assert.equal(manifest.partIndex, 2);
|
|
65
|
+
cloud.acknowledgeContent({ ...manifest, contentHash, resourceId: bootstrap.resourceId });
|
|
66
|
+
|
|
67
|
+
const mutation = cloud.claim();
|
|
68
|
+
assert.equal(mutation.length, 1);
|
|
69
|
+
assert.equal(mutation[0].operation.record.uuid, file.uuid);
|
|
70
|
+
assert.equal(mutation[0].operation.record.payloadVersion, contentHash);
|
|
71
|
+
|
|
72
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('a later file version never substitutes its bytes for an earlier captured head', () => {
|
|
76
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-entity-content-race-'));
|
|
77
|
+
const source = path.join(dir, 'notes.txt');
|
|
78
|
+
fs.writeFileSync(source, 'first');
|
|
79
|
+
const firstHead = sha256(Buffer.from('first'));
|
|
80
|
+
fs.writeFileSync(source, 'second');
|
|
81
|
+
const content = createEntityContent({ dir });
|
|
82
|
+
assert.throws(
|
|
83
|
+
() => content.captureFile({ filePath: source, contentHash: firstHead }),
|
|
84
|
+
/file changed while its content was being captured/,
|
|
85
|
+
);
|
|
86
|
+
assert.equal(content.uploaded({ resourceId: 'pvr_test', contentHash: firstHead }), false);
|
|
87
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('a cloud manifest and its immutable chunks rebuild exact bytes in another machine cache', () => {
|
|
91
|
+
const sourceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-entity-content-source-'));
|
|
92
|
+
const targetDir = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-entity-content-target-'));
|
|
93
|
+
const sourceFile = path.join(sourceDir, 'message.txt');
|
|
94
|
+
const bytes = Buffer.concat([Buffer.alloc(CHUNK_BYTES, 0x62), Buffer.from('from another machine')]);
|
|
95
|
+
const contentHash = sha256(bytes);
|
|
96
|
+
fs.writeFileSync(sourceFile, bytes);
|
|
97
|
+
|
|
98
|
+
const source = createEntityContent({ dir: sourceDir });
|
|
99
|
+
source.captureFile({ filePath: sourceFile, contentHash });
|
|
100
|
+
source.ensureUpload({ resourceId: 'pvr_test', contentHash });
|
|
101
|
+
const parts = [];
|
|
102
|
+
while (true) {
|
|
103
|
+
const part = source.claim({ resourceId: 'pvr_test' });
|
|
104
|
+
if (!part) break;
|
|
105
|
+
parts.push(part);
|
|
106
|
+
source.acknowledge({
|
|
107
|
+
resourceId: 'pvr_test', contentHash, kind: part.kind, partIndex: part.partIndex,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const target = createEntityContent({ dir: targetDir });
|
|
112
|
+
const manifest = parts.find((part) => part.kind === 'manifest');
|
|
113
|
+
target.receive({
|
|
114
|
+
contentHash, kind: 'manifest', partIndex: 0, sha256: manifest.sha256, dataBase64: manifest.dataBase64,
|
|
115
|
+
});
|
|
116
|
+
for (const part of parts.filter((item) => item.kind === 'chunk')) {
|
|
117
|
+
target.receive({
|
|
118
|
+
contentHash, kind: 'chunk', partIndex: part.partIndex, sha256: part.sha256, dataBase64: part.dataBase64,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
assert.equal(target.complete({ contentHash }), true);
|
|
123
|
+
assert.deepEqual(target.bytes({ contentHash }), bytes);
|
|
124
|
+
fs.rmSync(sourceDir, { recursive: true, force: true });
|
|
125
|
+
fs.rmSync(targetDir, { recursive: true, force: true });
|
|
126
|
+
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const test = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
const crypto = require('crypto');
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const os = require('os');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
|
|
10
|
+
const Database = require('better-sqlite3');
|
|
11
|
+
const { createRegistry } = require('../registry');
|
|
12
|
+
const { createEntityCloud, snapshotFromRecords } = require('../registration/entity-cloud');
|
|
13
|
+
const { createEntityContent } = require('../registration/entity-content');
|
|
14
|
+
const { createRegistrationService } = require('../registration/service');
|
|
15
|
+
|
|
16
|
+
function sha256(bytes) {
|
|
17
|
+
return crypto.createHash('sha256').update(bytes).digest('hex');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function uploadedParts(content, resourceId, contentHash) {
|
|
21
|
+
content.ensureUpload({ resourceId, contentHash });
|
|
22
|
+
const parts = [];
|
|
23
|
+
while (true) {
|
|
24
|
+
const part = content.claim({ resourceId });
|
|
25
|
+
if (!part) return parts;
|
|
26
|
+
parts.push(part);
|
|
27
|
+
content.acknowledge({ resourceId, contentHash, kind: part.kind, partIndex: part.partIndex });
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
test('add materializes one compacted cloud head, preserves its UUIDs, and starts a local watcher without creating an outbound history replay', async () => {
|
|
32
|
+
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-add-materialization-'));
|
|
33
|
+
const sourceDir = path.join(rootDir, 'source');
|
|
34
|
+
const targetState = path.join(rootDir, 'target-state');
|
|
35
|
+
const targetParent = path.join(rootDir, 'checkout');
|
|
36
|
+
fs.mkdirSync(sourceDir, { recursive: true });
|
|
37
|
+
fs.mkdirSync(targetParent, { recursive: true });
|
|
38
|
+
const bytes = Buffer.from('# arrives from cloud\n');
|
|
39
|
+
const contentHash = sha256(bytes);
|
|
40
|
+
const sourceFile = path.join(sourceDir, 'readme.md');
|
|
41
|
+
fs.writeFileSync(sourceFile, bytes);
|
|
42
|
+
|
|
43
|
+
const sourceDb = new Database(':memory:');
|
|
44
|
+
const sourceContent = createEntityContent({ dir: sourceDir });
|
|
45
|
+
const sourceCloud = createEntityCloud({
|
|
46
|
+
db: sourceDb, userId: 'user-1', deviceId: 'source-device', content: sourceContent,
|
|
47
|
+
});
|
|
48
|
+
const sourceRegistry = createRegistry({ db: sourceDb, ephemeral: true, emit: () => {}, journal: sourceCloud.journal });
|
|
49
|
+
const root = sourceRegistry.enrollRoot({ type: 'workspace', name: 'project' });
|
|
50
|
+
const file = sourceRegistry.create({
|
|
51
|
+
type: 'file.text', parentUUID: root.uuid, name: 'readme.md', payloadVersion: contentHash,
|
|
52
|
+
});
|
|
53
|
+
sourceContent.captureFile({ filePath: sourceFile, contentHash });
|
|
54
|
+
const parts = uploadedParts(sourceContent, sourceCloud.identity().resourceId, contentHash);
|
|
55
|
+
const snapshot = snapshotFromRecords(sourceRegistry.syncRecords());
|
|
56
|
+
const snapshotBytes = Buffer.from(JSON.stringify(snapshot), 'utf8');
|
|
57
|
+
|
|
58
|
+
const target = createRegistrationService({
|
|
59
|
+
dir: targetState,
|
|
60
|
+
homeDir: path.join(rootDir, 'target-home'),
|
|
61
|
+
cloudUserId: 'user-1',
|
|
62
|
+
cloudDeviceId: 'target-device',
|
|
63
|
+
});
|
|
64
|
+
try {
|
|
65
|
+
target.entityCloud.install({
|
|
66
|
+
registry: target.registration.registry,
|
|
67
|
+
resourceId: sourceCloud.identity().resourceId,
|
|
68
|
+
authorityEpoch: 7,
|
|
69
|
+
headVersion: 41,
|
|
70
|
+
snapshotBase64: snapshotBytes.toString('base64'),
|
|
71
|
+
snapshotChecksum: sha256(snapshotBytes),
|
|
72
|
+
});
|
|
73
|
+
assert.deepEqual(target.registration.registry.syncRecords(), [], 'the compacted cloud graph is not watched before add');
|
|
74
|
+
|
|
75
|
+
const manifest = parts.find((part) => part.kind === 'manifest');
|
|
76
|
+
target.entityCloud.receiveDownload({
|
|
77
|
+
resourceId: sourceCloud.identity().resourceId,
|
|
78
|
+
contentHash,
|
|
79
|
+
kind: 'manifest',
|
|
80
|
+
partIndex: 0,
|
|
81
|
+
sha256: manifest.sha256,
|
|
82
|
+
dataBase64: manifest.dataBase64,
|
|
83
|
+
});
|
|
84
|
+
for (const part of parts.filter((entry) => entry.kind === 'chunk')) {
|
|
85
|
+
target.entityCloud.receiveDownload({
|
|
86
|
+
resourceId: sourceCloud.identity().resourceId,
|
|
87
|
+
contentHash,
|
|
88
|
+
kind: 'chunk',
|
|
89
|
+
partIndex: part.partIndex,
|
|
90
|
+
sha256: part.sha256,
|
|
91
|
+
dataBase64: part.dataBase64,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const job = await target.add({ uuid: root.uuid, targetPath: targetParent });
|
|
96
|
+
assert.equal(job.state, 'complete');
|
|
97
|
+
const destination = path.join(targetParent, 'project');
|
|
98
|
+
assert.deepEqual(fs.readFileSync(path.join(destination, 'readme.md')), bytes);
|
|
99
|
+
assert.equal(target.registration.registry.syncRecord(root.uuid).uuid, root.uuid);
|
|
100
|
+
assert.equal(target.registration.registry.syncRecord(file.uuid).uuid, file.uuid);
|
|
101
|
+
const watchers = target.observer.status();
|
|
102
|
+
assert.equal(watchers.find((entry) => entry.path === fs.realpathSync(destination))?.watching, true, JSON.stringify(watchers));
|
|
103
|
+
const targetDb = new Database(path.join(targetState, 'identity.db'), { readonly: true });
|
|
104
|
+
try {
|
|
105
|
+
assert.equal(
|
|
106
|
+
targetDb.prepare('SELECT COUNT(*) AS count FROM entity_cloud_mutations').get().count,
|
|
107
|
+
0,
|
|
108
|
+
'binding an authoritative cloud snapshot does not emit a new outbound mutation history',
|
|
109
|
+
);
|
|
110
|
+
} finally {
|
|
111
|
+
targetDb.close();
|
|
112
|
+
}
|
|
113
|
+
} finally {
|
|
114
|
+
target.close();
|
|
115
|
+
sourceDb.close();
|
|
116
|
+
fs.rmSync(rootDir, { recursive: true, force: true });
|
|
117
|
+
}
|
|
118
|
+
});
|
|
@@ -83,6 +83,72 @@ test('first initialization mints the sentinel; reopening finds the same registry
|
|
|
83
83
|
assert.deepEqual(rootOf(reopened), reopened.record(root.uuid));
|
|
84
84
|
});
|
|
85
85
|
|
|
86
|
+
test('the additive entity-cloud migration preserves a generation-2 registry', () => {
|
|
87
|
+
const db = new Database(':memory:');
|
|
88
|
+
db.exec(`
|
|
89
|
+
CREATE TABLE registry_meta (singleton INTEGER PRIMARY KEY, epoch_uuid TEXT NOT NULL, created_at_ms INTEGER NOT NULL);
|
|
90
|
+
CREATE TABLE entities (uuid TEXT PRIMARY KEY, type TEXT NOT NULL, parent_uuid TEXT, name TEXT NOT NULL, status TEXT NOT NULL, payload_version TEXT);
|
|
91
|
+
CREATE TABLE channel_bindings (channel_id TEXT PRIMARY KEY, scope_id TEXT NOT NULL, entity_uuid TEXT NOT NULL);
|
|
92
|
+
CREATE TABLE contested_ground (scope_id TEXT NOT NULL, rel_path TEXT NOT NULL, claimant_uuid TEXT NOT NULL, kind TEXT NOT NULL, PRIMARY KEY (scope_id, rel_path));
|
|
93
|
+
`);
|
|
94
|
+
db.prepare('INSERT INTO registry_meta (singleton, epoch_uuid, created_at_ms) VALUES (1, ?, ?)')
|
|
95
|
+
.run('legacy-epoch', 1);
|
|
96
|
+
db.pragma('user_version = 2');
|
|
97
|
+
|
|
98
|
+
const registry = createRegistry({ db, ephemeral: true, emit: () => {} });
|
|
99
|
+
assert.equal(registry.epoch(), 'legacy-epoch', 'the existing identity epoch survives');
|
|
100
|
+
assert.equal(db.pragma('user_version', { simple: true }), 6);
|
|
101
|
+
assert.ok(db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'repo_states'").get(),
|
|
102
|
+
'the repo-state document table is added without replacing identity');
|
|
103
|
+
assert.ok(db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'entity_cloud_mutations'").get(),
|
|
104
|
+
'the entity-cloud outbox is added beside the existing UUID graph');
|
|
105
|
+
assert.ok(db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'entity_cloud_catalog_state'").get(),
|
|
106
|
+
'the snapshot-head marker is added beside the existing UUID graph');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('the active-snapshot marker migrates beside an existing materialization catalog', () => {
|
|
110
|
+
const db = new Database(':memory:');
|
|
111
|
+
createRegistry({ db, ephemeral: true, emit: () => {} });
|
|
112
|
+
db.exec('DROP TABLE entity_cloud_catalog_state');
|
|
113
|
+
db.pragma('user_version = 5');
|
|
114
|
+
|
|
115
|
+
const reopened = createRegistry({ db, ephemeral: true, emit: () => {} });
|
|
116
|
+
assert.ok(reopened.epoch(), 'the previous identity database remains usable');
|
|
117
|
+
assert.equal(db.pragma('user_version', { simple: true }), 6);
|
|
118
|
+
assert.ok(db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'entity_cloud_catalog_state'").get(),
|
|
119
|
+
'the separate pinned-snapshot marker is created without rewriting identities');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('an entity commit and its cloud delivery intent are one SQLite transaction', () => {
|
|
123
|
+
const db = new Database(':memory:');
|
|
124
|
+
let nextMutation = 0;
|
|
125
|
+
const registry = createRegistry({
|
|
126
|
+
db,
|
|
127
|
+
ephemeral: true,
|
|
128
|
+
emit: () => {},
|
|
129
|
+
journal: ({ record }) => {
|
|
130
|
+
db.prepare(`
|
|
131
|
+
INSERT INTO entity_cloud_mutations (
|
|
132
|
+
resource_id, mutation_id, operation_json, mutation_state, created_at, updated_at
|
|
133
|
+
) VALUES ('pvr_test', ?, ?, 'pending', 'now', 'now')
|
|
134
|
+
`).run(`ent_test_${nextMutation += 1}`, JSON.stringify({ kind: 'entity.upsert', record }));
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
const root = registry.enrollRoot({ type: 'workspace', name: 'clouded' });
|
|
138
|
+
assert.equal(db.prepare('SELECT COUNT(*) AS count FROM entity_cloud_mutations').get().count, 1);
|
|
139
|
+
assert.equal(db.prepare('SELECT COUNT(*) AS count FROM entities WHERE uuid = ?').get(root.uuid).count, 1);
|
|
140
|
+
|
|
141
|
+
const failing = createRegistry({
|
|
142
|
+
db: new Database(':memory:'),
|
|
143
|
+
ephemeral: true,
|
|
144
|
+
emit: () => {},
|
|
145
|
+
journal: () => { throw new Error('outbox unavailable'); },
|
|
146
|
+
});
|
|
147
|
+
assert.throws(() => failing.enrollRoot({ type: 'workspace', name: 'must-not-land' }), /outbox unavailable/);
|
|
148
|
+
assert.equal(failing.roots().length, 0,
|
|
149
|
+
'a failed cloud intent rolls the entity birth back instead of leaving local-only identity');
|
|
150
|
+
});
|
|
151
|
+
|
|
86
152
|
test('a registry that existed is never silently replaced', () => {
|
|
87
153
|
const dir = fs.mkdtempSync(path.join(tempRoot, 'loss-'));
|
|
88
154
|
const sentinelPath = path.join(dir, 'registry.epoch');
|
|
@@ -154,11 +220,11 @@ test('a failed notification is a pending bell, never a permanent miss', () => {
|
|
|
154
220
|
const renamed = registry.move(file.uuid, { parentUUID: root.uuid, name: 'b.txt' });
|
|
155
221
|
assert.equal(renamed.name, 'b.txt', 'the commit succeeded — the registry is truth');
|
|
156
222
|
assert.equal(events.length, 0, 'the notification was missed');
|
|
157
|
-
registry.
|
|
223
|
+
registry.attest(file.uuid, { payloadVersion: 'h2', type: 'file.text' });
|
|
158
224
|
assert.equal(events.length, 0, 'two misses collapse into one owed comparison');
|
|
159
225
|
|
|
160
226
|
down = false;
|
|
161
|
-
registry.
|
|
227
|
+
registry.attest(file.uuid, { payloadVersion: 'h3', type: 'file.text' });
|
|
162
228
|
assert.deepEqual(events.map((event) => event.type), ['registry.changed', 'entity.committed'],
|
|
163
229
|
'the next commit rings the pending bell first, then its own event');
|
|
164
230
|
assert.deepEqual(registry.record(file.uuid), events[1].record,
|
|
@@ -202,7 +268,7 @@ test('trees in one registry: parentless records are roots, and a tree boundary i
|
|
|
202
268
|
assert.deepEqual(registry.roots().map((record) => record.uuid), [other.uuid],
|
|
203
269
|
'the adopted tree is no longer parentless — it lives inside its new tree');
|
|
204
270
|
|
|
205
|
-
// The ratified extension (`amalgm
|
|
271
|
+
// The ratified extension (`amalgm register <file>`): a single registered
|
|
206
272
|
// file is a tree of one — its root record IS the file entity.
|
|
207
273
|
const { registry: soloRegistry } = makeRegistry();
|
|
208
274
|
const solo = soloRegistry.enrollRoot({ type: 'file.text', name: 'notes.md', payloadVersion: 'h1' });
|
|
@@ -210,7 +276,7 @@ test('trees in one registry: parentless records are roots, and a tree boundary i
|
|
|
210
276
|
assert.throws(() => soloRegistry.create({ type: 'file.text', parentUUID: solo.uuid, name: 'x' }),
|
|
211
277
|
/only containers hold active children/, 'a tree of one stays a tree of one');
|
|
212
278
|
|
|
213
|
-
// And its sibling (amended 2026-08-01): `amalgm
|
|
279
|
+
// And its sibling (amended 2026-08-01): `amalgm register` names an ADDRESS,
|
|
214
280
|
// so a pointer added explicitly is an entity of its own — a tree of
|
|
215
281
|
// one of the pointer kind, its payload the written target text.
|
|
216
282
|
const { registry: ptrRegistry } = makeRegistry();
|
|
@@ -227,7 +293,9 @@ test('a reference\'s payload is identity, never an attestation', () => {
|
|
|
227
293
|
type: 'reference', parentUUID: root.uuid, name: 'proj', payloadVersion: 'target-uuid-123',
|
|
228
294
|
});
|
|
229
295
|
assert.equal(ref.type, 'reference');
|
|
230
|
-
assert.throws(
|
|
296
|
+
assert.throws(
|
|
297
|
+
() => registry.attest(ref.uuid, { payloadVersion: 'other', type: 'reference' }),
|
|
298
|
+
/never an attestation/,
|
|
231
299
|
'the evidence path cannot write a reference\'s payload: ground attests bytes, never identity');
|
|
232
300
|
assert.throws(() => registry.create({ type: 'file.text', parentUUID: ref.uuid, name: 'x' }),
|
|
233
301
|
/only containers hold active children/, 'a reference holds no children');
|
|
@@ -266,10 +334,57 @@ test('rename and move preserve the uuid; the version is a state fingerprint', ()
|
|
|
266
334
|
assert.equal(home.version, born.version,
|
|
267
335
|
'identical state is the identical version — a fingerprint, not a history counter');
|
|
268
336
|
|
|
269
|
-
const edited = registry.
|
|
337
|
+
const edited = registry.attest(file.uuid, { payloadVersion: 'hash-2', type: 'file.text' });
|
|
270
338
|
assert.notEqual(edited.version, home.version);
|
|
271
|
-
assert.equal(
|
|
272
|
-
|
|
339
|
+
assert.equal(
|
|
340
|
+
registry.attest(file.uuid, { payloadVersion: 'hash-2', type: 'file.text' }).version,
|
|
341
|
+
edited.version, 'an equal attestation is silence');
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
test('one observation is one transition: payload and type land in a single commit', () => {
|
|
345
|
+
const { registry, events, file } = makeGraph();
|
|
346
|
+
events.length = 0;
|
|
347
|
+
|
|
348
|
+
// The same look that found these bytes says they are bytes, not text.
|
|
349
|
+
const after = registry.attest(file.uuid, { payloadVersion: 'hash-2', type: 'file.binary' });
|
|
350
|
+
assert.equal(after.type, 'file.binary');
|
|
351
|
+
assert.equal(events.length, 1, 'one look, one bell — never a hash commit and a type commit');
|
|
352
|
+
assert.equal(events[0].record.type, 'file.binary');
|
|
353
|
+
assert.equal(events[0].previous.type, 'file.text');
|
|
354
|
+
// The state nobody may ever hear or store: new bytes wearing the old
|
|
355
|
+
// type. Two writes would publish exactly that, and the equal-payload
|
|
356
|
+
// silence on the next pass would never heal it.
|
|
357
|
+
assert.equal(registry.record(file.uuid).type, 'file.binary');
|
|
358
|
+
|
|
359
|
+
// A pointer replacing bytes on the same slot is the same one transition.
|
|
360
|
+
events.length = 0;
|
|
361
|
+
const asLink = registry.attest(file.uuid, { payloadVersion: '../elsewhere', type: 'link' });
|
|
362
|
+
assert.equal(asLink.type, 'link');
|
|
363
|
+
assert.equal(events.length, 1);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
test('only leaf ground attests: membership and identity are never observed', () => {
|
|
367
|
+
const { registry, root, folder, file } = makeGraph();
|
|
368
|
+
for (const [uuid, what] of [[root.uuid, 'workspace'], [folder.uuid, 'folder']]) {
|
|
369
|
+
assert.throws(() => registry.attest(uuid, { payloadVersion: 'h', type: 'file.text' }),
|
|
370
|
+
/payload is derived membership/, `${what} payload is its members, never a look's answer`);
|
|
371
|
+
}
|
|
372
|
+
// Both ends are judged: what the record is now, and what the bytes claim.
|
|
373
|
+
assert.throws(() => registry.attest(file.uuid, { payloadVersion: 'h', type: 'folder' }),
|
|
374
|
+
/payload is derived membership/);
|
|
375
|
+
assert.throws(() => registry.attest(file.uuid, { payloadVersion: 'h', type: 'reference' }),
|
|
376
|
+
/never an attestation/);
|
|
377
|
+
assert.throws(() => registry.attest(file.uuid, { payloadVersion: 'h', type: 'directory' }),
|
|
378
|
+
/unknown entity type/);
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
test('a tree\'s top record attests only a type a root may wear', () => {
|
|
382
|
+
const { registry } = makeRegistry();
|
|
383
|
+
const root = registry.enrollRoot({ type: 'file.text', name: 'notes.md', payloadVersion: 'h1' });
|
|
384
|
+
assert.equal(registry.attest(root.uuid, { payloadVersion: 'h2', type: 'file.binary' }).type,
|
|
385
|
+
'file.binary', 'a registered single file changes kind on its own UUID');
|
|
386
|
+
assert.throws(() => registry.attest(root.uuid, { payloadVersion: 'h3', type: 'folder' }),
|
|
387
|
+
/payload is derived membership/);
|
|
273
388
|
});
|
|
274
389
|
|
|
275
390
|
test('the slot admits one active owner — and the schema itself convicts', () => {
|
|
@@ -377,7 +492,7 @@ test('a trashed record is not ground: it never moves, never attests, never re-ty
|
|
|
377
492
|
registry.trash(file.uuid);
|
|
378
493
|
assert.throws(() => registry.move(file.uuid, { parentUUID: root.uuid, name: 'x.txt' }),
|
|
379
494
|
/only an active entity moves/);
|
|
380
|
-
assert.throws(() => registry.
|
|
495
|
+
assert.throws(() => registry.attest(file.uuid, { payloadVersion: 'h9', type: 'file.text' }),
|
|
381
496
|
/only an active entity attests/);
|
|
382
497
|
assert.throws(() => registry.changeType(file.uuid, 'folder'),
|
|
383
498
|
/only an active entity changes type/);
|
|
@@ -442,7 +557,7 @@ test('a container\'s version is its active membership, nothing else', () => {
|
|
|
442
557
|
const { registry, folder, file } = makeGraph();
|
|
443
558
|
const v0 = registry.record(folder.uuid).version;
|
|
444
559
|
|
|
445
|
-
registry.
|
|
560
|
+
registry.attest(file.uuid, { payloadVersion: 'hash-2', type: 'file.text' });
|
|
446
561
|
assert.equal(registry.record(folder.uuid).version, v0,
|
|
447
562
|
'a child\'s content edit does not change its parent\'s version');
|
|
448
563
|
|
|
@@ -483,7 +598,7 @@ test('every commit emits the record and its previous; no-ops emit nothing', () =
|
|
|
483
598
|
assert.notEqual(events[0].previous.version, moved.version);
|
|
484
599
|
|
|
485
600
|
events.length = 0;
|
|
486
|
-
registry.
|
|
601
|
+
registry.attest(file.uuid, { payloadVersion: 'hash-1', type: 'file.text' }); // equal attestation
|
|
487
602
|
registry.changeType(file.uuid, 'file.text'); // same type
|
|
488
603
|
assert.equal(events.length, 0, 'silence when nothing changed');
|
|
489
604
|
|
|
@@ -32,8 +32,14 @@ const defaultPolicy = require('../workspace/tree/inclusion').shouldEnroll;
|
|
|
32
32
|
// The RIG's explicit classification policy — injected into the model
|
|
33
33
|
// exactly like the enrollment policy. "NUL byte means binary" is a choice
|
|
34
34
|
// these tests make; the model owns no such rule.
|
|
35
|
-
|
|
36
|
-
|
|
35
|
+
//
|
|
36
|
+
// ONE law, two askers. The oracle holds the bytes and looks for the NUL
|
|
37
|
+
// itself; the evidence lens is handed the observer's report on the bytes
|
|
38
|
+
// it already hashed. Neither re-reads the ground to answer, so the two
|
|
39
|
+
// can never grade the same file differently.
|
|
40
|
+
const typeOf = (binary) => (binary ? 'file.binary' : 'file.text');
|
|
41
|
+
const classify = (bytes) => typeOf(
|
|
42
|
+
(Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes)).includes(0));
|
|
37
43
|
|
|
38
44
|
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-entity-rig-'));
|
|
39
45
|
|
|
@@ -113,9 +119,10 @@ function makeKit(name, { policy = defaultPolicy, watch, settleMs } = {}) {
|
|
|
113
119
|
// born here — birth reads store truth, exactly like the boundary.
|
|
114
120
|
evidence = createEvidence({
|
|
115
121
|
observer, registry, memory, rootId: kit.rootId,
|
|
116
|
-
// The
|
|
117
|
-
//
|
|
118
|
-
|
|
122
|
+
// The SAME rule the model got, asked of the SAME evidence: the
|
|
123
|
+
// observer's own report on the bytes it hashed. No bridge, no
|
|
124
|
+
// second read — that is the point of the law.
|
|
125
|
+
classify: ({ binary }) => typeOf(binary),
|
|
119
126
|
});
|
|
120
127
|
kit.evidence = evidence;
|
|
121
128
|
evidence.reconcile();
|
|
@@ -207,7 +214,14 @@ function fakeWatchKit() {
|
|
|
207
214
|
return {
|
|
208
215
|
watch: (rootPath, onDirty) => {
|
|
209
216
|
bells.set(rootPath, onDirty);
|
|
210
|
-
|
|
217
|
+
// A doorbell held in a Map has no arming window: pressing it answers.
|
|
218
|
+
const handle = {
|
|
219
|
+
degraded: false,
|
|
220
|
+
heard: true,
|
|
221
|
+
press: () => { handle.heard = true; },
|
|
222
|
+
close: () => bells.delete(rootPath),
|
|
223
|
+
};
|
|
224
|
+
return handle;
|
|
211
225
|
},
|
|
212
226
|
ring: (rootPath, relPath) => bells.get(rootPath)(relPath),
|
|
213
227
|
/** Every open watcher whose ground covers this absolute path rings,
|
|
@@ -22,6 +22,14 @@ function installFakeWatch() {
|
|
|
22
22
|
return entry;
|
|
23
23
|
};
|
|
24
24
|
return {
|
|
25
|
+
/**
|
|
26
|
+
* An OS that delivers only what the exam delivers has no arming
|
|
27
|
+
* window to cross, so there is nothing to prove about its doorbells.
|
|
28
|
+
* Pass this as the observer's `prove` wherever this stand-in is
|
|
29
|
+
* installed; the real proof is exercised where the real OS is
|
|
30
|
+
* (registration.service.test.js).
|
|
31
|
+
*/
|
|
32
|
+
prove: async () => false,
|
|
25
33
|
/** Ring every open doorbell on `target` with one raw name. */
|
|
26
34
|
deliver(target, name) {
|
|
27
35
|
for (const entry of [...watchers]) {
|
|
@@ -91,7 +91,14 @@ function fakeWatchKit() {
|
|
|
91
91
|
return {
|
|
92
92
|
watch: (rootPath, onDirty) => {
|
|
93
93
|
bells.set(rootPath, onDirty);
|
|
94
|
-
|
|
94
|
+
// A doorbell held in a Map has no arming window: pressing it answers.
|
|
95
|
+
const handle = {
|
|
96
|
+
degraded: false,
|
|
97
|
+
heard: true,
|
|
98
|
+
press: () => { handle.heard = true; },
|
|
99
|
+
close: () => bells.delete(rootPath),
|
|
100
|
+
};
|
|
101
|
+
return handle;
|
|
95
102
|
},
|
|
96
103
|
ring: (rootPath, relPath) => bells.get(rootPath)(relPath),
|
|
97
104
|
};
|