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.
Files changed (157) hide show
  1. package/README.md +12 -0
  2. package/lib/cli.js +146 -8
  3. package/lib/layout.js +89 -4
  4. package/lib/shared-realtime-tunnel.js +384 -43
  5. package/lib/supervisor.js +0 -3
  6. package/package.json +2 -2
  7. package/runtime/scripts/amalgm-mcp/adapters/contract.js +197 -0
  8. package/runtime/scripts/amalgm-mcp/adapters/filesystem.js +135 -0
  9. package/runtime/scripts/amalgm-mcp/adapters/git.js +84 -0
  10. package/runtime/scripts/amalgm-mcp/adapters/index.js +43 -0
  11. package/runtime/scripts/amalgm-mcp/adapters/reference.js +31 -0
  12. package/runtime/scripts/amalgm-mcp/adapters/truth.js +19 -0
  13. package/runtime/scripts/amalgm-mcp/browser/cookie-jar.js +20 -68
  14. package/runtime/scripts/amalgm-mcp/index.js +4 -0
  15. package/runtime/scripts/amalgm-mcp/lib/layout.js +89 -4
  16. package/runtime/scripts/amalgm-mcp/observer/README.md +259 -0
  17. package/runtime/scripts/amalgm-mcp/observer/apply.js +150 -0
  18. package/runtime/scripts/amalgm-mcp/observer/continuity.js +111 -0
  19. package/runtime/scripts/amalgm-mcp/observer/edges.js +111 -0
  20. package/runtime/scripts/amalgm-mcp/observer/index.js +1146 -0
  21. package/runtime/scripts/amalgm-mcp/observer/scan.js +387 -0
  22. package/runtime/scripts/amalgm-mcp/observer/store.js +262 -0
  23. package/runtime/scripts/amalgm-mcp/observer/verify.js +200 -0
  24. package/runtime/scripts/amalgm-mcp/observer/watch.js +62 -0
  25. package/runtime/scripts/amalgm-mcp/project-context/store.js +19 -87
  26. package/runtime/scripts/amalgm-mcp/registration/classify.js +33 -0
  27. package/runtime/scripts/amalgm-mcp/registration/entity-cloud.js +456 -0
  28. package/runtime/scripts/amalgm-mcp/registration/entity-content.js +345 -0
  29. package/runtime/scripts/amalgm-mcp/registration/index.js +752 -0
  30. package/runtime/scripts/amalgm-mcp/registration/refusal.js +42 -0
  31. package/runtime/scripts/amalgm-mcp/registration/repo-followers.js +127 -0
  32. package/runtime/scripts/amalgm-mcp/registration/repo-states.js +109 -0
  33. package/runtime/scripts/amalgm-mcp/registration/service.js +310 -0
  34. package/runtime/scripts/amalgm-mcp/registration/tree.js +178 -0
  35. package/runtime/scripts/amalgm-mcp/registry/evidence.js +826 -0
  36. package/runtime/scripts/amalgm-mcp/registry/index.js +666 -0
  37. package/runtime/scripts/amalgm-mcp/registry/store.js +290 -0
  38. package/runtime/scripts/amalgm-mcp/repocard/README.md +103 -0
  39. package/runtime/scripts/amalgm-mcp/repocard/apply.js +111 -0
  40. package/runtime/scripts/amalgm-mcp/repocard/capture-worker.js +94 -0
  41. package/runtime/scripts/amalgm-mcp/repocard/capture.js +149 -0
  42. package/runtime/scripts/amalgm-mcp/repocard/follow.js +310 -0
  43. package/runtime/scripts/amalgm-mcp/repocard/git.js +48 -0
  44. package/runtime/scripts/amalgm-mcp/repocard/index.js +48 -0
  45. package/runtime/scripts/amalgm-mcp/server/local-service-router.js +2 -0
  46. package/runtime/scripts/amalgm-mcp/server/routes/entities.js +287 -0
  47. package/runtime/scripts/amalgm-mcp/server/routes/state.js +0 -13
  48. package/runtime/scripts/amalgm-mcp/state/attachments.js +11 -45
  49. package/runtime/scripts/amalgm-mcp/state/db.js +46 -215
  50. package/runtime/scripts/amalgm-mcp/state/docs.js +301 -1101
  51. package/runtime/scripts/amalgm-mcp/state/mutation-contracts.js +0 -99
  52. package/runtime/scripts/amalgm-mcp/state/mutations.js +130 -507
  53. package/runtime/scripts/amalgm-mcp/state/promotions.js +1 -21
  54. package/runtime/scripts/amalgm-mcp/state/replicas.js +13 -134
  55. package/runtime/scripts/amalgm-mcp/state/shared-rest.js +3 -46
  56. package/runtime/scripts/amalgm-mcp/tests/adapters.test.js +379 -0
  57. package/runtime/scripts/amalgm-mcp/tests/browser-cookie-cloud.test.js +0 -193
  58. package/runtime/scripts/amalgm-mcp/tests/doorbell.matrix.life.test.js +449 -0
  59. package/runtime/scripts/amalgm-mcp/tests/doorbell.matrix.rig.js +107 -0
  60. package/runtime/scripts/amalgm-mcp/tests/doorbell.matrix.watch.test.js +213 -0
  61. package/runtime/scripts/amalgm-mcp/tests/entity-oracle/adapter.js +72 -0
  62. package/runtime/scripts/amalgm-mcp/tests/entity-oracle/assert.js +497 -0
  63. package/runtime/scripts/amalgm-mcp/tests/entity-oracle/bind.js +91 -0
  64. package/runtime/scripts/amalgm-mcp/tests/entity-oracle/graph.js +1031 -0
  65. package/runtime/scripts/amalgm-mcp/tests/entity-oracle/ledger.js +234 -0
  66. package/runtime/scripts/amalgm-mcp/tests/entity-oracle/referee.js +118 -0
  67. package/runtime/scripts/amalgm-mcp/tests/entity.oracle.test.js +1365 -0
  68. package/runtime/scripts/amalgm-mcp/tests/entity.registry.test.js +967 -0
  69. package/runtime/scripts/amalgm-mcp/tests/entity.rig.js +239 -0
  70. package/runtime/scripts/amalgm-mcp/tests/entity.storm.test.js +486 -0
  71. package/runtime/scripts/amalgm-mcp/tests/fake-watch.js +35 -0
  72. package/runtime/scripts/amalgm-mcp/tests/observer.links.test.js +289 -0
  73. package/runtime/scripts/amalgm-mcp/tests/observer.rig.js +103 -0
  74. package/runtime/scripts/amalgm-mcp/tests/observer.roots.test.js +505 -0
  75. package/runtime/scripts/amalgm-mcp/tests/observer.storm.test.js +389 -0
  76. package/runtime/scripts/amalgm-mcp/tests/observer.territory.test.js +845 -0
  77. package/runtime/scripts/amalgm-mcp/tests/observer.test.js +657 -0
  78. package/runtime/scripts/amalgm-mcp/tests/project-context.test.js +17 -32
  79. package/runtime/scripts/amalgm-mcp/tests/registration.service.test.js +256 -0
  80. package/runtime/scripts/amalgm-mcp/tests/registration.test.js +931 -0
  81. package/runtime/scripts/amalgm-mcp/tests/repocard.storm.test.js +284 -0
  82. package/runtime/scripts/amalgm-mcp/tests/repocard.test.js +371 -0
  83. package/runtime/scripts/amalgm-mcp/tests/repofollow.storm.test.js +435 -0
  84. package/runtime/scripts/amalgm-mcp/tests/repofollow.test.js +675 -0
  85. package/runtime/scripts/amalgm-mcp/tests/shared-tunnel-rehydration.test.js +0 -84
  86. package/runtime/scripts/amalgm-mcp/tests/state-docs.test.js +13 -376
  87. package/runtime/scripts/amalgm-mcp/tests/state-mutations.test.js +0 -309
  88. package/runtime/scripts/amalgm-mcp/tests/state-shared-replica.test.js +30 -758
  89. package/runtime/scripts/amalgm-mcp/tests/workspace-cards-store.test.js +3 -72
  90. package/runtime/scripts/amalgm-mcp/tests/workspace-cards.test.js +1 -89
  91. package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint-reducer.test.js +24 -55
  92. package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint.test.js +141 -424
  93. package/runtime/scripts/amalgm-mcp/tests/workspace-object-tunnel.test.js +3 -171
  94. package/runtime/scripts/amalgm-mcp/tests/workspace-objects.test.js +7 -220
  95. package/runtime/scripts/amalgm-mcp/tests/workspace-publication-invariants.test.js +236 -179
  96. package/runtime/scripts/amalgm-mcp/tests/workspace-reducer.test.js +326 -2228
  97. package/runtime/scripts/amalgm-mcp/tests/workspace-tree-cloud.test.js +3 -5319
  98. package/runtime/scripts/amalgm-mcp/tests/workspace-tree-core.test.js +1 -191
  99. package/runtime/scripts/amalgm-mcp/tests/workspace-tree-store.test.js +17 -3140
  100. package/runtime/scripts/amalgm-mcp/tests/workspace-wall.test.js +740 -137
  101. package/runtime/scripts/amalgm-mcp/workspace/access-store.js +1 -5
  102. package/runtime/scripts/amalgm-mcp/workspace/card.js +11 -35
  103. package/runtime/scripts/amalgm-mcp/workspace/cards.js +14 -39
  104. package/runtime/scripts/amalgm-mcp/workspace/checkpoint.js +147 -587
  105. package/runtime/scripts/amalgm-mcp/workspace/git.js +34 -406
  106. package/runtime/scripts/amalgm-mcp/workspace/merge.js +181 -102
  107. package/runtime/scripts/amalgm-mcp/workspace/objects.js +56 -221
  108. package/runtime/scripts/amalgm-mcp/workspace/reducer.js +538 -1964
  109. package/runtime/scripts/amalgm-mcp/workspace/rest.js +28 -49
  110. package/runtime/scripts/amalgm-mcp/workspace/store.js +4 -77
  111. package/runtime/scripts/amalgm-mcp/workspace/transition.js +192 -1734
  112. package/runtime/scripts/amalgm-mcp/workspace/tree/inclusion.js +1 -7
  113. package/runtime/scripts/amalgm-mcp/workspace/tree/local-state.js +61 -73
  114. package/runtime/scripts/amalgm-mcp/workspace/tree/reducer.js +6 -51
  115. package/runtime/scripts/amalgm-mcp/workspace/tree/snapshot.js +8 -79
  116. package/runtime/scripts/amalgm-mcp/workspace/tree-cloud.js +146 -3461
  117. package/runtime/scripts/amalgm-mcp/workspace/tree-store.js +71 -899
  118. package/runtime/scripts/amalgm-mcp/workspace/wall.js +538 -370
  119. package/runtime/scripts/local-gateway.js +1 -0
  120. package/runtime/scripts/amalgm-mcp/state/doc-disk.js +0 -550
  121. package/runtime/scripts/amalgm-mcp/tests/code-project-stream.test.js +0 -399
  122. package/runtime/scripts/amalgm-mcp/tests/fixtures/code-project-stream-v2.json +0 -1
  123. package/runtime/scripts/amalgm-mcp/tests/workspace-durable.test.js +0 -24
  124. package/runtime/scripts/amalgm-mcp/tests/workspace-local-intent.test.js +0 -1999
  125. package/runtime/scripts/amalgm-mcp/tests/workspace-native-file-transaction.test.js +0 -364
  126. package/runtime/scripts/amalgm-mcp/tests/workspace-provenance.test.js +0 -185
  127. package/runtime/scripts/amalgm-mcp/tests/workspace-reducer-liveness.test.js +0 -26
  128. package/runtime/scripts/amalgm-mcp/tests/workspace-sync-stats.test.js +0 -119
  129. package/runtime/scripts/amalgm-mcp/tests/workspace-transition.test.js +0 -1446
  130. package/runtime/scripts/amalgm-mcp/workspace/content.js +0 -32
  131. package/runtime/scripts/amalgm-mcp/workspace/durable.js +0 -45
  132. package/runtime/scripts/amalgm-mcp/workspace/intent.js +0 -535
  133. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/BUILD.md +0 -31
  134. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/bin/darwin-arm64/amalgm-file-transaction +0 -0
  135. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/bin/linux-x64/amalgm-file-transaction +0 -0
  136. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/build.mjs +0 -72
  137. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/client.js +0 -165
  138. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/go.mod +0 -5
  139. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/go.sum +0 -2
  140. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/main.go +0 -2201
  141. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/manifest.json +0 -19
  142. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/move_darwin.go +0 -18
  143. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/move_linux.go +0 -18
  144. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/process_darwin.go +0 -33
  145. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/process_linux.go +0 -47
  146. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/verify.mjs +0 -43
  147. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/writers_darwin.go +0 -114
  148. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/writers_linux.go +0 -84
  149. package/runtime/scripts/amalgm-mcp/workspace/project-stream.js +0 -771
  150. package/runtime/scripts/amalgm-mcp/workspace/provenance.js +0 -240
  151. package/runtime/scripts/amalgm-mcp/workspace/sync-stats.js +0 -240
  152. package/runtime/scripts/amalgm-mcp/workspace/tree/floor.js +0 -100
  153. package/runtime/scripts/amalgm-mcp/workspace/tree/observation-holds.js +0 -127
  154. package/runtime/scripts/amalgm-mcp/workspace/tree/path-state.js +0 -12
  155. package/runtime/scripts/amalgm-mcp/workspace/tree/root-binding.js +0 -125
  156. package/runtime/scripts/amalgm-mcp/workspace/tree/symlinks.js +0 -76
  157. 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) {