amalgm 0.1.175 → 0.1.176

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 (34) hide show
  1. package/lib/shared-realtime-tunnel.js +28 -12
  2. package/package.json +1 -1
  3. package/runtime/scripts/amalgm-mcp/index.js +1 -1
  4. package/runtime/scripts/amalgm-mcp/server/routes/state.js +5 -1
  5. package/runtime/scripts/amalgm-mcp/server/routes/workspace.js +4 -0
  6. package/runtime/scripts/amalgm-mcp/state/docs.js +13 -6
  7. package/runtime/scripts/amalgm-mcp/state/migrations.js +0 -4
  8. package/runtime/scripts/amalgm-mcp/state/mutations.js +13 -1
  9. package/runtime/scripts/amalgm-mcp/state/shared-rest.js +25 -1
  10. package/runtime/scripts/amalgm-mcp/tests/state-mutations.test.js +9 -0
  11. package/runtime/scripts/amalgm-mcp/tests/state-promotion-ownership.test.js +30 -3
  12. package/runtime/scripts/amalgm-mcp/tests/workspace-cards-store.test.js +4 -5
  13. package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint-reducer.test.js +91 -0
  14. package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint.test.js +37 -11
  15. package/runtime/scripts/amalgm-mcp/tests/workspace-object-tunnel.test.js +10 -2
  16. package/runtime/scripts/amalgm-mcp/tests/workspace-objects.test.js +14 -6
  17. package/runtime/scripts/amalgm-mcp/tests/workspace-reducer.test.js +324 -0
  18. package/runtime/scripts/amalgm-mcp/tests/workspace-store.test.js +36 -1
  19. package/runtime/scripts/amalgm-mcp/tests/workspace-wall.test.js +81 -48
  20. package/runtime/scripts/amalgm-mcp/workspace/card.js +11 -1
  21. package/runtime/scripts/amalgm-mcp/workspace/cards.js +9 -21
  22. package/runtime/scripts/amalgm-mcp/workspace/checkpoint.js +187 -122
  23. package/runtime/scripts/amalgm-mcp/workspace/git.js +71 -0
  24. package/runtime/scripts/amalgm-mcp/workspace/merge.js +119 -2
  25. package/runtime/scripts/amalgm-mcp/workspace/objects.js +49 -17
  26. package/runtime/scripts/amalgm-mcp/workspace/reducer.js +582 -0
  27. package/runtime/scripts/amalgm-mcp/workspace/rest.js +15 -1
  28. package/runtime/scripts/amalgm-mcp/workspace/store.js +8 -18
  29. package/runtime/scripts/amalgm-mcp/workspace/transition.js +67 -0
  30. package/runtime/scripts/amalgm-mcp/workspace/tree/local-state.js +3 -4
  31. package/runtime/scripts/amalgm-mcp/workspace/wall.js +331 -180
  32. package/runtime/scripts/amalgm-mcp/tests/workspace-reconcile.test.js +0 -359
  33. package/runtime/scripts/amalgm-mcp/workspace/ledger.js +0 -96
  34. package/runtime/scripts/amalgm-mcp/workspace/reconcile.js +0 -452
@@ -1,26 +1,13 @@
1
1
  'use strict';
2
2
 
3
3
  /**
4
- * The machine wall: every machine's repo cards, shared across the
5
- * account's devices on the wave-3 rail.
4
+ * One ordered card+checkpoint stream per code project.
6
5
  *
7
- * The wall is one shared live document per account — a jsonl file where
8
- * each machine owns exactly one line, its full card set. Single-writer
9
- * per line means concurrent publishes from different machines are
10
- * disjoint edits the doc rail merges perfectly; the doc itself rides the
11
- * exact journal → outbox → gateway-stamp → fanout → catch-up spine every
12
- * shared document rides. No new transport, no new failure modes.
13
- *
14
- * The wall's resource id is derived from the account, so every machine
15
- * finds it without a rendezvous service: attach first; if the cloud has
16
- * never heard of it, promote it into existence; if two machines race,
17
- * exactly one create wins and the loser attaches. Access is enforced by
18
- * the gateway's membership check — another account attaching this id gets
19
- * a flat 403, which is the cross-account gate this slice must prove.
20
- *
21
- * Publishing is gated on the replica binding: until the wall is attached,
22
- * the local file stays empty (which is exactly what attachment requires)
23
- * and nothing is written. Cards therefore never ride an unbound file.
6
+ * A project is the authorization pool for one repository. Same-user
7
+ * machines derive the same resource from (owner, repo identity). A
8
+ * collaborator binds that repo identity to the owner's resource after the
9
+ * owner grants membership. Every authorized machine is then an ordinary
10
+ * writer in the same stream.
24
11
  */
25
12
 
26
13
  const crypto = require('crypto');
@@ -30,21 +17,23 @@ const { AMALGM_DIR } = require('../config');
30
17
  const { subscribeStateEvents } = require('../state/events');
31
18
  const { machineId, userId } = require('./identity');
32
19
  const { listRepoCards } = require('./cards');
20
+ const { readCheckpoint, repoKeyOfCard } = require('./checkpoint');
33
21
 
34
- const WALL_CONTRACT = 'machine-cards@4';
35
- const WALL_FILE = path.join(AMALGM_DIR, 'state', 'machine-cards-v4.jsonl');
22
+ const WALL_CONTRACT = 'code-project@1';
23
+ const PROJECT_DIR = path.join(AMALGM_DIR, 'state', 'code-projects');
24
+ const REGISTRY_FILE = path.join(PROJECT_DIR, 'projects.json');
36
25
  const PUBLISH_QUIET_MS = 25;
37
26
  const ENSURE_STEP_TIMEOUT_MS = 20_000;
38
- const ENSURE_RETRY_MS = 15_000;
27
+ const ENSURE_INTERVAL_MS = 15_000;
39
28
  const ATTACH_REJECTED = /shared_resource_read_forbidden/;
40
29
 
41
30
  const service = {
42
31
  started: false,
43
- stopRequested: false,
44
32
  publishTimer: null,
45
- ensureTimer: null,
33
+ ensureInterval: null,
46
34
  unsubscribe: null,
47
- ensure: { state: 'idle', attempts: 0, lastError: null },
35
+ ensuring: new Map(),
36
+ ensure: new Map(),
48
37
  counters: { publishes: 0, publishSkips: 0, publishFailures: 0 },
49
38
  lastPublishedAt: null,
50
39
  };
@@ -53,90 +42,230 @@ function sha256(text) {
53
42
  return crypto.createHash('sha256').update(text, 'utf8').digest('hex');
54
43
  }
55
44
 
56
- /** The account's wall resource id — same on every machine of the account. */
57
- function wallResourceId(forUserId = userId()) {
58
- if (!forUserId) return null;
45
+ function readRegistry() {
46
+ try {
47
+ const parsed = JSON.parse(fs.readFileSync(REGISTRY_FILE, 'utf8'));
48
+ return parsed && typeof parsed === 'object' ? parsed : {};
49
+ } catch {
50
+ return {};
51
+ }
52
+ }
53
+
54
+ function writeRegistry(registry) {
55
+ fs.mkdirSync(PROJECT_DIR, { recursive: true });
56
+ fs.writeFileSync(REGISTRY_FILE, `${JSON.stringify(registry, null, 2)}\n`);
57
+ }
58
+
59
+ function projectResourceId(repoKey, ownerUserId = userId()) {
60
+ if (!repoKey || !ownerUserId) return null;
59
61
  const digest = crypto.createHash('sha256')
60
- .update(`amalgm-machine-wall@4|${forUserId}`)
62
+ .update(`amalgm-code-project@1|${ownerUserId}|${repoKey}`)
61
63
  .digest('base64url');
62
- return `shr_mw4${digest.slice(0, 40)}`;
64
+ return `shr_cp1${digest.slice(0, 40)}`;
65
+ }
66
+
67
+ function projectFile(resourceId) {
68
+ const digest = crypto.createHash('sha256').update(resourceId).digest('hex').slice(0, 24);
69
+ return path.join(PROJECT_DIR, `${digest}.jsonl`);
63
70
  }
64
71
 
65
- function wallReplica() {
66
- const resourceId = wallResourceId();
67
- if (!resourceId) return null;
68
- return require('../state/replicas').getReplicaByResourceId(resourceId);
72
+ function projectForRepo(repoKey) {
73
+ if (!repoKey) return null;
74
+ const bound = readRegistry()[repoKey];
75
+ const ownerUserId = bound?.ownerUserId || userId();
76
+ const resourceId = bound?.resourceId || projectResourceId(repoKey, ownerUserId);
77
+ return resourceId ? { repoKey, resourceId, ownerUserId, file: projectFile(resourceId) } : null;
78
+ }
79
+
80
+ function bindProject(repoKey, resourceId, ownerUserId = null) {
81
+ if (!repoKey || typeof repoKey !== 'string') throw new Error('repoKey is required');
82
+ if (!/^shr_[A-Za-z0-9_-]{16,128}$/.test(String(resourceId || ''))) {
83
+ throw new Error('resourceId must be an opaque shared resource id');
84
+ }
85
+ const registry = readRegistry();
86
+ registry[repoKey] = {
87
+ resourceId,
88
+ ownerUserId: ownerUserId || null,
89
+ joinedAt: new Date().toISOString(),
90
+ };
91
+ writeRegistry(registry);
92
+ if (service.started) ensureProjectBound(projectForRepo(repoKey)).catch(() => {});
93
+ return projectForRepo(repoKey);
94
+ }
95
+
96
+ function projects() {
97
+ const byRepo = new Map();
98
+ for (const card of listRepoCards()) {
99
+ const repoKey = repoKeyOfCard(card);
100
+ if (repoKey && !byRepo.has(repoKey)) byRepo.set(repoKey, projectForRepo(repoKey));
101
+ }
102
+ for (const repoKey of Object.keys(readRegistry())) {
103
+ if (!byRepo.has(repoKey)) byRepo.set(repoKey, projectForRepo(repoKey));
104
+ }
105
+ return [...byRepo.values()].filter(Boolean);
106
+ }
107
+
108
+ function wallReplica(project) {
109
+ return require('../state/replicas').getReplicaByResourceId(project.resourceId);
69
110
  }
70
111
 
71
112
  function parseWallLine(line) {
72
113
  try {
73
114
  const entry = JSON.parse(line);
74
- if (entry?.wall !== WALL_CONTRACT || !entry.machine || !Array.isArray(entry.cards)) return null;
115
+ if (
116
+ entry?.wall !== WALL_CONTRACT
117
+ || !entry.project
118
+ || !entry.machine
119
+ || !Array.isArray(entry.cards)
120
+ ) return null;
75
121
  return entry;
76
122
  } catch {
77
123
  return null;
78
124
  }
79
125
  }
80
126
 
81
- /**
82
- * Every machine's published line, keyed by machine — the wall as this
83
- * machine currently sees it. Lines that do not parse as wall entries are
84
- * ignored; the next publish from their machine replaces them.
85
- */
86
- function readWall() {
87
- if (!fs.existsSync(WALL_FILE)) return [];
127
+ function readProjectWall(project) {
128
+ if (!fs.existsSync(project.file)) return [];
88
129
  const entries = new Map();
89
- for (const line of require('../state/docs').getDocText(WALL_FILE).split('\n')) {
130
+ for (const line of require('../state/docs').getDocText(project.file).split('\n')) {
90
131
  if (!line) continue;
91
132
  const entry = parseWallLine(line);
92
- if (entry) entries.set(entry.machine, entry);
133
+ if (entry?.project === project.repoKey) entries.set(entry.machine, entry);
93
134
  }
94
135
  return [...entries.values()];
95
136
  }
96
137
 
97
- /**
98
- * Write this machine's line — and only this machine's line — into the
99
- * wall. Other machines' parseable lines are preserved byte-for-byte in
100
- * place, so the resulting doc edit is exactly one line's splice and
101
- * concurrent publishes from other machines never overlap it.
102
- *
103
- * Lines that do not parse as wall entries are deleted, not preserved: a
104
- * garbled line is nobody's line (every writer replaces its own on the
105
- * next publish), and keeping it would make corruption permanent — no
106
- * writer would ever own the bytes. Deleting it is safe under concurrent
107
- * publishes because valid lines are disjoint per machine and the doc
108
- * rail merges a delete of garbage with any machine's splice of its own
109
- * line.
110
- */
111
- function publishWall() {
112
- const replica = wallReplica();
113
- if (!replica) {
114
- service.counters.publishSkips += 1;
115
- return { published: false, reason: 'wall-not-bound' };
138
+ function readWall(repoKey = null) {
139
+ const selected = repoKey ? [projectForRepo(repoKey)].filter(Boolean) : projects();
140
+ return selected.flatMap((project) => readProjectWall(project));
141
+ }
142
+
143
+ function samePosition(left, right) {
144
+ const sameHead = left?.head?.branch === right?.head?.branch
145
+ && left?.head?.oid === right?.head?.oid
146
+ && Boolean(left?.head?.detached) === Boolean(right?.head?.detached);
147
+ if (!sameHead) return false;
148
+ const previousRefs = new Map((left?.branches || []).map((ref) => [ref.name, ref.oid ?? null]));
149
+ const currentRefs = new Map((right?.refs || []).map((ref) => [ref.name, ref.oid]));
150
+ const names = new Set([...previousRefs.keys(), ...currentRefs.keys()]);
151
+ for (const name of names) {
152
+ if ((previousRefs.get(name) ?? null) !== (currentRefs.get(name) ?? null)) return false;
116
153
  }
154
+ return true;
155
+ }
117
156
 
118
- const docs = require('../state/docs');
119
- const current = docs.getDocText(WALL_FILE);
120
- const mine = JSON.stringify({
121
- wall: WALL_CONTRACT,
122
- machine: machineId(),
123
- publishedAt: new Date().toISOString(),
124
- cards: listRepoCards(),
157
+ function checkpointIdentity(checkpoint) {
158
+ if (!checkpoint) return null;
159
+ return JSON.stringify({
160
+ baseOid: checkpoint.baseOid,
161
+ branch: checkpoint.branch,
162
+ from: checkpoint.from,
163
+ to: checkpoint.to,
164
+ parked: checkpoint.parked || [],
165
+ });
166
+ }
167
+
168
+ function officialVersion(resourceId, mutationId) {
169
+ if (!mutationId) return null;
170
+ const mutation = require('../state/mutations').getMutation(`resource:${resourceId}`, mutationId);
171
+ return Number.isInteger(mutation?.officialCloudVersion) ? mutation.officialCloudVersion : null;
172
+ }
173
+
174
+ function stampBranches(refs, previousBranches, mutationId, resourceId) {
175
+ const current = new Map((refs || []).map((ref) => [ref.name, ref]));
176
+ const previous = new Map((previousBranches || []).map((ref) => [ref.name, ref]));
177
+ const names = [...new Set([...current.keys(), ...previous.keys()])].sort();
178
+ return names.map((name) => {
179
+ const observed = current.get(name);
180
+ const before = previous.get(name);
181
+ const oid = observed?.oid ?? null;
182
+ const unchanged = before && (before.oid ?? null) === oid;
183
+ const mutation = unchanged && before.mutationId ? before.mutationId : mutationId;
184
+ const carried = unchanged && Number.isInteger(before.version) ? before.version : null;
185
+ return {
186
+ name,
187
+ oid,
188
+ ...(observed?.objects ? { objects: observed.objects } : {}),
189
+ mutationId: mutation,
190
+ version: carried ?? officialVersion(resourceId, mutation),
191
+ };
192
+ });
193
+ }
194
+
195
+ function stampCards(cards, previousCards, mutationId, project) {
196
+ const previousByReplica = new Map(
197
+ (previousCards || []).filter((card) => card?.replica).map((card) => [card.replica, card]),
198
+ );
199
+ return cards.map((card) => {
200
+ const previous = previousByReplica.get(card.replica);
201
+ const positionMutationId = samePosition(previous, card) && previous?.positionMutationId
202
+ ? previous.positionMutationId
203
+ : mutationId;
204
+ const carriedPosition = samePosition(previous, card)
205
+ && Number.isInteger(previous?.positionVersion)
206
+ ? previous.positionVersion
207
+ : null;
208
+ const checkpoint = readCheckpoint(machineId(), project.repoKey);
209
+ const currentCheckpoint = checkpoint
210
+ && checkpoint.baseOid === card.head?.oid
211
+ && checkpoint.branch === card.head?.branch
212
+ ? checkpoint
213
+ : null;
214
+ const sameCheckpoint = checkpointIdentity(previous?.checkpoint) === checkpointIdentity(currentCheckpoint);
215
+ const checkpointMutationId = currentCheckpoint
216
+ ? (sameCheckpoint && previous?.checkpointMutationId ? previous.checkpointMutationId : mutationId)
217
+ : null;
218
+ const checkpointVersion = currentCheckpoint
219
+ ? (sameCheckpoint && Number.isInteger(previous?.checkpointVersion)
220
+ ? previous.checkpointVersion
221
+ : officialVersion(project.resourceId, checkpointMutationId))
222
+ : null;
223
+ const { refs, ...cardWithoutRawRefs } = card;
224
+ return {
225
+ ...cardWithoutRawRefs,
226
+ positionMutationId,
227
+ positionVersion: carriedPosition ?? officialVersion(project.resourceId, positionMutationId),
228
+ branches: stampBranches(refs, previous?.branches, mutationId, project.resourceId),
229
+ ...(currentCheckpoint ? {
230
+ checkpoint: currentCheckpoint,
231
+ checkpointMutationId,
232
+ checkpointVersion,
233
+ } : {}),
234
+ };
125
235
  });
236
+ }
237
+
238
+ function withoutPublishedAt(line) {
239
+ const entry = parseWallLine(line);
240
+ if (!entry) return line;
241
+ const { publishedAt, ...rest } = entry;
242
+ return JSON.stringify(rest);
243
+ }
126
244
 
245
+ function publishProject(project, rawCards) {
246
+ const replica = wallReplica(project);
247
+ if (!replica) {
248
+ service.counters.publishSkips += 1;
249
+ return { published: false, reason: 'project-not-bound', repoKey: project.repoKey };
250
+ }
251
+ const docs = require('../state/docs');
252
+ const current = docs.getDocText(project.file);
127
253
  const lines = [];
128
254
  let previous = null;
255
+ let previousEntry = null;
256
+ let slot = -1;
129
257
  let dropped = 0;
130
258
  for (const line of current.split('\n')) {
131
259
  if (!line) continue;
132
260
  const entry = parseWallLine(line);
133
- if (!entry) {
261
+ if (!entry || entry.project !== project.repoKey) {
134
262
  dropped += 1;
135
263
  } else if (entry.machine === machineId()) {
136
- // Duplicate own lines (a healed fork) collapse into the first slot.
137
264
  if (previous === null) {
138
265
  previous = line;
139
- lines.push(mine);
266
+ previousEntry = entry;
267
+ slot = lines.length;
268
+ lines.push('');
140
269
  } else {
141
270
  dropped += 1;
142
271
  }
@@ -144,122 +273,129 @@ function publishWall() {
144
273
  lines.push(line);
145
274
  }
146
275
  }
147
- if (previous === null) lines.push(mine);
148
276
 
277
+ const mutationId = `mut_project_${crypto.randomBytes(16).toString('base64url')}`;
278
+ const cards = stampCards(rawCards, previousEntry?.cards, mutationId, project);
279
+ const mine = JSON.stringify({
280
+ wall: WALL_CONTRACT,
281
+ project: project.repoKey,
282
+ machine: machineId(),
283
+ publishedAt: new Date().toISOString(),
284
+ cards,
285
+ });
286
+ if (slot < 0) lines.push(mine);
287
+ else lines[slot] = mine;
149
288
  const unchanged = dropped === 0
150
289
  && previous !== null
151
290
  && withoutPublishedAt(previous) === withoutPublishedAt(mine);
152
291
  if (unchanged) {
153
292
  service.counters.publishSkips += 1;
154
- return { published: false, reason: 'unchanged' };
293
+ return { published: false, reason: 'unchanged', repoKey: project.repoKey };
155
294
  }
156
295
 
157
- const result = docs.writeDocText(WALL_FILE, `${lines.join('\n')}\n`, { baseHash: sha256(current) });
296
+ const result = docs.writeDocText(
297
+ project.file,
298
+ `${lines.join('\n')}\n`,
299
+ { baseHash: sha256(current), mutationId },
300
+ );
158
301
  service.counters.publishes += 1;
159
302
  service.lastPublishedAt = new Date().toISOString();
160
- return { published: true, merge: result.merge, cards: listRepoCards().length, dropped };
303
+ return {
304
+ published: true,
305
+ repoKey: project.repoKey,
306
+ resourceId: project.resourceId,
307
+ mutationId: result.mutationId,
308
+ merge: result.merge,
309
+ cards: cards.length,
310
+ dropped,
311
+ };
161
312
  }
162
313
 
163
- function withoutPublishedAt(line) {
164
- const entry = parseWallLine(line);
165
- if (!entry) return line;
166
- const { publishedAt, ...rest } = entry;
167
- return JSON.stringify(rest);
314
+ function publishWall(repoKey = null) {
315
+ const allCards = listRepoCards();
316
+ const selected = repoKey ? [projectForRepo(repoKey)].filter(Boolean) : projects();
317
+ const results = selected.map((project) => publishProject(
318
+ project,
319
+ allCards.filter((card) => repoKeyOfCard(card) === project.repoKey),
320
+ ));
321
+ if (repoKey) return results[0] || { published: false, reason: 'project-missing', repoKey };
322
+ return {
323
+ published: results.some((result) => result.published),
324
+ results,
325
+ };
168
326
  }
169
327
 
170
- // --- binding the wall to the rail -----------------------------------------
171
-
172
- function sleep(ms) {
173
- return new Promise((resolve) => {
174
- const timer = setTimeout(resolve, ms);
175
- timer.unref?.();
176
- service.ensureTimer = timer;
328
+ function callLocal(handler, body) {
329
+ return new Promise((resolve, reject) => {
330
+ handler(body, (status, payload) => {
331
+ if (status >= 400) reject(new Error(payload?.error || `local shared route failed with ${status}`));
332
+ else resolve(payload);
333
+ });
177
334
  });
178
335
  }
179
336
 
180
- async function waitForSettled(read) {
337
+ async function waitForSettled(project, read) {
181
338
  const deadline = Date.now() + ENSURE_STEP_TIMEOUT_MS;
182
- while (Date.now() < deadline && !service.stopRequested) {
183
- if (wallReplica()) return read();
339
+ while (Date.now() < deadline) {
340
+ if (!service.started || wallReplica(project)) return read();
184
341
  const row = read();
185
342
  if (row && row.state !== 'pending' && row.state !== 'syncing') return row;
186
- await sleep(500);
343
+ await new Promise((resolve) => {
344
+ const timer = setTimeout(resolve, 500);
345
+ timer.unref?.();
346
+ });
187
347
  }
188
348
  return read();
189
349
  }
190
350
 
191
- /**
192
- * Bind this machine to the account wall, retrying for as long as the
193
- * service runs. The attachment row is the one durable request per
194
- * resource: create it if absent, cycle it back to pending after a
195
- * refusal, and — when the refusal means the cloud has never heard of the
196
- * wall — promote the empty file into existence exactly once. Two
197
- * machines racing the creation resolve cleanly: one create wins, the
198
- * loser's next attach retry finds the wall existing. The tunnel delivers
199
- * queued requests whenever it connects, so an offline boot binds on
200
- * reconnect.
201
- */
202
- async function ensureWallBound() {
203
- const resourceId = wallResourceId();
204
- if (!resourceId) {
205
- service.ensure.state = 'no-account';
206
- return;
207
- }
351
+ async function ensureProject(project) {
352
+ if (!project || wallReplica(project)) return;
208
353
  const shared = require('../state/shared-rest');
209
354
  const attachments = require('../state/attachments');
210
355
  const promotions = require('../state/promotions');
356
+ fs.mkdirSync(path.dirname(project.file), { recursive: true });
357
+ if (!fs.existsSync(project.file)) fs.writeFileSync(project.file, '');
211
358
 
212
- while (!service.stopRequested && !wallReplica()) {
213
- service.ensure.attempts += 1;
214
- try {
215
- fs.mkdirSync(path.dirname(WALL_FILE), { recursive: true });
216
- if (!fs.existsSync(WALL_FILE)) fs.writeFileSync(WALL_FILE, '');
217
-
218
- let request = attachments.getAttachmentByResource(resourceId);
219
- if (!request) {
220
- service.ensure.state = 'attaching';
221
- await callLocal(shared.handleAttachmentStart, { resourceId, path: WALL_FILE });
222
- } else if (request.state === 'rejected') {
223
- if (ATTACH_REJECTED.test(request.lastError || '') && !promotions.getPromotionByResource(resourceId)) {
224
- // The cloud has never heard of this wall — or access is gone,
225
- // and a create attempt on an existing id is refused, so trying
226
- // is safe either way. Promote the empty file into existence.
227
- service.ensure.state = 'creating';
228
- await callLocal(shared.handlePromotionStart, { resourceId, path: WALL_FILE });
229
- await waitForSettled(() => promotions.getPromotionByResource(resourceId));
230
- if (wallReplica()) break;
231
- }
232
- service.ensure.state = 'attaching';
233
- attachments.retryAttachment(resourceId);
234
- }
235
- const settled = await waitForSettled(() => attachments.getAttachmentByResource(resourceId));
236
- service.ensure.lastError = settled?.lastError || null;
237
- } catch (error) {
238
- service.ensure.lastError = error.message;
359
+ let request = attachments.getAttachmentByResource(project.resourceId);
360
+ if (!request) {
361
+ await callLocal(shared.handleAttachmentStart, {
362
+ resourceId: project.resourceId,
363
+ path: project.file,
364
+ });
365
+ } else if (request.state === 'rejected') {
366
+ if (
367
+ ATTACH_REJECTED.test(request.lastError || '')
368
+ && project.resourceId === projectResourceId(project.repoKey, userId())
369
+ && !promotions.getPromotionByResource(project.resourceId)
370
+ ) {
371
+ await callLocal(shared.handlePromotionStart, {
372
+ resourceId: project.resourceId,
373
+ path: project.file,
374
+ });
375
+ await waitForSettled(project, () => promotions.getPromotionByResource(project.resourceId));
239
376
  }
240
- if (wallReplica()) break;
241
- service.ensure.state = 'waiting';
242
- await sleep(ENSURE_RETRY_MS);
243
- }
244
-
245
- if (wallReplica()) {
246
- service.ensure.state = 'bound';
247
- service.ensure.lastError = null;
248
- schedulePublish();
377
+ if (!wallReplica(project)) attachments.retryAttachment(project.resourceId);
249
378
  }
379
+ await waitForSettled(project, () => attachments.getAttachmentByResource(project.resourceId));
380
+ if (wallReplica(project)) schedulePublish();
250
381
  }
251
382
 
252
- /** Adapt a shared-rest handler (which speaks sendJson) into a promise. */
253
- function callLocal(handler, body) {
254
- return new Promise((resolve, reject) => {
255
- handler(body, (status, payload) => {
256
- if (status >= 400) reject(new Error(payload?.error || `local shared route failed with ${status}`));
257
- else resolve(payload);
258
- });
259
- });
383
+ function ensureProjectBound(project) {
384
+ if (!project) return Promise.resolve();
385
+ const running = service.ensuring.get(project.resourceId);
386
+ if (running) return running;
387
+ const promise = ensureProject(project)
388
+ .catch((error) => {
389
+ service.ensure.set(project.resourceId, { state: 'failed', lastError: error.message });
390
+ })
391
+ .finally(() => service.ensuring.delete(project.resourceId));
392
+ service.ensuring.set(project.resourceId, promise);
393
+ return promise;
260
394
  }
261
395
 
262
- // --- service ---------------------------------------------------------------
396
+ function ensureProjects() {
397
+ return Promise.all(projects().map((project) => ensureProjectBound(project)));
398
+ }
263
399
 
264
400
  function schedulePublish() {
265
401
  if (!service.started || service.publishTimer) return;
@@ -269,7 +405,7 @@ function schedulePublish() {
269
405
  publishWall();
270
406
  } catch (error) {
271
407
  service.counters.publishFailures += 1;
272
- console.warn('[MachineWall] publish failed:', error.message);
408
+ console.warn('[CodeProject] publish failed:', error.message);
273
409
  }
274
410
  }, PUBLISH_QUIET_MS);
275
411
  service.publishTimer.unref?.();
@@ -278,58 +414,73 @@ function schedulePublish() {
278
414
  function startWallService() {
279
415
  if (service.started) return;
280
416
  service.started = true;
281
- service.stopRequested = false;
282
417
  service.unsubscribe = subscribeStateEvents((event) => {
283
- if (event.resource === 'repo-cards') schedulePublish();
284
- });
285
- ensureWallBound().catch((error) => {
286
- service.ensure.state = 'failed';
287
- service.ensure.lastError = error.message;
288
- console.warn('[MachineWall] binding failed:', error.message);
418
+ const acknowledged = event.resource === 'shared-mutations'
419
+ && projects().some((project) => project.resourceId === event.sharedResourceId);
420
+ if (
421
+ event.resource === 'repo-cards'
422
+ || event.resource === 'repo-checkpoints'
423
+ || acknowledged
424
+ ) {
425
+ ensureProjects().catch(() => {});
426
+ schedulePublish();
427
+ }
289
428
  });
429
+ service.ensureInterval = setInterval(() => ensureProjects().catch(() => {}), ENSURE_INTERVAL_MS);
430
+ service.ensureInterval.unref?.();
431
+ ensureProjects().catch(() => {});
290
432
  }
291
433
 
292
434
  function stopWallService() {
293
435
  if (!service.started) return;
294
436
  service.started = false;
295
- service.stopRequested = true;
296
437
  service.unsubscribe?.();
297
438
  service.unsubscribe = null;
298
439
  if (service.publishTimer) clearTimeout(service.publishTimer);
440
+ if (service.ensureInterval) clearInterval(service.ensureInterval);
299
441
  service.publishTimer = null;
300
- if (service.ensureTimer) clearTimeout(service.ensureTimer);
301
- service.ensureTimer = null;
442
+ service.ensureInterval = null;
302
443
  }
303
444
 
304
445
  function wallStats() {
305
- const replica = wallReplica();
446
+ const rows = projects().map((project) => {
447
+ const replica = wallReplica(project);
448
+ return {
449
+ repoKey: project.repoKey,
450
+ resourceId: project.resourceId,
451
+ ownerUserId: project.ownerUserId,
452
+ path: project.file,
453
+ replica: replica && {
454
+ appliedVersion: replica.appliedVersion,
455
+ snapshotVersion: replica.snapshotVersion,
456
+ authorityEpoch: replica.authorityEpoch,
457
+ syncStatus: replica.syncStatus,
458
+ lastError: replica.lastError,
459
+ updatedAt: replica.updatedAt,
460
+ },
461
+ machines: readProjectWall(project).length,
462
+ };
463
+ });
306
464
  return {
307
465
  contract: WALL_CONTRACT,
308
- resourceId: wallResourceId(),
309
- path: WALL_FILE,
310
- replica: replica && {
311
- appliedVersion: replica.appliedVersion,
312
- snapshotVersion: replica.snapshotVersion,
313
- authorityEpoch: replica.authorityEpoch,
314
- syncStatus: replica.syncStatus,
315
- lastError: replica.lastError,
316
- updatedAt: replica.updatedAt,
317
- },
318
- ensure: { ...service.ensure },
466
+ projects: rows,
467
+ paths: rows.map((row) => row.path),
319
468
  publishes: service.counters.publishes,
320
469
  publishSkips: service.counters.publishSkips,
321
470
  publishFailures: service.counters.publishFailures,
322
471
  lastPublishedAt: service.lastPublishedAt,
323
- machines: readWall().length,
324
472
  };
325
473
  }
326
474
 
327
475
  module.exports = {
328
476
  WALL_CONTRACT,
477
+ bindProject,
478
+ projectFile,
479
+ projectForRepo,
480
+ projectResourceId,
329
481
  publishWall,
330
482
  readWall,
331
483
  startWallService,
332
484
  stopWallService,
333
- wallResourceId,
334
485
  wallStats,
335
486
  };