amalgm 0.1.240 → 0.1.241
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 +12 -3
- package/package.json +1 -1
- package/runtime/scripts/amalgm-mcp/server/routes/state.js +13 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-object-tunnel.test.js +90 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-objects.test.js +42 -0
- package/runtime/scripts/amalgm-mcp/workspace/objects.js +66 -1
|
@@ -618,9 +618,18 @@ function createSharedRealtimeTunnel({
|
|
|
618
618
|
}
|
|
619
619
|
|
|
620
620
|
function handleError(frame) {
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
621
|
+
const failedUpload = repoUploadInflight.get(frame.request_id);
|
|
622
|
+
if (failedUpload) {
|
|
623
|
+
void localStateJson('POST', '/state/repo-objects/uploads/fail', {
|
|
624
|
+
resourceId: failedUpload.resourceId,
|
|
625
|
+
sha256: failedUpload.sha256,
|
|
626
|
+
retryable: frame.retryable !== false,
|
|
627
|
+
code: frame.code,
|
|
628
|
+
message: frame.message,
|
|
629
|
+
}).finally(() => {
|
|
630
|
+
repoUploadInflight.delete(frame.request_id);
|
|
631
|
+
scheduleRepoObjects();
|
|
632
|
+
});
|
|
624
633
|
return;
|
|
625
634
|
}
|
|
626
635
|
if (repoDownloadInflight.has(frame.request_id)) {
|
package/package.json
CHANGED
|
@@ -59,6 +59,19 @@ async function handleStateRoutes(ctx) {
|
|
|
59
59
|
));
|
|
60
60
|
return true;
|
|
61
61
|
}
|
|
62
|
+
if (ctx.pathname === '/state/repo-objects/uploads/fail') {
|
|
63
|
+
const body = await ctx.readJsonBody();
|
|
64
|
+
ctx.sendJson(200, require('../../workspace/objects').failUpload(
|
|
65
|
+
body?.resourceId,
|
|
66
|
+
body?.sha256,
|
|
67
|
+
{
|
|
68
|
+
retryable: body?.retryable,
|
|
69
|
+
code: body?.code,
|
|
70
|
+
message: body?.message,
|
|
71
|
+
},
|
|
72
|
+
));
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
62
75
|
if (ctx.pathname === '/state/repo-objects/downloads/claim') {
|
|
63
76
|
ctx.sendJson(200, { download: require('../../workspace/objects').claimDownload() });
|
|
64
77
|
return true;
|
|
@@ -201,6 +201,96 @@ test('repo object chunks retry after the tunnel reconnects', async () => {
|
|
|
201
201
|
}
|
|
202
202
|
});
|
|
203
203
|
|
|
204
|
+
test('a rejected stale object yields the tunnel to the next upload', async () => {
|
|
205
|
+
const staleBytes = Buffer.from('stale denied object');
|
|
206
|
+
const staleSha = crypto.createHash('sha256').update(staleBytes).digest('hex');
|
|
207
|
+
const currentBytes = Buffer.from('current allowed object');
|
|
208
|
+
const currentSha = crypto.createHash('sha256').update(currentBytes).digest('hex');
|
|
209
|
+
const resourceId = 'shr_cp1abcdefghijklmnopqrstuvwx';
|
|
210
|
+
let failed = false;
|
|
211
|
+
const calls = [];
|
|
212
|
+
const server = http.createServer((req, res) => {
|
|
213
|
+
let raw = '';
|
|
214
|
+
req.on('data', (chunk) => { raw += chunk; });
|
|
215
|
+
req.on('end', () => {
|
|
216
|
+
const body = raw ? JSON.parse(raw) : null;
|
|
217
|
+
calls.push({ path: req.url, body });
|
|
218
|
+
res.setHeader('content-type', 'application/json');
|
|
219
|
+
if (req.url === '/state/shared/replicas') {
|
|
220
|
+
res.end('{"replicas":[]}');
|
|
221
|
+
} else if (req.url.startsWith('/state/stream')) {
|
|
222
|
+
res.writeHead(200, { 'content-type': 'text/event-stream' });
|
|
223
|
+
res.write('\n');
|
|
224
|
+
} else if (req.url === '/state/repo-objects/uploads/claim') {
|
|
225
|
+
const bytes = failed ? currentBytes : staleBytes;
|
|
226
|
+
const sha256 = failed ? currentSha : staleSha;
|
|
227
|
+
res.end(JSON.stringify({
|
|
228
|
+
upload: {
|
|
229
|
+
resourceId,
|
|
230
|
+
sha256,
|
|
231
|
+
bytes: bytes.length,
|
|
232
|
+
dataBase64: bytes.toString('base64'),
|
|
233
|
+
},
|
|
234
|
+
}));
|
|
235
|
+
} else if (req.url === '/state/repo-objects/uploads/fail') {
|
|
236
|
+
failed = true;
|
|
237
|
+
res.end('{"changed":true,"terminal":true}');
|
|
238
|
+
} else if (req.url.endsWith('/claim')) {
|
|
239
|
+
res.end('{}');
|
|
240
|
+
} else {
|
|
241
|
+
res.end('{}');
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
await new Promise((resolve) => { server.listen(0, '127.0.0.1', resolve); });
|
|
246
|
+
|
|
247
|
+
const frames = [];
|
|
248
|
+
const tunnel = createSharedRealtimeTunnel({
|
|
249
|
+
send: (frame) => { frames.push(frame); return true; },
|
|
250
|
+
sendLarge: (frame) => { frames.push(frame); return true; },
|
|
251
|
+
localPort: () => server.address().port,
|
|
252
|
+
localHeaders: (headers) => headers,
|
|
253
|
+
warn: () => {},
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
try {
|
|
257
|
+
tunnel.enable(['shared.mutation.v1']);
|
|
258
|
+
await until(
|
|
259
|
+
() => frames.some((frame) => frame.type === 'repo.object.put'),
|
|
260
|
+
'the stale upload',
|
|
261
|
+
);
|
|
262
|
+
const stale = frames.find((frame) => frame.type === 'repo.object.put');
|
|
263
|
+
assert.equal(stale.sha256, staleSha);
|
|
264
|
+
tunnel.handleFrame({
|
|
265
|
+
type: 'shared.error',
|
|
266
|
+
request_id: stale.request_id,
|
|
267
|
+
resource_id: resourceId,
|
|
268
|
+
code: 'shared_resource_read_forbidden',
|
|
269
|
+
message: 'stale rail is no longer readable',
|
|
270
|
+
retryable: false,
|
|
271
|
+
});
|
|
272
|
+
await until(
|
|
273
|
+
() => frames.some((frame) => (
|
|
274
|
+
frame.type === 'repo.object.put' && frame.sha256 === currentSha
|
|
275
|
+
)),
|
|
276
|
+
'the current upload behind the stale one',
|
|
277
|
+
);
|
|
278
|
+
assert.deepEqual(
|
|
279
|
+
calls.find((call) => call.path === '/state/repo-objects/uploads/fail').body,
|
|
280
|
+
{
|
|
281
|
+
resourceId,
|
|
282
|
+
sha256: staleSha,
|
|
283
|
+
retryable: false,
|
|
284
|
+
code: 'shared_resource_read_forbidden',
|
|
285
|
+
message: 'stale rail is no longer readable',
|
|
286
|
+
},
|
|
287
|
+
);
|
|
288
|
+
} finally {
|
|
289
|
+
tunnel.stop();
|
|
290
|
+
server.close();
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
|
|
204
294
|
test('a healthy tunnel sends one request for one durable mutation', async () => {
|
|
205
295
|
const envelope = {
|
|
206
296
|
resourceId: '018f0000-0000-7000-8000-000000000050',
|
|
@@ -29,6 +29,7 @@ const {
|
|
|
29
29
|
acknowledgeUpload,
|
|
30
30
|
claimDownload,
|
|
31
31
|
claimUpload,
|
|
32
|
+
failUpload,
|
|
32
33
|
importFromLocator,
|
|
33
34
|
objectSourceFor,
|
|
34
35
|
prepareBundleImport,
|
|
@@ -242,3 +243,44 @@ test('one malformed download request cannot block valid requests behind it', ()
|
|
|
242
243
|
assert.equal(fs.existsSync(path.join(requests, '000-malformed.json')), false);
|
|
243
244
|
receiveDownload(PROJECT, chunks[0].sha256, content.toString('base64'));
|
|
244
245
|
});
|
|
246
|
+
|
|
247
|
+
test('failed uploads yield the bulk lane to later durable objects', () => {
|
|
248
|
+
for (const content of ['first queued object', 'second queued object', 'third queued object']) {
|
|
249
|
+
assert.equal(
|
|
250
|
+
publishPayload(`fair-${content}`, Buffer.from(content), REPO_KEY).kind,
|
|
251
|
+
'pending',
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const first = claimUpload();
|
|
256
|
+
assert.ok(first);
|
|
257
|
+
const retry = failUpload(first.resourceId, first.sha256, {
|
|
258
|
+
retryable: true,
|
|
259
|
+
code: 'temporary_object_failure',
|
|
260
|
+
message: 'try this object later',
|
|
261
|
+
});
|
|
262
|
+
assert.equal(retry.changed, true);
|
|
263
|
+
assert.equal(retry.terminal, false);
|
|
264
|
+
assert.ok(Date.parse(retry.retryAfter) > Date.now());
|
|
265
|
+
|
|
266
|
+
const second = claimUpload();
|
|
267
|
+
assert.ok(second);
|
|
268
|
+
assert.notEqual(second.sha256, first.sha256);
|
|
269
|
+
const terminal = failUpload(second.resourceId, second.sha256, {
|
|
270
|
+
retryable: false,
|
|
271
|
+
code: 'shared_resource_read_forbidden',
|
|
272
|
+
message: 'the stale rail is no longer readable',
|
|
273
|
+
});
|
|
274
|
+
assert.equal(terminal.changed, true);
|
|
275
|
+
assert.equal(terminal.terminal, true);
|
|
276
|
+
|
|
277
|
+
const third = claimUpload();
|
|
278
|
+
assert.ok(third);
|
|
279
|
+
assert.notEqual(third.sha256, first.sha256);
|
|
280
|
+
assert.notEqual(third.sha256, second.sha256);
|
|
281
|
+
|
|
282
|
+
acknowledgeUpload(first.resourceId, first.sha256);
|
|
283
|
+
acknowledgeUpload(second.resourceId, second.sha256);
|
|
284
|
+
acknowledgeUpload(third.resourceId, third.sha256);
|
|
285
|
+
assert.equal(claimUpload(), null);
|
|
286
|
+
});
|
|
@@ -31,6 +31,8 @@ const PAYLOADS = path.join(ROOT, 'payloads');
|
|
|
31
31
|
const CHUNKS = path.join(ROOT, 'chunks');
|
|
32
32
|
const REQUESTS = path.join(ROOT, 'requests');
|
|
33
33
|
const preparing = new Map();
|
|
34
|
+
const UPLOAD_RETRY_BASE_MS = 1_000;
|
|
35
|
+
const UPLOAD_RETRY_MAX_MS = 60_000;
|
|
34
36
|
|
|
35
37
|
function isProjectId(value) {
|
|
36
38
|
return require('./tree/identity').isUuid(String(value || ''));
|
|
@@ -209,11 +211,20 @@ async function objectSourceFor(worktreeRoot, repoKey, oid) {
|
|
|
209
211
|
|
|
210
212
|
function claimUpload() {
|
|
211
213
|
ensureDirs();
|
|
214
|
+
const now = Date.now();
|
|
212
215
|
for (const directory of [MANIFESTS, PAYLOADS]) {
|
|
213
216
|
for (const name of fs.readdirSync(directory).sort()) {
|
|
214
217
|
const file = path.join(directory, name);
|
|
215
218
|
const manifest = readJson(file);
|
|
216
|
-
const chunk = manifest?.chunks?.find((entry) =>
|
|
219
|
+
const chunk = manifest?.chunks?.find((entry) => (
|
|
220
|
+
!entry.uploaded
|
|
221
|
+
&& !entry.uploadTerminal
|
|
222
|
+
&& (
|
|
223
|
+
!entry.uploadRetryAfter
|
|
224
|
+
|| !Number.isFinite(Date.parse(entry.uploadRetryAfter))
|
|
225
|
+
|| Date.parse(entry.uploadRetryAfter) <= now
|
|
226
|
+
)
|
|
227
|
+
));
|
|
217
228
|
if (!chunk) continue;
|
|
218
229
|
const chunkFile = path.join(CHUNKS, `${chunk.sha256}.bin`);
|
|
219
230
|
const bytes = fs.existsSync(chunkFile) ? fs.readFileSync(chunkFile) : null;
|
|
@@ -252,6 +263,10 @@ function acknowledgeUpload(resourceId, digest) {
|
|
|
252
263
|
for (const chunk of manifest.chunks) {
|
|
253
264
|
if (chunk.sha256 === digest && !chunk.uploaded) {
|
|
254
265
|
chunk.uploaded = true;
|
|
266
|
+
delete chunk.uploadAttempts;
|
|
267
|
+
delete chunk.uploadLastError;
|
|
268
|
+
delete chunk.uploadRetryAfter;
|
|
269
|
+
delete chunk.uploadTerminal;
|
|
255
270
|
touched = true;
|
|
256
271
|
changed = true;
|
|
257
272
|
}
|
|
@@ -272,6 +287,55 @@ function acknowledgeUpload(resourceId, digest) {
|
|
|
272
287
|
return { changed, completed };
|
|
273
288
|
}
|
|
274
289
|
|
|
290
|
+
function failUpload(resourceId, digest, failure = {}) {
|
|
291
|
+
if (!isProjectId(resourceId) || !/^[0-9a-f]{64}$/.test(String(digest || ''))) {
|
|
292
|
+
return { changed: false, terminal: false };
|
|
293
|
+
}
|
|
294
|
+
ensureDirs();
|
|
295
|
+
const failedAt = new Date().toISOString();
|
|
296
|
+
const retryable = failure?.retryable !== false;
|
|
297
|
+
let changed = false;
|
|
298
|
+
let terminal = false;
|
|
299
|
+
let retryAfter = null;
|
|
300
|
+
let attempts = 0;
|
|
301
|
+
for (const directory of [MANIFESTS, PAYLOADS]) {
|
|
302
|
+
for (const name of fs.readdirSync(directory)) {
|
|
303
|
+
const file = path.join(directory, name);
|
|
304
|
+
const manifest = readJson(file);
|
|
305
|
+
if (manifest?.resourceId !== resourceId || !manifest?.chunks) continue;
|
|
306
|
+
const chunk = manifest.chunks.find((entry) => (
|
|
307
|
+
entry.sha256 === digest && !entry.uploaded
|
|
308
|
+
));
|
|
309
|
+
if (!chunk) continue;
|
|
310
|
+
attempts = Math.max(attempts, Number(chunk.uploadAttempts || 0) + 1);
|
|
311
|
+
chunk.uploadAttempts = attempts;
|
|
312
|
+
chunk.uploadLastError = {
|
|
313
|
+
code: typeof failure?.code === 'string' ? failure.code : 'repo_object_upload_failed',
|
|
314
|
+
message: typeof failure?.message === 'string' ? failure.message : 'repo object upload failed',
|
|
315
|
+
retryable,
|
|
316
|
+
at: failedAt,
|
|
317
|
+
};
|
|
318
|
+
if (retryable) {
|
|
319
|
+
const delayMs = Math.min(
|
|
320
|
+
UPLOAD_RETRY_MAX_MS,
|
|
321
|
+
UPLOAD_RETRY_BASE_MS * (2 ** Math.min(attempts - 1, 16)),
|
|
322
|
+
);
|
|
323
|
+
retryAfter = new Date(Date.now() + delayMs).toISOString();
|
|
324
|
+
chunk.uploadRetryAfter = retryAfter;
|
|
325
|
+
delete chunk.uploadTerminal;
|
|
326
|
+
} else {
|
|
327
|
+
chunk.uploadTerminal = { ...chunk.uploadLastError };
|
|
328
|
+
delete chunk.uploadRetryAfter;
|
|
329
|
+
terminal = true;
|
|
330
|
+
}
|
|
331
|
+
writeJson(file, manifest);
|
|
332
|
+
changed = true;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
if (changed) wake('update', digest);
|
|
336
|
+
return { changed, terminal, retryAfter, attempts };
|
|
337
|
+
}
|
|
338
|
+
|
|
275
339
|
function validatePayloadLocator(locator) {
|
|
276
340
|
if (
|
|
277
341
|
locator?.kind !== 'chunks'
|
|
@@ -456,6 +520,7 @@ module.exports = {
|
|
|
456
520
|
bundleSourceFor,
|
|
457
521
|
claimDownload,
|
|
458
522
|
claimUpload,
|
|
523
|
+
failUpload,
|
|
459
524
|
importFromLocator,
|
|
460
525
|
objectSourceFor,
|
|
461
526
|
prepareBundleImport,
|