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.
@@ -618,9 +618,18 @@ function createSharedRealtimeTunnel({
618
618
  }
619
619
 
620
620
  function handleError(frame) {
621
- if (repoUploadInflight.has(frame.request_id)) {
622
- repoUploadInflight.delete(frame.request_id);
623
- scheduleRepoObjects(RETRY_MS);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amalgm",
3
- "version": "0.1.240",
3
+ "version": "0.1.241",
4
4
  "description": "Amalgm local computer runtime: login, MCP, chat, events, previews, and tunnels.",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
@@ -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) => !entry.uploaded);
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,