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.
- package/lib/shared-realtime-tunnel.js +28 -12
- package/package.json +1 -1
- package/runtime/scripts/amalgm-mcp/index.js +1 -1
- package/runtime/scripts/amalgm-mcp/server/routes/state.js +5 -1
- package/runtime/scripts/amalgm-mcp/server/routes/workspace.js +4 -0
- package/runtime/scripts/amalgm-mcp/state/docs.js +13 -6
- package/runtime/scripts/amalgm-mcp/state/migrations.js +0 -4
- package/runtime/scripts/amalgm-mcp/state/mutations.js +13 -1
- package/runtime/scripts/amalgm-mcp/state/shared-rest.js +25 -1
- package/runtime/scripts/amalgm-mcp/tests/state-mutations.test.js +9 -0
- package/runtime/scripts/amalgm-mcp/tests/state-promotion-ownership.test.js +30 -3
- package/runtime/scripts/amalgm-mcp/tests/workspace-cards-store.test.js +4 -5
- package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint-reducer.test.js +91 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint.test.js +37 -11
- package/runtime/scripts/amalgm-mcp/tests/workspace-object-tunnel.test.js +10 -2
- package/runtime/scripts/amalgm-mcp/tests/workspace-objects.test.js +14 -6
- package/runtime/scripts/amalgm-mcp/tests/workspace-reducer.test.js +324 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-store.test.js +36 -1
- package/runtime/scripts/amalgm-mcp/tests/workspace-wall.test.js +81 -48
- package/runtime/scripts/amalgm-mcp/workspace/card.js +11 -1
- package/runtime/scripts/amalgm-mcp/workspace/cards.js +9 -21
- package/runtime/scripts/amalgm-mcp/workspace/checkpoint.js +187 -122
- package/runtime/scripts/amalgm-mcp/workspace/git.js +71 -0
- package/runtime/scripts/amalgm-mcp/workspace/merge.js +119 -2
- package/runtime/scripts/amalgm-mcp/workspace/objects.js +49 -17
- package/runtime/scripts/amalgm-mcp/workspace/reducer.js +582 -0
- package/runtime/scripts/amalgm-mcp/workspace/rest.js +15 -1
- package/runtime/scripts/amalgm-mcp/workspace/store.js +8 -18
- package/runtime/scripts/amalgm-mcp/workspace/transition.js +67 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree/local-state.js +3 -4
- package/runtime/scripts/amalgm-mcp/workspace/wall.js +331 -180
- package/runtime/scripts/amalgm-mcp/tests/workspace-reconcile.test.js +0 -359
- package/runtime/scripts/amalgm-mcp/workspace/ledger.js +0 -96
- package/runtime/scripts/amalgm-mcp/workspace/reconcile.js +0 -452
|
@@ -1,26 +1,13 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
5
|
-
* account's devices on the wave-3 rail.
|
|
4
|
+
* One ordered card+checkpoint stream per code project.
|
|
6
5
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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 = '
|
|
35
|
-
const
|
|
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
|
|
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
|
-
|
|
33
|
+
ensureInterval: null,
|
|
46
34
|
unsubscribe: null,
|
|
47
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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-
|
|
62
|
+
.update(`amalgm-code-project@1|${ownerUserId}|${repoKey}`)
|
|
61
63
|
.digest('base64url');
|
|
62
|
-
return `
|
|
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
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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 (
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
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(
|
|
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 {
|
|
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
|
|
164
|
-
const
|
|
165
|
-
|
|
166
|
-
const
|
|
167
|
-
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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())
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
|
|
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('[
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|
-
|
|
301
|
-
service.ensureTimer = null;
|
|
442
|
+
service.ensureInterval = null;
|
|
302
443
|
}
|
|
303
444
|
|
|
304
445
|
function wallStats() {
|
|
305
|
-
const
|
|
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
|
-
|
|
309
|
-
|
|
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
|
};
|