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.
- package/package.json +1 -1
- package/runtime/scripts/amalgm-mcp/fs/rest.js +1 -1
- package/runtime/scripts/amalgm-mcp/index.js +2 -0
- package/runtime/scripts/amalgm-mcp/server/routes/state.js +4 -0
- package/runtime/scripts/amalgm-mcp/server/routes/workspace.js +16 -0
- package/runtime/scripts/amalgm-mcp/state/attachments.js +43 -0
- package/runtime/scripts/amalgm-mcp/state/db.js +35 -0
- package/runtime/scripts/amalgm-mcp/state/presence.js +175 -0
- package/runtime/scripts/amalgm-mcp/state/promotions.js +10 -0
- package/runtime/scripts/amalgm-mcp/state/rest.js +41 -0
- package/runtime/scripts/amalgm-mcp/state/shared-rest.js +20 -1
- package/runtime/scripts/amalgm-mcp/tests/state-presence.test.js +130 -0
- package/runtime/scripts/amalgm-mcp/tests/state-promotion-ownership.test.js +86 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-cards-store.test.js +326 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-cards.test.js +401 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-wall.test.js +148 -0
- package/runtime/scripts/amalgm-mcp/tools/shared-live-smoke.js +227 -0
- package/runtime/scripts/amalgm-mcp/workspace/card.js +199 -0
- package/runtime/scripts/amalgm-mcp/workspace/cards.js +437 -0
- package/runtime/scripts/amalgm-mcp/workspace/git.js +286 -0
- package/runtime/scripts/amalgm-mcp/workspace/identity.js +88 -0
- package/runtime/scripts/amalgm-mcp/workspace/rest.js +64 -0
- package/runtime/scripts/amalgm-mcp/workspace/wall.js +308 -0
|
@@ -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
|
+
};
|