amalgm 0.1.158 → 0.1.159

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.
@@ -0,0 +1,437 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * The repo-card store and service: this machine's durable map of every
5
+ * tracked worktree.
6
+ *
7
+ * The tracked world is the workspace registry. refreshRepoCards()
8
+ * recomputes it — one refresh runs at a time, concurrent callers coalesce
9
+ * onto the next pass — writes only what changed, and bumps a card's
10
+ * generation exactly when its fingerprint or health moves. Absence is a
11
+ * state, not an erasure: a card leaves the map only when git itself
12
+ * settles the removal (the repo no longer registers that worktree) or
13
+ * the user untracks the folder that reached it — and untrack-deletes are
14
+ * only settled on a pass that scanned every tracked root. Everything
15
+ * else is health — missing, broken, inaccessible — carried on the last
16
+ * good observation.
17
+ *
18
+ * The service keeps the map live without anyone asking: a doorbell on
19
+ * each checkout's git directory rings on commits, checkouts, staging and
20
+ * in-flight operations, and a slow reconcile pass catches what no
21
+ * doorbell can see (working-tree edits, new repos in tracked folders).
22
+ */
23
+
24
+ const fs = require('fs');
25
+ const path = require('path');
26
+ const { openLocalDb } = require('../state/db');
27
+ const { insertStateEvent, publishStateEvent, subscribeStateEvents } = require('../state/events');
28
+ const { listWorkspaces } = require('./store');
29
+ const { buildRepoCard, cardFingerprint, discoverWorktrees } = require('./card');
30
+ const { GitError, classifyDirectory } = require('./git');
31
+
32
+ const DOORBELL_QUIET_MS = 250;
33
+ const RECONCILE_INTERVAL_MS = 30_000;
34
+
35
+ const service = {
36
+ started: false,
37
+ reconcileTimer: null,
38
+ workspaceTimer: null,
39
+ unsubscribe: null,
40
+ doorbells: new Map(), // replicaId → { watchers, timer }
41
+ counters: { refreshes: 0, doorbellRings: 0 },
42
+ lastRefresh: null,
43
+ };
44
+
45
+ function rowToCard(row) {
46
+ return {
47
+ ...JSON.parse(row.card_json),
48
+ health: row.health,
49
+ generation: row.generation,
50
+ firstSeenAt: row.first_seen_at,
51
+ updatedAt: row.updated_at,
52
+ };
53
+ }
54
+
55
+ function listRepoCards() {
56
+ return openLocalDb()
57
+ .prepare('SELECT * FROM repo_cards ORDER BY path')
58
+ .all()
59
+ .map(rowToCard);
60
+ }
61
+
62
+ function trackedRoots() {
63
+ return listWorkspaces()
64
+ .filter((workspace) => workspace.path && workspace.status === 'ready')
65
+ .map((workspace) => workspace.path);
66
+ }
67
+
68
+ /** The primary repo folder a git dir belongs to: '<repo>/.git',
69
+ * '<repo>/.git/worktrees/<name>', and '<super>/.git/modules/<name>' all
70
+ * answer to the folder in front of '/.git'. */
71
+ function repoPathOfGitDir(gitDir) {
72
+ const marker = gitDir.lastIndexOf(`${path.sep}.git`);
73
+ return marker > 0 ? gitDir.slice(0, marker) : path.dirname(gitDir);
74
+ }
75
+
76
+ /**
77
+ * One namespace for path comparison. Card paths are realpaths (they come
78
+ * from live checkouts); registered roots are stored as the user gave them
79
+ * and may reach the same place through symlinks (`/var` vs `/private/var`).
80
+ * Resolve through the nearest ancestor that still exists, so a deleted
81
+ * path canonicalizes the same way it did while alive.
82
+ */
83
+ function canonicalPath(candidate) {
84
+ let base = candidate;
85
+ const tail = [];
86
+ while (!fs.existsSync(base)) {
87
+ const parent = path.dirname(base);
88
+ if (parent === base) break;
89
+ tail.unshift(path.basename(base));
90
+ base = parent;
91
+ }
92
+ try {
93
+ base = fs.realpathSync(base);
94
+ } catch {
95
+ /* keep the unresolved form; comparison degrades to string equality */
96
+ }
97
+ return path.join(base, ...tail);
98
+ }
99
+
100
+ /**
101
+ * Would a registered root reach this path today? True when the path is a
102
+ * registered root itself, sits one level under a folder-of-repos root, or
103
+ * contains a registered root (a workspace inside the repo is covered by
104
+ * the repo that owns it). Pure string geometry over canonical paths — the
105
+ * paths involved may no longer exist on disk.
106
+ */
107
+ function coveredByRoots(candidate, canonicalRoots) {
108
+ const target = canonicalPath(candidate);
109
+ return canonicalRoots.includes(target)
110
+ || canonicalRoots.includes(path.dirname(target))
111
+ || canonicalRoots.some((root) => root.startsWith(target + path.sep));
112
+ }
113
+
114
+ /**
115
+ * Is this card's checkout reachable by the registrations of today —
116
+ * directly, or through the primary repo that owns its git dir (how linked
117
+ * worktrees and submodules are discovered)? Coverage is always judged
118
+ * against the current registration list, never against the root recorded
119
+ * when the card was written: registrations change without any git
120
+ * activity, and a stale record must never decide an erasure. Geometry errs
121
+ * toward coverage; the cost of a wrong `missing` is a lingering card,
122
+ * the cost of a wrong delete is a broken guarantee.
123
+ */
124
+ function reachableByCurrentRoots(row, canonicalRoots) {
125
+ return coveredByRoots(row.path, canonicalRoots)
126
+ || coveredByRoots(repoPathOfGitDir(row.git_dir), canonicalRoots);
127
+ }
128
+
129
+ /**
130
+ * What a stored card means now that its worktree did not surface.
131
+ * Probes are read-only: file existence plus one classification.
132
+ * Returns { op: 'health', health } or { op: 'delete', settledBy }.
133
+ */
134
+ async function dispositionAbsent(row, canonicalRoots) {
135
+ const card = JSON.parse(row.card_json);
136
+ const stillTracked = reachableByCurrentRoots(row, canonicalRoots);
137
+
138
+ if (!fs.existsSync(row.path)) {
139
+ // git settles a linked worktree's (or submodule's) removal by deleting
140
+ // its git dir out of the owning repo. A primary's git dir dies with
141
+ // the folder, which settles nothing — someone deleted the repo and
142
+ // the card must say so for as long as the folder stays tracked.
143
+ if (card.kind !== 'primary' && !fs.existsSync(row.git_dir)) {
144
+ return { op: 'delete', settledBy: 'worktree-removed' };
145
+ }
146
+ if (stillTracked) {
147
+ return { op: 'health', health: 'missing' };
148
+ }
149
+ return { op: 'delete', settledBy: 'untracked' };
150
+ }
151
+
152
+ let classified;
153
+ try {
154
+ classified = await classifyDirectory(row.path);
155
+ } catch (error) {
156
+ if (error instanceof GitError) return { op: 'health', health: 'broken' };
157
+ if (error.code === 'EACCES' || error.code === 'EPERM') return { op: 'health', health: 'inaccessible' };
158
+ return { op: 'health', health: 'missing' };
159
+ }
160
+ if (classified.kind !== 'worktree') {
161
+ if (stillTracked) return { op: 'health', health: 'broken' };
162
+ return { op: 'delete', settledBy: 'untracked' };
163
+ }
164
+ // The path answers as a live checkout that no scanned root reaches any
165
+ // more (or as a different repo reusing the path): tracking ended.
166
+ return { op: 'delete', settledBy: 'untracked' };
167
+ }
168
+
169
+ async function runRefresh() {
170
+ const startedAt = Date.now();
171
+ service.counters.refreshes += 1;
172
+
173
+ const roots = trackedRoots();
174
+ const targets = new Map(); // worktree root → registry root that reached it
175
+ const skipped = [];
176
+ const rootErrors = [];
177
+ for (const root of roots) {
178
+ try {
179
+ const discovered = await discoverWorktrees(root);
180
+ for (const worktree of discovered.worktrees) {
181
+ if (!targets.has(worktree)) targets.set(worktree, root);
182
+ }
183
+ skipped.push(...discovered.skipped);
184
+ } catch (error) {
185
+ rootErrors.push({ root, error: error.message });
186
+ }
187
+ }
188
+
189
+ const built = [];
190
+ for (const [target, root] of targets) {
191
+ try {
192
+ built.push({ card: await buildRepoCard(target), root });
193
+ } catch (error) {
194
+ skipped.push({ path: target, reason: 'observation-failed', error: error.message });
195
+ }
196
+ }
197
+
198
+ const db = openLocalDb();
199
+ const builtReplicas = new Set(built.map((entry) => entry.card.replica));
200
+ const absentRows = db.prepare('SELECT * FROM repo_cards').all()
201
+ .filter((row) => !builtReplicas.has(row.replica_id));
202
+ const canonicalRoots = roots.map(canonicalPath);
203
+ const decisions = [];
204
+ for (const row of absentRows) {
205
+ const decision = await dispositionAbsent(row, canonicalRoots);
206
+ // A pass that failed to scan a root is blind, and a blind pass never
207
+ // settles an untrack — only git-settled removals apply.
208
+ if (decision.op === 'delete' && decision.settledBy === 'untracked' && rootErrors.length > 0) continue;
209
+ if (decision.op === 'health' && decision.health === row.health) continue;
210
+ decisions.push({ row, decision });
211
+ }
212
+
213
+ const events = [];
214
+ const stats = {
215
+ roots: roots.length,
216
+ discovered: targets.size,
217
+ changed: 0,
218
+ healthChanged: 0,
219
+ removed: 0,
220
+ rootErrors: rootErrors.length,
221
+ };
222
+
223
+ db.transaction(() => {
224
+ const readRow = db.prepare('SELECT * FROM repo_cards WHERE replica_id = ?');
225
+ const upsert = db.prepare(`
226
+ INSERT INTO repo_cards (
227
+ replica_id, path, git_dir, root, health, fingerprint,
228
+ generation, first_seen_at, updated_at, card_json
229
+ )
230
+ VALUES (?, ?, ?, ?, 'healthy', ?, ?, ?, ?, ?)
231
+ ON CONFLICT(replica_id) DO UPDATE SET
232
+ path = excluded.path,
233
+ git_dir = excluded.git_dir,
234
+ root = excluded.root,
235
+ health = excluded.health,
236
+ fingerprint = excluded.fingerprint,
237
+ generation = excluded.generation,
238
+ updated_at = excluded.updated_at,
239
+ card_json = excluded.card_json
240
+ `);
241
+
242
+ const repairRoot = db.prepare('UPDATE repo_cards SET root = ? WHERE replica_id = ?');
243
+
244
+ for (const { card, root } of built) {
245
+ const row = readRow.get(card.replica);
246
+ const fingerprint = cardFingerprint(card);
247
+ if (row && row.fingerprint === fingerprint && row.health === 'healthy') {
248
+ // Which registration covers a checkout can change with no git
249
+ // activity at all. Absence decisions no longer read this column,
250
+ // but it still answers "why is this card on the map" — keep it
251
+ // truthful. Bookkeeping only: no generation bump, no event.
252
+ if (row.root !== root) repairRoot.run(root, card.replica);
253
+ continue;
254
+ }
255
+
256
+ const generation = row ? row.generation + 1 : 1;
257
+ const firstSeenAt = row ? row.first_seen_at : card.observedAt;
258
+ upsert.run(
259
+ card.replica, card.path, card.gitDir, root, fingerprint,
260
+ generation, firstSeenAt, card.observedAt, JSON.stringify(card),
261
+ );
262
+ stats.changed += 1;
263
+ events.push(insertStateEvent(db, {
264
+ resource: 'repo-cards',
265
+ op: row ? 'update' : 'insert',
266
+ id: card.replica,
267
+ value: { ...card, health: 'healthy', generation, firstSeenAt, updatedAt: card.observedAt },
268
+ source: 'repo-cards:refresh',
269
+ }));
270
+ }
271
+
272
+ for (const { row, decision } of decisions) {
273
+ if (decision.op === 'delete') {
274
+ db.prepare('DELETE FROM repo_cards WHERE replica_id = ?').run(row.replica_id);
275
+ stats.removed += 1;
276
+ events.push(insertStateEvent(db, {
277
+ resource: 'repo-cards',
278
+ op: 'delete',
279
+ id: row.replica_id,
280
+ source: 'repo-cards:refresh',
281
+ }));
282
+ continue;
283
+ }
284
+ const now = new Date().toISOString();
285
+ const generation = row.generation + 1;
286
+ db.prepare('UPDATE repo_cards SET health = ?, generation = ?, updated_at = ? WHERE replica_id = ?')
287
+ .run(decision.health, generation, now, row.replica_id);
288
+ stats.healthChanged += 1;
289
+ events.push(insertStateEvent(db, {
290
+ resource: 'repo-cards',
291
+ op: 'update',
292
+ id: row.replica_id,
293
+ value: {
294
+ ...JSON.parse(row.card_json),
295
+ health: decision.health,
296
+ generation,
297
+ firstSeenAt: row.first_seen_at,
298
+ updatedAt: now,
299
+ },
300
+ source: 'repo-cards:refresh',
301
+ }));
302
+ }
303
+ })();
304
+
305
+ for (const event of events) publishStateEvent(event);
306
+
307
+ const cards = listRepoCards();
308
+ syncDoorbells(cards);
309
+ service.lastRefresh = { ...stats, at: new Date().toISOString(), durationMs: Date.now() - startedAt };
310
+ return { cards, skipped, rootErrors, stats };
311
+ }
312
+
313
+ let running = null;
314
+ let followUp = null;
315
+
316
+ /**
317
+ * Refresh with single-flight coherence: one pass at a time, and a call
318
+ * that arrives mid-pass gets the next full pass — never a stale answer,
319
+ * never two passes racing each other into the store.
320
+ */
321
+ function refreshRepoCards() {
322
+ if (running) {
323
+ followUp ??= running.catch(() => {}).then(() => refreshRepoCards());
324
+ return followUp;
325
+ }
326
+ followUp = null;
327
+ running = runRefresh().finally(() => {
328
+ running = null;
329
+ });
330
+ return running;
331
+ }
332
+
333
+ // --- the live service: doorbells + reconcile ------------------------------
334
+
335
+ function ringDoorbell(replicaId) {
336
+ const bell = service.doorbells.get(replicaId);
337
+ if (!bell || bell.timer) return;
338
+ service.counters.doorbellRings += 1;
339
+ bell.timer = setTimeout(() => {
340
+ bell.timer = null;
341
+ refreshRepoCards().catch((error) => console.warn('[RepoCards] doorbell refresh failed:', error.message));
342
+ }, DOORBELL_QUIET_MS);
343
+ bell.timer.unref?.();
344
+ }
345
+
346
+ function watchGitDir(card) {
347
+ const watchers = [];
348
+ const ring = () => ringDoorbell(card.replica);
349
+ // The git dir itself carries HEAD, index, and operation markers; logs/
350
+ // carries the reflog, which moves on every commit, checkout, and reset.
351
+ for (const dir of [card.gitDir, path.join(card.gitDir, 'logs')]) {
352
+ try {
353
+ const watcher = fs.watch(dir, ring);
354
+ watcher.on('error', () => watcher.close());
355
+ watchers.push(watcher);
356
+ } catch {
357
+ // The directory vanished between observation and watch; the next
358
+ // reconcile pass re-syncs the bells.
359
+ }
360
+ }
361
+ return watchers;
362
+ }
363
+
364
+ function dropDoorbell(replicaId) {
365
+ const bell = service.doorbells.get(replicaId);
366
+ if (!bell) return;
367
+ for (const watcher of bell.watchers) watcher.close();
368
+ if (bell.timer) clearTimeout(bell.timer);
369
+ service.doorbells.delete(replicaId);
370
+ }
371
+
372
+ function syncDoorbells(cards) {
373
+ if (!service.started) return;
374
+ const healthy = new Map(
375
+ cards.filter((card) => card.health === 'healthy').map((card) => [card.replica, card]),
376
+ );
377
+ for (const replicaId of [...service.doorbells.keys()]) {
378
+ if (!healthy.has(replicaId)) dropDoorbell(replicaId);
379
+ }
380
+ for (const [replicaId, card] of healthy) {
381
+ if (!service.doorbells.has(replicaId)) {
382
+ service.doorbells.set(replicaId, { watchers: watchGitDir(card), timer: null });
383
+ }
384
+ }
385
+ }
386
+
387
+ function scheduleWorkspaceRefresh() {
388
+ if (service.workspaceTimer) return;
389
+ service.workspaceTimer = setTimeout(() => {
390
+ service.workspaceTimer = null;
391
+ refreshRepoCards().catch((error) => console.warn('[RepoCards] workspace refresh failed:', error.message));
392
+ }, DOORBELL_QUIET_MS);
393
+ service.workspaceTimer.unref?.();
394
+ }
395
+
396
+ function startRepoCardService() {
397
+ if (service.started) return;
398
+ service.started = true;
399
+ refreshRepoCards().catch((error) => console.warn('[RepoCards] initial refresh failed:', error.message));
400
+ service.unsubscribe = subscribeStateEvents((event) => {
401
+ if (event.resource === 'workspaces') scheduleWorkspaceRefresh();
402
+ });
403
+ service.reconcileTimer = setInterval(() => {
404
+ refreshRepoCards().catch((error) => console.warn('[RepoCards] reconcile failed:', error.message));
405
+ }, RECONCILE_INTERVAL_MS);
406
+ service.reconcileTimer.unref?.();
407
+ }
408
+
409
+ function stopRepoCardService() {
410
+ if (!service.started) return;
411
+ service.started = false;
412
+ clearInterval(service.reconcileTimer);
413
+ service.reconcileTimer = null;
414
+ if (service.workspaceTimer) clearTimeout(service.workspaceTimer);
415
+ service.workspaceTimer = null;
416
+ service.unsubscribe?.();
417
+ service.unsubscribe = null;
418
+ for (const replicaId of [...service.doorbells.keys()]) dropDoorbell(replicaId);
419
+ }
420
+
421
+ function repoCardServiceStats() {
422
+ return {
423
+ started: service.started,
424
+ refreshes: service.counters.refreshes,
425
+ doorbellRings: service.counters.doorbellRings,
426
+ doorbells: service.doorbells.size,
427
+ lastRefresh: service.lastRefresh,
428
+ };
429
+ }
430
+
431
+ module.exports = {
432
+ listRepoCards,
433
+ refreshRepoCards,
434
+ repoCardServiceStats,
435
+ startRepoCardService,
436
+ stopRepoCardService,
437
+ };