amalgm 0.1.240 → 0.1.242

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.242",
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',
@@ -2,6 +2,7 @@
2
2
 
3
3
  const test = require('node:test');
4
4
  const assert = require('node:assert/strict');
5
+ const crypto = require('crypto');
5
6
  const fs = require('fs');
6
7
  const os = require('os');
7
8
  const path = require('path');
@@ -29,6 +30,7 @@ const {
29
30
  acknowledgeUpload,
30
31
  claimDownload,
31
32
  claimUpload,
33
+ failUpload,
32
34
  importFromLocator,
33
35
  objectSourceFor,
34
36
  prepareBundleImport,
@@ -242,3 +244,85 @@ test('one malformed download request cannot block valid requests behind it', ()
242
244
  assert.equal(fs.existsSync(path.join(requests, '000-malformed.json')), false);
243
245
  receiveDownload(PROJECT, chunks[0].sha256, content.toString('base64'));
244
246
  });
247
+
248
+ test('failed uploads yield the bulk lane to later durable objects', () => {
249
+ for (const content of ['first queued object', 'second queued object', 'third queued object']) {
250
+ assert.equal(
251
+ publishPayload(`fair-${content}`, Buffer.from(content), REPO_KEY).kind,
252
+ 'pending',
253
+ );
254
+ }
255
+
256
+ const first = claimUpload();
257
+ assert.ok(first);
258
+ const retry = failUpload(first.resourceId, first.sha256, {
259
+ retryable: true,
260
+ code: 'temporary_object_failure',
261
+ message: 'try this object later',
262
+ });
263
+ assert.equal(retry.changed, true);
264
+ assert.equal(retry.terminal, false);
265
+ assert.ok(Date.parse(retry.retryAfter) > Date.now());
266
+
267
+ const second = claimUpload();
268
+ assert.ok(second);
269
+ assert.notEqual(second.sha256, first.sha256);
270
+ const terminal = failUpload(second.resourceId, second.sha256, {
271
+ retryable: false,
272
+ code: 'shared_resource_read_forbidden',
273
+ message: 'the stale rail is no longer readable',
274
+ });
275
+ assert.equal(terminal.changed, true);
276
+ assert.equal(terminal.terminal, true);
277
+
278
+ const third = claimUpload();
279
+ assert.ok(third);
280
+ assert.notEqual(third.sha256, first.sha256);
281
+ assert.notEqual(third.sha256, second.sha256);
282
+
283
+ acknowledgeUpload(first.resourceId, first.sha256);
284
+ acknowledgeUpload(second.resourceId, second.sha256);
285
+ acknowledgeUpload(third.resourceId, third.sha256);
286
+ assert.equal(claimUpload(), null);
287
+ });
288
+
289
+ test('legacy shared-resource uploads can terminate without starving current projects', () => {
290
+ const legacyResourceId = 'shr_legacyobjectqueue0001';
291
+ const legacyBytes = Buffer.from('stale object from a legacy rail');
292
+ const legacyDigest = crypto.createHash('sha256').update(legacyBytes).digest('hex');
293
+ const objectRoot = path.join(process.env.AMALGM_DIR, 'state', 'repo-objects');
294
+ const manifests = path.join(objectRoot, 'manifests');
295
+ const chunks = path.join(objectRoot, 'chunks');
296
+ fs.writeFileSync(path.join(chunks, `${legacyDigest}.bin`), legacyBytes);
297
+ fs.writeFileSync(path.join(manifests, '000-legacy-shared-resource.json'), JSON.stringify({
298
+ contract: 'git-bundle-chunks@2',
299
+ resourceId: legacyResourceId,
300
+ repoKey: 'legacy-repo',
301
+ oid: '1'.repeat(40),
302
+ ref: `refs/amalgm/exports/${'1'.repeat(40)}`,
303
+ checksum: locatorChecksum([{ sha256: legacyDigest, bytes: legacyBytes.length }]),
304
+ bytes: legacyBytes.length,
305
+ chunks: [{ sha256: legacyDigest, bytes: legacyBytes.length, uploaded: false }],
306
+ createdAt: new Date().toISOString(),
307
+ }));
308
+
309
+ assert.equal(
310
+ publishPayload('current-after-legacy', Buffer.from('current project object'), REPO_KEY).kind,
311
+ 'pending',
312
+ );
313
+ const legacy = claimUpload();
314
+ assert.equal(legacy.resourceId, legacyResourceId);
315
+ assert.equal(legacy.sha256, legacyDigest);
316
+ const terminal = failUpload(legacy.resourceId, legacy.sha256, {
317
+ retryable: false,
318
+ code: 'shared_resource_read_forbidden',
319
+ message: 'the legacy rail is no longer readable',
320
+ });
321
+ assert.equal(terminal.changed, true);
322
+ assert.equal(terminal.terminal, true);
323
+
324
+ const current = claimUpload();
325
+ assert.equal(current.resourceId, PROJECT);
326
+ assert.notEqual(current.sha256, legacy.sha256);
327
+ acknowledgeUpload(current.resourceId, current.sha256);
328
+ });
@@ -13,6 +13,7 @@ const crypto = require('crypto');
13
13
  const fs = require('fs');
14
14
  const path = require('path');
15
15
  const { AMALGM_DIR } = require('../config');
16
+ const { isSharedResourceId } = require('../state/cloud-resource-id');
16
17
  const { appendStateEvent } = require('../state/events');
17
18
  const durable = require('./durable');
18
19
  const transition = require('./transition');
@@ -31,6 +32,8 @@ const PAYLOADS = path.join(ROOT, 'payloads');
31
32
  const CHUNKS = path.join(ROOT, 'chunks');
32
33
  const REQUESTS = path.join(ROOT, 'requests');
33
34
  const preparing = new Map();
35
+ const UPLOAD_RETRY_BASE_MS = 1_000;
36
+ const UPLOAD_RETRY_MAX_MS = 60_000;
34
37
 
35
38
  function isProjectId(value) {
36
39
  return require('./tree/identity').isUuid(String(value || ''));
@@ -209,11 +212,20 @@ async function objectSourceFor(worktreeRoot, repoKey, oid) {
209
212
 
210
213
  function claimUpload() {
211
214
  ensureDirs();
215
+ const now = Date.now();
212
216
  for (const directory of [MANIFESTS, PAYLOADS]) {
213
217
  for (const name of fs.readdirSync(directory).sort()) {
214
218
  const file = path.join(directory, name);
215
219
  const manifest = readJson(file);
216
- const chunk = manifest?.chunks?.find((entry) => !entry.uploaded);
220
+ const chunk = manifest?.chunks?.find((entry) => (
221
+ !entry.uploaded
222
+ && !entry.uploadTerminal
223
+ && (
224
+ !entry.uploadRetryAfter
225
+ || !Number.isFinite(Date.parse(entry.uploadRetryAfter))
226
+ || Date.parse(entry.uploadRetryAfter) <= now
227
+ )
228
+ ));
217
229
  if (!chunk) continue;
218
230
  const chunkFile = path.join(CHUNKS, `${chunk.sha256}.bin`);
219
231
  const bytes = fs.existsSync(chunkFile) ? fs.readFileSync(chunkFile) : null;
@@ -252,6 +264,10 @@ function acknowledgeUpload(resourceId, digest) {
252
264
  for (const chunk of manifest.chunks) {
253
265
  if (chunk.sha256 === digest && !chunk.uploaded) {
254
266
  chunk.uploaded = true;
267
+ delete chunk.uploadAttempts;
268
+ delete chunk.uploadLastError;
269
+ delete chunk.uploadRetryAfter;
270
+ delete chunk.uploadTerminal;
255
271
  touched = true;
256
272
  changed = true;
257
273
  }
@@ -272,6 +288,59 @@ function acknowledgeUpload(resourceId, digest) {
272
288
  return { changed, completed };
273
289
  }
274
290
 
291
+ function failUpload(resourceId, digest, failure = {}) {
292
+ // Upload work is durable across protocol generations. Match the exact
293
+ // identity that was accepted when the work was queued, including legacy
294
+ // `shr_` rails, so stale historical work can reach a terminal state instead
295
+ // of monopolizing every newer code project behind it.
296
+ if (!isSharedResourceId(resourceId) || !/^[0-9a-f]{64}$/.test(String(digest || ''))) {
297
+ return { changed: false, terminal: false };
298
+ }
299
+ ensureDirs();
300
+ const failedAt = new Date().toISOString();
301
+ const retryable = failure?.retryable !== false;
302
+ let changed = false;
303
+ let terminal = false;
304
+ let retryAfter = null;
305
+ let attempts = 0;
306
+ for (const directory of [MANIFESTS, PAYLOADS]) {
307
+ for (const name of fs.readdirSync(directory)) {
308
+ const file = path.join(directory, name);
309
+ const manifest = readJson(file);
310
+ if (manifest?.resourceId !== resourceId || !manifest?.chunks) continue;
311
+ const chunk = manifest.chunks.find((entry) => (
312
+ entry.sha256 === digest && !entry.uploaded
313
+ ));
314
+ if (!chunk) continue;
315
+ attempts = Math.max(attempts, Number(chunk.uploadAttempts || 0) + 1);
316
+ chunk.uploadAttempts = attempts;
317
+ chunk.uploadLastError = {
318
+ code: typeof failure?.code === 'string' ? failure.code : 'repo_object_upload_failed',
319
+ message: typeof failure?.message === 'string' ? failure.message : 'repo object upload failed',
320
+ retryable,
321
+ at: failedAt,
322
+ };
323
+ if (retryable) {
324
+ const delayMs = Math.min(
325
+ UPLOAD_RETRY_MAX_MS,
326
+ UPLOAD_RETRY_BASE_MS * (2 ** Math.min(attempts - 1, 16)),
327
+ );
328
+ retryAfter = new Date(Date.now() + delayMs).toISOString();
329
+ chunk.uploadRetryAfter = retryAfter;
330
+ delete chunk.uploadTerminal;
331
+ } else {
332
+ chunk.uploadTerminal = { ...chunk.uploadLastError };
333
+ delete chunk.uploadRetryAfter;
334
+ terminal = true;
335
+ }
336
+ writeJson(file, manifest);
337
+ changed = true;
338
+ }
339
+ }
340
+ if (changed) wake('update', digest);
341
+ return { changed, terminal, retryAfter, attempts };
342
+ }
343
+
275
344
  function validatePayloadLocator(locator) {
276
345
  if (
277
346
  locator?.kind !== 'chunks'
@@ -456,6 +525,7 @@ module.exports = {
456
525
  bundleSourceFor,
457
526
  claimDownload,
458
527
  claimUpload,
528
+ failUpload,
459
529
  importFromLocator,
460
530
  objectSourceFor,
461
531
  prepareBundleImport,