amalgm 0.1.245 → 0.1.246
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 +12 -0
- package/lib/cli.js +146 -8
- package/lib/layout.js +89 -4
- package/lib/shared-realtime-tunnel.js +384 -43
- package/lib/supervisor.js +0 -3
- package/package.json +2 -2
- package/runtime/scripts/amalgm-mcp/adapters/contract.js +197 -0
- package/runtime/scripts/amalgm-mcp/adapters/filesystem.js +135 -0
- package/runtime/scripts/amalgm-mcp/adapters/git.js +84 -0
- package/runtime/scripts/amalgm-mcp/adapters/index.js +43 -0
- package/runtime/scripts/amalgm-mcp/adapters/reference.js +31 -0
- package/runtime/scripts/amalgm-mcp/adapters/truth.js +19 -0
- package/runtime/scripts/amalgm-mcp/browser/cookie-jar.js +20 -68
- package/runtime/scripts/amalgm-mcp/index.js +4 -0
- package/runtime/scripts/amalgm-mcp/lib/layout.js +89 -4
- package/runtime/scripts/amalgm-mcp/observer/README.md +259 -0
- package/runtime/scripts/amalgm-mcp/observer/apply.js +150 -0
- package/runtime/scripts/amalgm-mcp/observer/continuity.js +111 -0
- package/runtime/scripts/amalgm-mcp/observer/edges.js +111 -0
- package/runtime/scripts/amalgm-mcp/observer/index.js +1146 -0
- package/runtime/scripts/amalgm-mcp/observer/scan.js +387 -0
- package/runtime/scripts/amalgm-mcp/observer/store.js +262 -0
- package/runtime/scripts/amalgm-mcp/observer/verify.js +200 -0
- package/runtime/scripts/amalgm-mcp/observer/watch.js +62 -0
- package/runtime/scripts/amalgm-mcp/project-context/store.js +19 -87
- package/runtime/scripts/amalgm-mcp/registration/classify.js +33 -0
- package/runtime/scripts/amalgm-mcp/registration/entity-cloud.js +456 -0
- package/runtime/scripts/amalgm-mcp/registration/entity-content.js +345 -0
- package/runtime/scripts/amalgm-mcp/registration/index.js +752 -0
- package/runtime/scripts/amalgm-mcp/registration/refusal.js +42 -0
- package/runtime/scripts/amalgm-mcp/registration/repo-followers.js +127 -0
- package/runtime/scripts/amalgm-mcp/registration/repo-states.js +109 -0
- package/runtime/scripts/amalgm-mcp/registration/service.js +310 -0
- package/runtime/scripts/amalgm-mcp/registration/tree.js +178 -0
- package/runtime/scripts/amalgm-mcp/registry/evidence.js +826 -0
- package/runtime/scripts/amalgm-mcp/registry/index.js +666 -0
- package/runtime/scripts/amalgm-mcp/registry/store.js +290 -0
- package/runtime/scripts/amalgm-mcp/repocard/README.md +103 -0
- package/runtime/scripts/amalgm-mcp/repocard/apply.js +111 -0
- package/runtime/scripts/amalgm-mcp/repocard/capture-worker.js +94 -0
- package/runtime/scripts/amalgm-mcp/repocard/capture.js +149 -0
- package/runtime/scripts/amalgm-mcp/repocard/follow.js +310 -0
- package/runtime/scripts/amalgm-mcp/repocard/git.js +48 -0
- package/runtime/scripts/amalgm-mcp/repocard/index.js +48 -0
- package/runtime/scripts/amalgm-mcp/server/local-service-router.js +2 -0
- package/runtime/scripts/amalgm-mcp/server/routes/entities.js +287 -0
- package/runtime/scripts/amalgm-mcp/server/routes/state.js +0 -13
- package/runtime/scripts/amalgm-mcp/state/attachments.js +11 -45
- package/runtime/scripts/amalgm-mcp/state/db.js +46 -215
- package/runtime/scripts/amalgm-mcp/state/docs.js +301 -1101
- package/runtime/scripts/amalgm-mcp/state/mutation-contracts.js +0 -99
- package/runtime/scripts/amalgm-mcp/state/mutations.js +130 -507
- package/runtime/scripts/amalgm-mcp/state/promotions.js +1 -21
- package/runtime/scripts/amalgm-mcp/state/replicas.js +13 -134
- package/runtime/scripts/amalgm-mcp/state/shared-rest.js +3 -46
- package/runtime/scripts/amalgm-mcp/tests/adapters.test.js +379 -0
- package/runtime/scripts/amalgm-mcp/tests/browser-cookie-cloud.test.js +0 -193
- package/runtime/scripts/amalgm-mcp/tests/doorbell.matrix.life.test.js +449 -0
- package/runtime/scripts/amalgm-mcp/tests/doorbell.matrix.rig.js +107 -0
- package/runtime/scripts/amalgm-mcp/tests/doorbell.matrix.watch.test.js +213 -0
- package/runtime/scripts/amalgm-mcp/tests/entity-oracle/adapter.js +72 -0
- package/runtime/scripts/amalgm-mcp/tests/entity-oracle/assert.js +497 -0
- package/runtime/scripts/amalgm-mcp/tests/entity-oracle/bind.js +91 -0
- package/runtime/scripts/amalgm-mcp/tests/entity-oracle/graph.js +1031 -0
- package/runtime/scripts/amalgm-mcp/tests/entity-oracle/ledger.js +234 -0
- package/runtime/scripts/amalgm-mcp/tests/entity-oracle/referee.js +118 -0
- package/runtime/scripts/amalgm-mcp/tests/entity.oracle.test.js +1365 -0
- package/runtime/scripts/amalgm-mcp/tests/entity.registry.test.js +967 -0
- package/runtime/scripts/amalgm-mcp/tests/entity.rig.js +239 -0
- package/runtime/scripts/amalgm-mcp/tests/entity.storm.test.js +486 -0
- package/runtime/scripts/amalgm-mcp/tests/fake-watch.js +35 -0
- package/runtime/scripts/amalgm-mcp/tests/observer.links.test.js +289 -0
- package/runtime/scripts/amalgm-mcp/tests/observer.rig.js +103 -0
- package/runtime/scripts/amalgm-mcp/tests/observer.roots.test.js +505 -0
- package/runtime/scripts/amalgm-mcp/tests/observer.storm.test.js +389 -0
- package/runtime/scripts/amalgm-mcp/tests/observer.territory.test.js +845 -0
- package/runtime/scripts/amalgm-mcp/tests/observer.test.js +657 -0
- package/runtime/scripts/amalgm-mcp/tests/project-context.test.js +17 -32
- package/runtime/scripts/amalgm-mcp/tests/registration.service.test.js +256 -0
- package/runtime/scripts/amalgm-mcp/tests/registration.test.js +931 -0
- package/runtime/scripts/amalgm-mcp/tests/repocard.storm.test.js +284 -0
- package/runtime/scripts/amalgm-mcp/tests/repocard.test.js +371 -0
- package/runtime/scripts/amalgm-mcp/tests/repofollow.storm.test.js +435 -0
- package/runtime/scripts/amalgm-mcp/tests/repofollow.test.js +675 -0
- package/runtime/scripts/amalgm-mcp/tests/shared-tunnel-rehydration.test.js +0 -84
- package/runtime/scripts/amalgm-mcp/tests/state-docs.test.js +13 -376
- package/runtime/scripts/amalgm-mcp/tests/state-mutations.test.js +0 -309
- package/runtime/scripts/amalgm-mcp/tests/state-shared-replica.test.js +30 -758
- package/runtime/scripts/amalgm-mcp/tests/workspace-cards-store.test.js +3 -72
- package/runtime/scripts/amalgm-mcp/tests/workspace-cards.test.js +1 -89
- package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint-reducer.test.js +24 -55
- package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint.test.js +141 -424
- package/runtime/scripts/amalgm-mcp/tests/workspace-object-tunnel.test.js +3 -171
- package/runtime/scripts/amalgm-mcp/tests/workspace-objects.test.js +7 -220
- package/runtime/scripts/amalgm-mcp/tests/workspace-publication-invariants.test.js +236 -179
- package/runtime/scripts/amalgm-mcp/tests/workspace-reducer.test.js +326 -2228
- package/runtime/scripts/amalgm-mcp/tests/workspace-tree-cloud.test.js +3 -5319
- package/runtime/scripts/amalgm-mcp/tests/workspace-tree-core.test.js +1 -191
- package/runtime/scripts/amalgm-mcp/tests/workspace-tree-store.test.js +17 -3140
- package/runtime/scripts/amalgm-mcp/tests/workspace-wall.test.js +740 -137
- package/runtime/scripts/amalgm-mcp/workspace/access-store.js +1 -5
- package/runtime/scripts/amalgm-mcp/workspace/card.js +11 -35
- package/runtime/scripts/amalgm-mcp/workspace/cards.js +14 -39
- package/runtime/scripts/amalgm-mcp/workspace/checkpoint.js +147 -587
- package/runtime/scripts/amalgm-mcp/workspace/git.js +34 -406
- package/runtime/scripts/amalgm-mcp/workspace/merge.js +181 -102
- package/runtime/scripts/amalgm-mcp/workspace/objects.js +56 -221
- package/runtime/scripts/amalgm-mcp/workspace/reducer.js +538 -1964
- package/runtime/scripts/amalgm-mcp/workspace/rest.js +28 -49
- package/runtime/scripts/amalgm-mcp/workspace/store.js +4 -77
- package/runtime/scripts/amalgm-mcp/workspace/transition.js +192 -1734
- package/runtime/scripts/amalgm-mcp/workspace/tree/inclusion.js +1 -7
- package/runtime/scripts/amalgm-mcp/workspace/tree/local-state.js +61 -73
- package/runtime/scripts/amalgm-mcp/workspace/tree/reducer.js +6 -51
- package/runtime/scripts/amalgm-mcp/workspace/tree/snapshot.js +8 -79
- package/runtime/scripts/amalgm-mcp/workspace/tree-cloud.js +146 -3461
- package/runtime/scripts/amalgm-mcp/workspace/tree-store.js +71 -899
- package/runtime/scripts/amalgm-mcp/workspace/wall.js +538 -370
- package/runtime/scripts/local-gateway.js +1 -0
- package/runtime/scripts/amalgm-mcp/state/doc-disk.js +0 -550
- package/runtime/scripts/amalgm-mcp/tests/code-project-stream.test.js +0 -399
- package/runtime/scripts/amalgm-mcp/tests/fixtures/code-project-stream-v2.json +0 -1
- package/runtime/scripts/amalgm-mcp/tests/workspace-durable.test.js +0 -24
- package/runtime/scripts/amalgm-mcp/tests/workspace-local-intent.test.js +0 -1999
- package/runtime/scripts/amalgm-mcp/tests/workspace-native-file-transaction.test.js +0 -364
- package/runtime/scripts/amalgm-mcp/tests/workspace-provenance.test.js +0 -185
- package/runtime/scripts/amalgm-mcp/tests/workspace-reducer-liveness.test.js +0 -26
- package/runtime/scripts/amalgm-mcp/tests/workspace-sync-stats.test.js +0 -119
- package/runtime/scripts/amalgm-mcp/tests/workspace-transition.test.js +0 -1446
- package/runtime/scripts/amalgm-mcp/workspace/content.js +0 -32
- package/runtime/scripts/amalgm-mcp/workspace/durable.js +0 -45
- package/runtime/scripts/amalgm-mcp/workspace/intent.js +0 -535
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/BUILD.md +0 -31
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/bin/darwin-arm64/amalgm-file-transaction +0 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/bin/linux-x64/amalgm-file-transaction +0 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/build.mjs +0 -72
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/client.js +0 -165
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/go.mod +0 -5
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/go.sum +0 -2
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/main.go +0 -2201
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/manifest.json +0 -19
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/move_darwin.go +0 -18
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/move_linux.go +0 -18
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/process_darwin.go +0 -33
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/process_linux.go +0 -47
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/verify.mjs +0 -43
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/writers_darwin.go +0 -114
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/writers_linux.go +0 -84
- package/runtime/scripts/amalgm-mcp/workspace/project-stream.js +0 -771
- package/runtime/scripts/amalgm-mcp/workspace/provenance.js +0 -240
- package/runtime/scripts/amalgm-mcp/workspace/sync-stats.js +0 -240
- package/runtime/scripts/amalgm-mcp/workspace/tree/floor.js +0 -100
- package/runtime/scripts/amalgm-mcp/workspace/tree/observation-holds.js +0 -127
- package/runtime/scripts/amalgm-mcp/workspace/tree/path-state.js +0 -12
- package/runtime/scripts/amalgm-mcp/workspace/tree/root-binding.js +0 -125
- package/runtime/scripts/amalgm-mcp/workspace/tree/symlinks.js +0 -76
- package/runtime/scripts/amalgm-mcp/workspace/tree-materialization.js +0 -3766
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Repo → { card, checkpoint, cardId, checkpointId, stateId }, or { busy }.
|
|
5
|
+
* Reads git, writes nothing. Capture is deterministic: the same repo state
|
|
6
|
+
* always derives the same ids, which is what makes equal ids silence.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const crypto = require('crypto');
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const os = require('os');
|
|
12
|
+
const path = require('path');
|
|
13
|
+
|
|
14
|
+
const { runGit, gitText, tryGitText } = require('./git');
|
|
15
|
+
|
|
16
|
+
/** git's well-known empty tree: the diff base for a repo with no commits. */
|
|
17
|
+
const EMPTY_TREE = '4b825dc642cb6eb9a060e54bf8d69288fbee4904';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Index states no patch can represent (axiom 3). Bisect is deliberately NOT
|
|
21
|
+
* here: it is a mode, not an unfinished operation — it can last hours, and
|
|
22
|
+
* every checkout it makes must be captured like any other.
|
|
23
|
+
*/
|
|
24
|
+
const BUSY_MARKERS = [
|
|
25
|
+
'MERGE_HEAD',
|
|
26
|
+
'rebase-merge',
|
|
27
|
+
'rebase-apply',
|
|
28
|
+
'CHERRY_PICK_HEAD',
|
|
29
|
+
'REVERT_HEAD',
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
const sha256 = (bytes) => crypto.createHash('sha256').update(bytes).digest('hex');
|
|
33
|
+
|
|
34
|
+
/** The actual .git directory, resolved through worktree/submodule pointers. */
|
|
35
|
+
function gitDirPath(repoPath) {
|
|
36
|
+
return path.resolve(repoPath, gitText(repoPath, ['rev-parse', '--git-dir']));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function busyReason(repoPath) {
|
|
40
|
+
const gitDir = gitDirPath(repoPath);
|
|
41
|
+
for (const marker of BUSY_MARKERS) {
|
|
42
|
+
if (fs.existsSync(path.join(gitDir, marker))) return marker;
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function refList(repoPath, prefix) {
|
|
48
|
+
const out = tryGitText(repoPath, ['for-each-ref', '--format=%(refname:short) %(objectname)', prefix]);
|
|
49
|
+
if (!out) return [];
|
|
50
|
+
return out.split('\n').map((line) => {
|
|
51
|
+
const at = line.lastIndexOf(' ');
|
|
52
|
+
return { name: line.slice(0, at), commit: line.slice(at + 1) };
|
|
53
|
+
}).sort((a, b) => (a.name < b.name ? -1 : 1));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function remoteList(repoPath) {
|
|
57
|
+
const out = tryGitText(repoPath, ['config', '--get-regexp', '^remote\\..*\\.url$']);
|
|
58
|
+
if (!out) return [];
|
|
59
|
+
return out.split('\n').map((line) => {
|
|
60
|
+
const at = line.indexOf(' ');
|
|
61
|
+
return { name: line.slice(7, at - 4), url: line.slice(at + 1) };
|
|
62
|
+
}).sort((a, b) => (a.name < b.name ? -1 : 1));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function untrackedFiles(repoPath) {
|
|
66
|
+
const out = runGit(repoPath, ['ls-files', '--others', '--exclude-standard', '-z']).toString('utf8');
|
|
67
|
+
return out.split('\0').filter(Boolean).sort().flatMap((rel) => {
|
|
68
|
+
// A trailing slash is git marking a repo boundary it will not enter — a
|
|
69
|
+
// nested repository or linked worktree. That is its own territory with
|
|
70
|
+
// its own card; the parent's checkpoint must not contain it.
|
|
71
|
+
if (rel.endsWith('/')) return [];
|
|
72
|
+
const abs = path.join(repoPath, rel);
|
|
73
|
+
const stat = fs.lstatSync(abs);
|
|
74
|
+
if (stat.isSymbolicLink()) {
|
|
75
|
+
const content = Buffer.from(fs.readlinkSync(abs));
|
|
76
|
+
return [{ path: rel, mode: '120000', content, sha256: sha256(content) }];
|
|
77
|
+
}
|
|
78
|
+
if (stat.isDirectory()) return []; // same boundary, unmarked: never read a directory as a file
|
|
79
|
+
const content = fs.readFileSync(abs);
|
|
80
|
+
const mode = stat.mode & 0o111 ? '100755' : '100644';
|
|
81
|
+
return [{ path: rel, mode, content, sha256: sha256(content) }];
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function makeBundle(repoPath) {
|
|
86
|
+
const tmp = path.join(os.tmpdir(), `repocard-${process.pid}-${crypto.randomBytes(6).toString('hex')}.bundle`);
|
|
87
|
+
try {
|
|
88
|
+
runGit(repoPath, ['bundle', 'create', tmp, '--all', 'HEAD']);
|
|
89
|
+
const bytes = fs.readFileSync(tmp);
|
|
90
|
+
return { bytes, sha256: sha256(bytes) };
|
|
91
|
+
} finally {
|
|
92
|
+
fs.rmSync(tmp, { force: true });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function deriveCardId(card) {
|
|
97
|
+
return sha256(JSON.stringify({
|
|
98
|
+
version: card.version,
|
|
99
|
+
head: card.head,
|
|
100
|
+
branches: card.branches,
|
|
101
|
+
tags: card.tags,
|
|
102
|
+
// remotes deliberately absent: seed metadata, not identity (axiom 5).
|
|
103
|
+
// bundle bytes deliberately absent: packing is not deterministic (axiom 2).
|
|
104
|
+
}));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function deriveCheckpointId(checkpoint) {
|
|
108
|
+
return sha256(JSON.stringify({
|
|
109
|
+
baseCommit: checkpoint.baseCommit,
|
|
110
|
+
stagedPatch: sha256(checkpoint.stagedPatch),
|
|
111
|
+
unstagedPatch: sha256(checkpoint.unstagedPatch),
|
|
112
|
+
untracked: checkpoint.untracked.map((file) => ({
|
|
113
|
+
path: file.path, mode: file.mode, sha256: file.sha256,
|
|
114
|
+
})),
|
|
115
|
+
}));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function capture(repoPath, { withBundle = true } = {}) {
|
|
119
|
+
const busy = busyReason(repoPath);
|
|
120
|
+
if (busy) return { busy };
|
|
121
|
+
|
|
122
|
+
const branch = tryGitText(repoPath, ['symbolic-ref', '-q', '--short', 'HEAD']); // null = detached
|
|
123
|
+
const commit = tryGitText(repoPath, ['rev-parse', '-q', '--verify', 'HEAD']); // null = unborn
|
|
124
|
+
|
|
125
|
+
const card = {
|
|
126
|
+
version: 1,
|
|
127
|
+
head: { branch, commit, detached: branch === null && commit !== null },
|
|
128
|
+
branches: refList(repoPath, 'refs/heads'),
|
|
129
|
+
tags: refList(repoPath, 'refs/tags'),
|
|
130
|
+
remotes: remoteList(repoPath),
|
|
131
|
+
bundle: commit && withBundle ? makeBundle(repoPath) : null,
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const checkpoint = {
|
|
135
|
+
baseCommit: commit,
|
|
136
|
+
branch,
|
|
137
|
+
// Patches stay Buffers: a "text" diff of NUL-free binary content carries
|
|
138
|
+
// raw non-UTF-8 bytes, and a string conversion would corrupt it silently.
|
|
139
|
+
stagedPatch: runGit(repoPath, ['diff', '--cached', '--binary', '--full-index', commit ? 'HEAD' : EMPTY_TREE]),
|
|
140
|
+
unstagedPatch: runGit(repoPath, ['diff', '--binary', '--full-index']),
|
|
141
|
+
untracked: untrackedFiles(repoPath),
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const cardId = deriveCardId(card);
|
|
145
|
+
const checkpointId = deriveCheckpointId(checkpoint);
|
|
146
|
+
return { card, checkpoint, cardId, checkpointId, stateId: sha256(cardId + checkpointId) };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
module.exports = { capture, busyReason, gitDirPath, sha256, EMPTY_TREE };
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The follower: raw repo doorbells in, at most one honest semantic event out.
|
|
5
|
+
*
|
|
6
|
+
* The axiom: **ids identify settled states — and a settled state is the
|
|
7
|
+
* only completion git ever announces.** Every change the two documents
|
|
8
|
+
* describe settles as one of exactly two facts: the committed history
|
|
9
|
+
* moved (cardId) or the work on top of it moved (checkpointId). History-
|
|
10
|
+
* moving operations — commit, merge, rebase, checkout — end by moving a
|
|
11
|
+
* ref atomically; worktree commands — add, restore, clean, an editor
|
|
12
|
+
* saving — end the way any edit does, leaving files that settle into a
|
|
13
|
+
* new checkpointId. Ids never claim a *command* finished: an unowned
|
|
14
|
+
* writer that pauses mid-run yields two true states, not one. Operation
|
|
15
|
+
* boundaries exist only where someone owns the writer — git's busy
|
|
16
|
+
* markers for its own operations, `command()` for amalgm's — and no
|
|
17
|
+
* event-driven observer can invent one for a writer nobody owns. So we
|
|
18
|
+
* read where both ids are once the ground is still; card + checkpoint is
|
|
19
|
+
* the whole vocabulary:
|
|
20
|
+
*
|
|
21
|
+
* cardId moved -> card.changed (an operation completed)
|
|
22
|
+
* only checkpointId -> checkpoint.changed (work on top: edits,
|
|
23
|
+
* staging, untracked, clean)
|
|
24
|
+
* neither -> silence (churn, touches, echoes)
|
|
25
|
+
*
|
|
26
|
+
* Git stores the truth but gives an outside reader no bell, no atomic
|
|
27
|
+
* glance, and no record of untracked files. Everything else in this module
|
|
28
|
+
* is the plumbing that makes an outside read safe — never a third document:
|
|
29
|
+
*
|
|
30
|
+
* - **The torn-read guard.** A state travels only when two consecutive
|
|
31
|
+
* captures agree on its identity — a torn, id-inconsistent snapshot
|
|
32
|
+
* never travels, and ignored churn (which never moves the ids) cannot
|
|
33
|
+
* starve detection (`capMs` keeps trying, the pair keeps it honest).
|
|
34
|
+
* - **Busy markers.** Git saying "I am between two cards; there is no
|
|
35
|
+
* coherent state to photograph yet." Their removal rings. Bisect is NOT
|
|
36
|
+
* busy — it is a mode whose checkouts must each be seen.
|
|
37
|
+
* - **Locks, with a liveness check.** A lock HELD by a living process is
|
|
38
|
+
* git mid-write: wait, and report it (`status().blocked`); the lock's
|
|
39
|
+
* deletion rings. An unheld lock is litter from a crash and blocks
|
|
40
|
+
* nothing — proven empirically: a stale lock still lets edits, `git
|
|
41
|
+
* clean`, even `update-ref` through, so honoring it would be permanent
|
|
42
|
+
* blindness. The holder check is a single shot when a capture attempt
|
|
43
|
+
* meets a lock — never a poll.
|
|
44
|
+
* - **`command(fn)` is an optimization, not an authority.** An amalgm-run
|
|
45
|
+
* command batches its noise into one capture at its end. Correctness
|
|
46
|
+
* never depends on it — the ids' movement is the completion signal
|
|
47
|
+
* for commands amalgm never sees.
|
|
48
|
+
*
|
|
49
|
+
* The follower retries itself whenever it holds firsthand evidence of a
|
|
50
|
+
* miss (a disagreeing pair, a failed capture, a failed emit). It waits on
|
|
51
|
+
* the doorbell only for endings that are filesystem events, and consumer
|
|
52
|
+
* moments (`flush()` — a session opening, an app regaining focus) are the
|
|
53
|
+
* pull valve that turns a lost doorbell's "delayed" into "delivered"
|
|
54
|
+
* without polling. Named residual: a lock-free writer that stalls across
|
|
55
|
+
* both reads looks like stillness; the next doorbell or flush corrects the
|
|
56
|
+
* ids but cannot unsend the event.
|
|
57
|
+
*/
|
|
58
|
+
|
|
59
|
+
const { execFileSync } = require('child_process');
|
|
60
|
+
const fs = require('fs');
|
|
61
|
+
const path = require('path');
|
|
62
|
+
|
|
63
|
+
const { capture, busyReason, gitDirPath } = require('./capture');
|
|
64
|
+
|
|
65
|
+
const LOCKS = ['index.lock', 'HEAD.lock', 'packed-refs.lock'];
|
|
66
|
+
|
|
67
|
+
let lsofUnavailable = false;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Does any living process hold this lock file open? Git's lockfile API
|
|
71
|
+
* keeps the fd open for the lock's whole life, so an unheld lock is litter
|
|
72
|
+
* from a crash. One subprocess per call, only ever called when a capture
|
|
73
|
+
* attempt meets a lock — never a poll. If the platform cannot answer,
|
|
74
|
+
* assume held: we never publish over a maybe-writing git.
|
|
75
|
+
*/
|
|
76
|
+
function lockHeld(lockPath) {
|
|
77
|
+
if (lsofUnavailable) return true;
|
|
78
|
+
try {
|
|
79
|
+
execFileSync('lsof', ['-t', '--', lockPath], { stdio: ['ignore', 'pipe', 'ignore'], timeout: 2_000 });
|
|
80
|
+
return true;
|
|
81
|
+
} catch (error) {
|
|
82
|
+
if (error.status === 1) return false; // lsof's "nobody": the lock is litter
|
|
83
|
+
lsofUnavailable = true;
|
|
84
|
+
console.warn('[Follower] lsof unavailable — treating locks as live:', error?.code || error?.message);
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function createFollower(options) {
|
|
90
|
+
const {
|
|
91
|
+
repoPath,
|
|
92
|
+
emit,
|
|
93
|
+
settleMs = 200, // doorbells quiet this long -> try a capture
|
|
94
|
+
capMs = 2000, // churn never quiet -> still try this often
|
|
95
|
+
last = null, // resume seed: { cardId, checkpointId }
|
|
96
|
+
// May return a promise (e.g. a capture worker): the follower awaits it,
|
|
97
|
+
// and any doorbell that lands mid-pair re-arms a retry (`pendingWake`).
|
|
98
|
+
captureFn = (repo) => capture(repo, { withBundle: false }),
|
|
99
|
+
} = options;
|
|
100
|
+
if (!repoPath) throw new Error('createFollower requires { repoPath }');
|
|
101
|
+
if (typeof emit !== 'function') throw new Error('createFollower requires { emit }');
|
|
102
|
+
|
|
103
|
+
let known = last ? { cardId: last.cardId, checkpointId: last.checkpointId } : null;
|
|
104
|
+
let owned = 0; // depth of amalgm-owned commands in flight
|
|
105
|
+
let blocked = null; // lock name while the last capture attempt met a held lock
|
|
106
|
+
let blockedPath = null;
|
|
107
|
+
let settleTimer = null;
|
|
108
|
+
let capTimer = null;
|
|
109
|
+
let firing = false; // a consumer flushing from inside emit must not recurse
|
|
110
|
+
let pendingWake = false; // a fire attempt landed while a pair was in flight
|
|
111
|
+
let closed = false;
|
|
112
|
+
|
|
113
|
+
/** The first lock present on disk, or null. Heldness is checked separately. */
|
|
114
|
+
function presentLock() {
|
|
115
|
+
let gitDir;
|
|
116
|
+
try {
|
|
117
|
+
gitDir = gitDirPath(repoPath);
|
|
118
|
+
} catch {
|
|
119
|
+
return null; // not a repo right now; capture will say so
|
|
120
|
+
}
|
|
121
|
+
const name = LOCKS.find((lock) => fs.existsSync(path.join(gitDir, lock)));
|
|
122
|
+
return name ? { name, path: path.join(gitDir, name) } : null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Self-armed retry: used only when the follower itself witnessed the miss. */
|
|
126
|
+
function retry(delayMs) {
|
|
127
|
+
if (closed) return;
|
|
128
|
+
if (settleTimer) clearTimeout(settleTimer);
|
|
129
|
+
settleTimer = setTimeout(() => {
|
|
130
|
+
settleTimer = null;
|
|
131
|
+
fire();
|
|
132
|
+
}, delayMs);
|
|
133
|
+
settleTimer.unref?.();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Emit if the ids moved. `known` advances only on successful delivery. */
|
|
137
|
+
function speak(snap) {
|
|
138
|
+
if (known && snap.cardId === known.cardId && snap.checkpointId === known.checkpointId) return true;
|
|
139
|
+
const type = !known || snap.cardId !== known.cardId ? 'card.changed' : 'checkpoint.changed';
|
|
140
|
+
try {
|
|
141
|
+
emit({
|
|
142
|
+
type,
|
|
143
|
+
path: repoPath,
|
|
144
|
+
cardId: snap.cardId,
|
|
145
|
+
checkpointId: snap.checkpointId,
|
|
146
|
+
stateId: snap.stateId,
|
|
147
|
+
card: snap.card,
|
|
148
|
+
checkpoint: snap.checkpoint,
|
|
149
|
+
});
|
|
150
|
+
} catch (error) {
|
|
151
|
+
console.warn(`[Follower] emit failed for "${repoPath}" — state stays undelivered:`, error?.message || error);
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
known = { cardId: snap.cardId, checkpointId: snap.checkpointId };
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function fire() {
|
|
159
|
+
if (closed || owned > 0) return;
|
|
160
|
+
if (firing) {
|
|
161
|
+
// With an async captureFn a doorbell can land while a pair is in
|
|
162
|
+
// flight; its timer is already consumed and the pair's answer may
|
|
163
|
+
// predate it. Remember the wake, re-arm when the pair completes.
|
|
164
|
+
// The completed pair still speaks: it read a true settled state
|
|
165
|
+
// (ids identify states, not commands). Invalidating in-flight pairs
|
|
166
|
+
// on every doorbell was considered and rejected — under sustained
|
|
167
|
+
// churn it silences the follower entirely (every pair dies young),
|
|
168
|
+
// where this rule delivers rolling true snapshots that converge.
|
|
169
|
+
pendingWake = true;
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
firing = true;
|
|
173
|
+
try {
|
|
174
|
+
await fireInner();
|
|
175
|
+
} catch (error) {
|
|
176
|
+
if (closed) return;
|
|
177
|
+
console.warn(`[Follower] capture cycle failed for "${repoPath}":`, error?.message || error);
|
|
178
|
+
retry(capMs);
|
|
179
|
+
} finally {
|
|
180
|
+
firing = false;
|
|
181
|
+
if (pendingWake) {
|
|
182
|
+
pendingWake = false;
|
|
183
|
+
retry(settleMs);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function fireInner() {
|
|
189
|
+
if (settleTimer) { clearTimeout(settleTimer); settleTimer = null; }
|
|
190
|
+
if (capTimer) { clearTimeout(capTimer); capTimer = null; }
|
|
191
|
+
|
|
192
|
+
// A lock held by a living process is git mid-write: wait, report it;
|
|
193
|
+
// the lock's deletion rings. An unheld lock is litter from a crash —
|
|
194
|
+
// it stops neither editors nor `git clean` nor `update-ref`, so
|
|
195
|
+
// honoring it would be permanent blindness, not safety.
|
|
196
|
+
const lock = presentLock();
|
|
197
|
+
if (lock && lockHeld(lock.path)) {
|
|
198
|
+
blocked = lock.name;
|
|
199
|
+
blockedPath = lock.path;
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
blocked = null;
|
|
203
|
+
blockedPath = null;
|
|
204
|
+
|
|
205
|
+
try {
|
|
206
|
+
if (busyReason(repoPath)) return; // the operation's end rings its own doorbell
|
|
207
|
+
} catch {
|
|
208
|
+
return; // not a repo right now; territory handling lives in the observer
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// The torn-read guard: two consecutive reads must agree before a state
|
|
212
|
+
// may travel — a repo mid-mutation cannot produce the same ids twice.
|
|
213
|
+
let first;
|
|
214
|
+
let second;
|
|
215
|
+
try {
|
|
216
|
+
first = await captureFn(repoPath);
|
|
217
|
+
if (closed || owned > 0) return; // the pair is stale before it finished
|
|
218
|
+
second = first && !first.busy ? await captureFn(repoPath) : null;
|
|
219
|
+
} catch (error) {
|
|
220
|
+
if (closed) return;
|
|
221
|
+
// A read tripping over a mid-flight writer (a file vanishing between
|
|
222
|
+
// list and read) is evidence of motion, not a reason to go quiet.
|
|
223
|
+
console.warn(`[Follower] capture failed for "${repoPath}":`, error?.message || error);
|
|
224
|
+
retry(capMs);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
// The world may have moved while we awaited: a close() must not be
|
|
228
|
+
// spoken over, and a command that began mid-pair owns the boundary now.
|
|
229
|
+
if (closed || owned > 0) return;
|
|
230
|
+
if (!first || first.busy || !second || second.busy) return;
|
|
231
|
+
if (first.stateId !== second.stateId) {
|
|
232
|
+
// The pair disagreeing is firsthand evidence of a writer in flight;
|
|
233
|
+
// re-arm ourselves rather than hope the write's doorbell was delivered.
|
|
234
|
+
retry(settleMs);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
if (!speak(second)) retry(capMs); // consumer failure: patient, loud, never lost
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function ring() {
|
|
241
|
+
if (closed || owned > 0) return; // the command's end is the boundary
|
|
242
|
+
if (settleTimer) clearTimeout(settleTimer);
|
|
243
|
+
settleTimer = setTimeout(() => {
|
|
244
|
+
settleTimer = null;
|
|
245
|
+
fire();
|
|
246
|
+
}, settleMs);
|
|
247
|
+
settleTimer.unref?.();
|
|
248
|
+
if (!capTimer) {
|
|
249
|
+
// Not restarted by rings: under endless churn this still fires, and
|
|
250
|
+
// the torn-read guard decides whether anything was actually still.
|
|
251
|
+
capTimer = setTimeout(() => {
|
|
252
|
+
capTimer = null;
|
|
253
|
+
fire();
|
|
254
|
+
}, capMs);
|
|
255
|
+
capTimer.unref?.();
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function beginCommand() {
|
|
260
|
+
owned += 1;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function endCommand() {
|
|
264
|
+
owned = Math.max(0, owned - 1);
|
|
265
|
+
if (owned === 0 && !closed) fire();
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Run an amalgm-owned command: no captures while it runs, one at its end. */
|
|
269
|
+
function command(fn) {
|
|
270
|
+
beginCommand();
|
|
271
|
+
let result;
|
|
272
|
+
try {
|
|
273
|
+
result = fn();
|
|
274
|
+
} catch (error) {
|
|
275
|
+
endCommand();
|
|
276
|
+
throw error;
|
|
277
|
+
}
|
|
278
|
+
if (result && typeof result.then === 'function') {
|
|
279
|
+
return Promise.resolve(result).finally(() => endCommand());
|
|
280
|
+
}
|
|
281
|
+
endCommand();
|
|
282
|
+
return result;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return {
|
|
286
|
+
ring,
|
|
287
|
+
/** Birth: capture once, immediately. With a seeded `last`, an unchanged repo is silence. */
|
|
288
|
+
start: () => fire(),
|
|
289
|
+
/** Consumer-requested capture — the "scanNow" of this layer. */
|
|
290
|
+
flush: () => fire(),
|
|
291
|
+
beginCommand,
|
|
292
|
+
endCommand,
|
|
293
|
+
command,
|
|
294
|
+
ids: () => (known ? { ...known } : null),
|
|
295
|
+
/**
|
|
296
|
+
* `blocked` is the lock that deferred the last capture attempt — checked
|
|
297
|
+
* against disk so a lock deleted behind our back never reads as blocked.
|
|
298
|
+
*/
|
|
299
|
+
status: () => ({ blocked: blocked && fs.existsSync(blockedPath) ? blocked : null }),
|
|
300
|
+
close() {
|
|
301
|
+
closed = true;
|
|
302
|
+
for (const timer of [settleTimer, capTimer]) {
|
|
303
|
+
if (timer) clearTimeout(timer);
|
|
304
|
+
}
|
|
305
|
+
settleTimer = capTimer = null;
|
|
306
|
+
},
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
module.exports = { createFollower };
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The one place this layer talks to git: plain subprocesses, no libraries.
|
|
5
|
+
* Output is Buffers (git output is not always text); errors carry git's own
|
|
6
|
+
* stderr. `tryGit` is for probes where failure is an answer, not a problem
|
|
7
|
+
* (detached HEAD, unborn HEAD, a merge that conflicts).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const { execFileSync } = require('child_process');
|
|
11
|
+
|
|
12
|
+
const MAX_BUFFER = 512 * 1024 * 1024;
|
|
13
|
+
|
|
14
|
+
function runGit(cwd, args, { input } = {}) {
|
|
15
|
+
try {
|
|
16
|
+
return execFileSync('git', args, {
|
|
17
|
+
cwd,
|
|
18
|
+
input,
|
|
19
|
+
maxBuffer: MAX_BUFFER,
|
|
20
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
21
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
|
22
|
+
});
|
|
23
|
+
} catch (error) {
|
|
24
|
+
const stderr = error.stderr ? error.stderr.toString('utf8').trim() : '';
|
|
25
|
+
error.message = `git ${args.join(' ')} failed${stderr ? `: ${stderr}` : ''}`;
|
|
26
|
+
// The mark truth readers classify by: git itself failed on this
|
|
27
|
+
// ground, distinct from a programming error in the caller.
|
|
28
|
+
error.gitFailed = `git ${args[0]}`;
|
|
29
|
+
throw error;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function tryGit(cwd, args, options) {
|
|
34
|
+
try {
|
|
35
|
+
return runGit(cwd, args, options);
|
|
36
|
+
} catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const gitText = (cwd, args, options) => runGit(cwd, args, options).toString('utf8').trim();
|
|
42
|
+
|
|
43
|
+
function tryGitText(cwd, args, options) {
|
|
44
|
+
const out = tryGit(cwd, args, options);
|
|
45
|
+
return out === null ? null : out.toString('utf8').trim();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
module.exports = { runGit, tryGit, gitText, tryGitText };
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The repocard api.
|
|
5
|
+
*
|
|
6
|
+
* capture(repoPath) → { card, checkpoint, cardId, checkpointId, stateId } | { busy }
|
|
7
|
+
* verify(repoPath, ids) → { ok, problems, cardId, checkpointId }
|
|
8
|
+
* apply(dir, state) → materialize + prove: the ids re-derived from the
|
|
9
|
+
* result must equal the ids that arrived (axiom 4).
|
|
10
|
+
* Throws if reproduction failed; only a return is
|
|
11
|
+
* "I applied this event."
|
|
12
|
+
* createFollower(opts) → repo doorbells in, card.changed /
|
|
13
|
+
* checkpoint.changed / silence out (follow.js).
|
|
14
|
+
* createCaptureWorker() → capture off the event loop: { capture, close }
|
|
15
|
+
* (capture-worker.js); pass its capture as a
|
|
16
|
+
* follower's captureFn.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const { capture } = require('./capture');
|
|
20
|
+
const { materialize } = require('./apply');
|
|
21
|
+
const { createFollower } = require('./follow');
|
|
22
|
+
const { createCaptureWorker } = require('./capture-worker');
|
|
23
|
+
|
|
24
|
+
function verify(repoPath, expected) {
|
|
25
|
+
const derived = capture(repoPath, { withBundle: false });
|
|
26
|
+
if (derived.busy) {
|
|
27
|
+
return { ok: false, problems: [`repo is busy: ${derived.busy}`], cardId: null, checkpointId: null };
|
|
28
|
+
}
|
|
29
|
+
const problems = [];
|
|
30
|
+
if (derived.cardId !== expected.cardId) {
|
|
31
|
+
problems.push(`cardId mismatch: expected ${expected.cardId}, derived ${derived.cardId}`);
|
|
32
|
+
}
|
|
33
|
+
if (derived.checkpointId !== expected.checkpointId) {
|
|
34
|
+
problems.push(`checkpointId mismatch: expected ${expected.checkpointId}, derived ${derived.checkpointId}`);
|
|
35
|
+
}
|
|
36
|
+
return { ok: problems.length === 0, problems, cardId: derived.cardId, checkpointId: derived.checkpointId };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function apply(dir, state) {
|
|
40
|
+
materialize(dir, state.card, state.checkpoint);
|
|
41
|
+
const proof = verify(dir, state);
|
|
42
|
+
if (!proof.ok) {
|
|
43
|
+
throw new Error(`apply did not reproduce the state: ${proof.problems.join('; ')}`);
|
|
44
|
+
}
|
|
45
|
+
return { cardId: proof.cardId, checkpointId: proof.checkpointId };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
module.exports = { capture, apply, verify, createFollower, createCaptureWorker };
|
|
@@ -24,6 +24,7 @@ const { handleLocalRoutes } = require('./routes/local');
|
|
|
24
24
|
const { handleCredentialRoutes } = require('./routes/credentials');
|
|
25
25
|
const { handleInboundRoutes } = require('./routes/inbound');
|
|
26
26
|
const { handleAppRoutes } = require('./routes/apps');
|
|
27
|
+
const { handleEntityRoutes } = require('./routes/entities');
|
|
27
28
|
|
|
28
29
|
const DEFAULT_ROUTE_GROUPS = [
|
|
29
30
|
handleHealthRoutes,
|
|
@@ -42,6 +43,7 @@ const DEFAULT_ROUTE_GROUPS = [
|
|
|
42
43
|
handleCredentialRoutes,
|
|
43
44
|
handleInboundRoutes,
|
|
44
45
|
handleAppRoutes,
|
|
46
|
+
handleEntityRoutes,
|
|
45
47
|
];
|
|
46
48
|
|
|
47
49
|
function createLocalServiceRouter(routeGroups = DEFAULT_ROUTE_GROUPS) {
|