amalgm 0.1.157 → 0.1.158

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/cli.js CHANGED
@@ -120,7 +120,7 @@ function usage() {
120
120
  ' amalgm stop',
121
121
  ' amalgm status',
122
122
  ' amalgm doctor',
123
- ' amalgm service [install|start|stop|status|uninstall] [--mode auto|systemd|launchd|portable]',
123
+ ' amalgm service [install|start|stop|status|uninstall] [--mode auto|systemd|launchd|windows|portable]',
124
124
  ' amalgm update [--tag latest|canary]',
125
125
  ' amalgm logs [service]',
126
126
  ' amalgm logout',
package/lib/service.js CHANGED
@@ -258,8 +258,8 @@ function normalizeMode(mode) {
258
258
  const value = String(mode || 'auto').trim().toLowerCase();
259
259
  if (!value || value === 'auto') return 'auto';
260
260
  if (value === 'watchdog') return 'portable';
261
- if (['portable', 'systemd', 'launchd'].includes(value)) return value;
262
- throw new Error(`Unsupported service mode "${mode}". Use auto, systemd, launchd, or portable.`);
261
+ if (['portable', 'systemd', 'launchd', 'windows'].includes(value)) return value;
262
+ throw new Error(`Unsupported service mode "${mode}". Use auto, systemd, launchd, windows, or portable.`);
263
263
  }
264
264
 
265
265
  function detectServiceBackend(mode = 'auto') {
@@ -273,6 +273,10 @@ function detectServiceBackend(mode = 'auto') {
273
273
  if (!systemdAvailable()) throw new Error('systemd --user is not available in this session.');
274
274
  return { backend: 'systemd', detail: 'systemd user service' };
275
275
  }
276
+ if (requested === 'windows') {
277
+ if (process.platform !== 'win32') throw new Error('Windows startup is not available on this machine.');
278
+ return { backend: 'windows', detail: 'Windows per-user startup entry' };
279
+ }
276
280
  if (requested === 'portable') {
277
281
  return {
278
282
  backend: 'portable',
@@ -280,6 +284,7 @@ function detectServiceBackend(mode = 'auto') {
280
284
  };
281
285
  }
282
286
  if (launchdAvailable()) return { backend: 'launchd', detail: 'macOS LaunchAgent' };
287
+ if (process.platform === 'win32') return { backend: 'windows', detail: 'Windows per-user startup entry' };
283
288
  if (inContainer) {
284
289
  return {
285
290
  backend: 'portable',
@@ -633,6 +638,34 @@ function systemdUnitPath() {
633
638
  return path.join(os.homedir(), '.config', 'systemd', 'user', `${SERVICE_LABEL}.service`);
634
639
  }
635
640
 
641
+ function windowsStartupDir(env = process.env) {
642
+ const appData = String(env.APPDATA || '').trim();
643
+ const roaming = appData || path.join(os.homedir(), 'AppData', 'Roaming');
644
+ return path.join(roaming, 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Startup');
645
+ }
646
+
647
+ function windowsStartupScriptPath(env = process.env) {
648
+ return path.join(windowsStartupDir(env), `Amalgm-${SERVICE_LABEL_SCOPE}.vbs`);
649
+ }
650
+
651
+ function vbsString(value) {
652
+ return `"${String(value).replace(/"/g, '""')}"`;
653
+ }
654
+
655
+ function writeWindowsStartupScript(localOnly, env = process.env) {
656
+ const startupPath = windowsStartupScriptPath(env);
657
+ ensureDir(path.dirname(startupPath), 0o700);
658
+ const watchdogPath = writePortableNodeScript(localOnly);
659
+ const command = `"${process.execPath}" "${watchdogPath}"`;
660
+ const source = [
661
+ "Set AmalgmShell = CreateObject(\"WScript.Shell\")",
662
+ `AmalgmShell.Run ${vbsString(command)}, 0, False`,
663
+ '',
664
+ ].join('\r\n');
665
+ fs.writeFileSync(startupPath, source, { mode: 0o600 });
666
+ return startupPath;
667
+ }
668
+
636
669
  function writeSystemdUnit(localOnly) {
637
670
  const unitPath = systemdUnitPath();
638
671
  ensureDir(path.dirname(unitPath), 0o700);
@@ -696,6 +729,9 @@ function installService(options = {}) {
696
729
  } else if (backend === 'systemd') {
697
730
  files.unit = writeSystemdUnit(localOnly);
698
731
  spawnSync('systemctl', ['--user', 'daemon-reload'], { stdio: 'ignore', timeout: 5000 });
732
+ } else if (backend === 'windows') {
733
+ files.nodeScript = writePortableNodeScript(localOnly);
734
+ files.startup = writeWindowsStartupScript(localOnly);
699
735
  } else {
700
736
  files.script = writePortableScript(localOnly);
701
737
  files.nodeScript = writePortableNodeScript(localOnly);
@@ -902,6 +938,10 @@ function uninstallService() {
902
938
  spawnSync('systemctl', ['--user', 'daemon-reload'], { stdio: 'ignore', timeout: 5000 });
903
939
  } else if (state?.backend === 'launchd') {
904
940
  removeFile(state?.files?.plist || launchdPlistPath());
941
+ } else if (state?.backend === 'windows') {
942
+ removeFile(state?.files?.startup || windowsStartupScriptPath());
943
+ removeFile(SERVICE_SCRIPT_FILE);
944
+ removeFile(SERVICE_NODE_SCRIPT_FILE);
905
945
  } else {
906
946
  removeFile(SERVICE_SCRIPT_FILE);
907
947
  removeFile(SERVICE_NODE_SCRIPT_FILE);
@@ -947,6 +987,10 @@ function getServiceStatus() {
947
987
  || fs.existsSync(state?.files?.nodeScript || SERVICE_NODE_SCRIPT_FILE);
948
988
  pid = readPidFile(SERVICE_PID_FILE);
949
989
  running = isPidRunning(pid);
990
+ } else if (backend === 'windows') {
991
+ installed = fs.existsSync(state?.files?.startup || windowsStartupScriptPath());
992
+ pid = readPidFile(SERVICE_PID_FILE);
993
+ running = isPidRunning(pid);
950
994
  }
951
995
 
952
996
  return {
@@ -970,6 +1014,8 @@ module.exports = {
970
1014
  serviceStateCanOwnRuntime,
971
1015
  writeLaunchdPlist,
972
1016
  writePortableNodeScript,
1017
+ windowsStartupScriptPath,
1018
+ writeWindowsStartupScript,
973
1019
  writeSystemdUnit,
974
1020
  },
975
1021
  detectServiceBackend,
@@ -26,6 +26,7 @@ function createSharedRealtimeTunnel({
26
26
  const mutationInflight = new Map();
27
27
  const promotionInflight = new Map();
28
28
  const attachmentInflight = new Map();
29
+ const joinInflight = new Map();
29
30
  const inboundChains = new Map();
30
31
 
31
32
  let enabled = false;
@@ -239,12 +240,14 @@ function createSharedRealtimeTunnel({
239
240
  const result = await localStateJson('GET', '/state/shared/replicas');
240
241
  for (const replica of Array.isArray(result?.replicas) ? result.replicas : []) {
241
242
  if (!replica?.resourceId) continue;
242
- send({
243
+ const requestId = `join_${crypto.randomBytes(12).toString('hex')}`;
244
+ const queued = send({
243
245
  type: 'shared.resource.join',
244
- request_id: `join_${crypto.randomBytes(12).toString('hex')}`,
246
+ request_id: requestId,
245
247
  resource_id: replica.resourceId,
246
248
  after_version: replica.appliedVersion || 0,
247
249
  });
250
+ if (queued) joinInflight.set(requestId, replica.resourceId);
248
251
  }
249
252
  } catch {
250
253
  // The local service can still be starting; wake reconnect retries.
@@ -393,6 +396,27 @@ function createSharedRealtimeTunnel({
393
396
  }
394
397
 
395
398
  function handleError(frame) {
399
+ const joinedResource = joinInflight.get(frame.request_id);
400
+ if (joinedResource) {
401
+ joinInflight.delete(frame.request_id);
402
+ if (frame.code === 'shared_tail_pruned' || frame.code === 'shared_tail_too_large') {
403
+ // The official tail can no longer reach this replica's cursor —
404
+ // compaction pruned past it, or the gap outgrew incremental
405
+ // recovery. Recover through a fresh snapshot attachment; its ack
406
+ // merges the snapshot, jumps the replica cursor, and re-joins.
407
+ void localStateJson('POST', '/state/shared/replicas/rehydrate', {
408
+ resourceId: joinedResource,
409
+ }).then(() => {
410
+ scheduleAttachmentDrain();
411
+ }).catch((error) => {
412
+ warn(`shared rehydration request failed for ${joinedResource}: ${error?.message || error}`);
413
+ });
414
+ } else {
415
+ warn(`shared join failed for ${joinedResource}: ${frame.code || 'shared_error'}: ${frame.message || ''}`);
416
+ }
417
+ return;
418
+ }
419
+
396
420
  const attachment = attachmentInflight.get(frame.request_id);
397
421
  if (attachment) {
398
422
  void localStateJson('POST', '/state/shared/attachments/fail', {
@@ -472,6 +496,7 @@ function createSharedRealtimeTunnel({
472
496
  handleError(frame);
473
497
  return true;
474
498
  case 'shared.resource.joined':
499
+ joinInflight.delete(frame.request_id);
475
500
  void localStateJson('POST', '/state/shared/replicas/authority', {
476
501
  resourceId: frame.resource_id,
477
502
  authorityEpoch: frame.authority_epoch,
@@ -499,6 +524,7 @@ function createSharedRealtimeTunnel({
499
524
  mutationInflight.clear();
500
525
  promotionInflight.clear();
501
526
  attachmentInflight.clear();
527
+ joinInflight.clear();
502
528
  inboundChains.clear();
503
529
  if (wakeRequest) wakeRequest.destroy();
504
530
  wakeRequest = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amalgm",
3
- "version": "0.1.157",
3
+ "version": "0.1.158",
4
4
  "description": "Amalgm local computer runtime: login, MCP, chat, events, previews, and tunnels.",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
@@ -95,6 +95,10 @@ async function handleStateRoutes(ctx) {
95
95
  await sharedRest.handleReplicaAuthority(await ctx.readJsonBody(), ctx.sendJson);
96
96
  return true;
97
97
  }
98
+ if (ctx.pathname === '/state/shared/replicas/rehydrate') {
99
+ await sharedRest.handleReplicaRehydrate(await ctx.readJsonBody(), ctx.sendJson);
100
+ return true;
101
+ }
98
102
  if (ctx.pathname === '/state/shared/promote') {
99
103
  await sharedRest.handlePromotionStart(await ctx.readJsonBody(), ctx.sendJson);
100
104
  return true;
@@ -1,5 +1,7 @@
1
1
  'use strict';
2
2
 
3
+ const crypto = require('crypto');
4
+
3
5
  const { cachedStatement, openLocalDb } = require('./db');
4
6
  const { appendStateEvent } = require('./events');
5
7
 
@@ -170,6 +172,57 @@ function failAttachment(attachmentIdInput, failure, options = {}) {
170
172
  );
171
173
  }
172
174
 
175
+ /**
176
+ * Re-runs the snapshot attachment for a replica the official tail can no
177
+ * longer reach (its cursor fell behind compaction's pruned floor). The
178
+ * attachment row is the durable request: an existing one cycles back to
179
+ * pending; a replica born from promotion — which never attached — gets one.
180
+ */
181
+ function requestRehydration(input, options = {}) {
182
+ const resourceId = requiredText(input?.resourceId, 'resourceId', 132);
183
+ if (!SHARED_RESOURCE_ID.test(resourceId)) throw invalid('resourceId has an invalid format');
184
+ const database = options.database || openLocalDb();
185
+ const replica = require('./replicas').getReplicaByResourceId(resourceId, { database });
186
+ if (!replica) throw invalid(`shared resource ${resourceId} has no local replica`, 404);
187
+ const now = new Date().toISOString();
188
+ const existing = cachedStatement(
189
+ database,
190
+ 'SELECT * FROM shared_resource_attachments WHERE resource_id = ?',
191
+ ).get(resourceId);
192
+ let attachmentId;
193
+ if (existing) {
194
+ attachmentId = existing.attachment_id;
195
+ cachedStatement(database, `
196
+ UPDATE shared_resource_attachments
197
+ SET attachment_state = 'pending',
198
+ next_attempt_at = NULL,
199
+ last_error = NULL,
200
+ updated_at = ?
201
+ WHERE attachment_id = ?
202
+ `).run(now, attachmentId);
203
+ } else {
204
+ attachmentId = `att_${crypto.randomBytes(16).toString('base64url')}`;
205
+ cachedStatement(database, `
206
+ INSERT INTO shared_resource_attachments (
207
+ attachment_id, resource_id, local_path, created_at, updated_at
208
+ ) VALUES (?, ?, ?, ?, ?)
209
+ `).run(attachmentId, resourceId, replica.localPath, now, now);
210
+ }
211
+ const attachment = normalizeRow(
212
+ cachedStatement(database, 'SELECT * FROM shared_resource_attachments WHERE attachment_id = ?')
213
+ .get(attachmentId),
214
+ );
215
+ appendStateEvent({
216
+ resource: 'shared:attachments',
217
+ op: 'update',
218
+ id: attachmentId,
219
+ sharedResourceId: resourceId,
220
+ value: { state: 'pending' },
221
+ source: 'shared:rehydration',
222
+ });
223
+ return { attachment, requeued: Boolean(existing) };
224
+ }
225
+
173
226
  function getAttachment(attachmentId, options = {}) {
174
227
  const database = options.database || openLocalDb();
175
228
  return normalizeRow(
@@ -184,4 +237,5 @@ module.exports = {
184
237
  enqueueAttachment,
185
238
  failAttachment,
186
239
  getAttachment,
240
+ requestRehydration,
187
241
  };
@@ -1044,26 +1044,44 @@ function installCloudDocSnapshot(pathInput, input) {
1044
1044
  } catch (error) {
1045
1045
  throw invalid(`invalid Yjs snapshot: ${error?.message || error}`);
1046
1046
  }
1047
+ const replicas = require('./replicas');
1047
1048
  const entry = loadDocEntry(pathInput);
1048
- const currentText = entry.doc.getText(TEXT_KEY).toString();
1049
- if (currentText.length > 0) {
1049
+ const existingReplica = replicas.getReplicaByResourceId(input?.resourceId);
1050
+ if (existingReplica) {
1051
+ // Rehydration: this replica fell behind the pruned official tail, so the
1052
+ // authority serves a full snapshot instead. Applying it to the live doc
1053
+ // is a CRDT merge — official state and unsynced local edits both survive,
1054
+ // and the outbox still carries those local edits to the cloud.
1055
+ if (existingReplica.localResource !== entry.resource) {
1056
+ throw invalid(`shared resource ${input?.resourceId} is bound to a different local file`, 409);
1057
+ }
1058
+ } else if (entry.doc.getText(TEXT_KEY).toString().length > 0) {
1050
1059
  throw invalid('cloud snapshot attachment requires an empty local text file', 409);
1051
1060
  }
1052
1061
  const origin = { amalgmMutation: true, source: 'cloud-snapshot', patch: null };
1053
1062
  Y.applyUpdate(entry.doc, new Uint8Array(bytes), origin);
1054
1063
  persistDocState(Y, entry);
1055
1064
  flushDocToDisk(Y, entry);
1056
- const registered = require('./replicas').registerReplica({
1057
- resourceId: input?.resourceId,
1058
- localResource: entry.resource,
1059
- localPath: entry.path,
1060
- contract: input?.contract,
1061
- schemaVersion: input?.schemaVersion,
1062
- authorityEpoch: input?.authorityEpoch,
1063
- appliedVersion: input?.snapshotVersion,
1064
- snapshotVersion: input?.snapshotVersion,
1065
- snapshotChecksum: checksum,
1066
- });
1065
+ const registered = existingReplica
1066
+ ? {
1067
+ replica: replicas.advanceReplicaSnapshot({
1068
+ resourceId: input?.resourceId,
1069
+ snapshotVersion: input?.snapshotVersion,
1070
+ snapshotChecksum: checksum,
1071
+ authorityEpoch: input?.authorityEpoch,
1072
+ }),
1073
+ }
1074
+ : replicas.registerReplica({
1075
+ resourceId: input?.resourceId,
1076
+ localResource: entry.resource,
1077
+ localPath: entry.path,
1078
+ contract: input?.contract,
1079
+ schemaVersion: input?.schemaVersion,
1080
+ authorityEpoch: input?.authorityEpoch,
1081
+ appliedVersion: input?.snapshotVersion,
1082
+ snapshotVersion: input?.snapshotVersion,
1083
+ snapshotChecksum: checksum,
1084
+ });
1067
1085
  // File and SQLite binding are current before a mounted UI can observe the
1068
1086
  // snapshot. Applying this full Yjs state is idempotent for an already-open
1069
1087
  // recipient surface.
@@ -193,7 +193,50 @@ function refreshReplicaAuthority(input, options = {}) {
193
193
  return getReplicaByResourceId(resourceId, { database });
194
194
  }
195
195
 
196
+ /**
197
+ * A freshly attached official snapshot subsumes every version at or before
198
+ * it, so the replica's cursors jump forward to the snapshot — never backward:
199
+ * a replica that already applied past the snapshot keeps its position.
200
+ */
201
+ function advanceReplicaSnapshot(input, options = {}) {
202
+ const resourceId = requiredText(input?.resourceId, 'resourceId', 132);
203
+ const snapshotVersion = integer(input?.snapshotVersion, 'snapshotVersion', 0);
204
+ const snapshotChecksum = requiredText(input?.snapshotChecksum, 'snapshotChecksum', 64);
205
+ if (!/^[0-9a-f]{64}$/.test(snapshotChecksum)) {
206
+ throw invalid('snapshotChecksum must be lowercase sha256 hex');
207
+ }
208
+ const authorityEpoch = integer(input?.authorityEpoch, 'authorityEpoch', 1);
209
+ const database = options.database || openLocalDb();
210
+ const row = cachedStatement(
211
+ database,
212
+ 'SELECT * FROM shared_resource_replicas WHERE resource_id = ?',
213
+ ).get(resourceId);
214
+ if (!row) throw invalid(`unknown shared replica ${resourceId}`, 404);
215
+ cachedStatement(database, `
216
+ UPDATE shared_resource_replicas
217
+ SET applied_version = MAX(applied_version, ?),
218
+ snapshot_version = MAX(snapshot_version, ?),
219
+ snapshot_checksum = CASE WHEN snapshot_version <= ? THEN ? ELSE snapshot_checksum END,
220
+ authority_epoch = MAX(authority_epoch, ?),
221
+ sync_status = 'synced',
222
+ last_error = NULL,
223
+ updated_at = ?
224
+ WHERE resource_id = ?
225
+ `).run(
226
+ snapshotVersion,
227
+ snapshotVersion,
228
+ snapshotVersion,
229
+ snapshotChecksum,
230
+ authorityEpoch,
231
+ new Date().toISOString(),
232
+ resourceId,
233
+ );
234
+ trace('replica-rehydrate', { resourceId, snapshotVersion });
235
+ return getReplicaByResourceId(resourceId, { database });
236
+ }
237
+
196
238
  module.exports = {
239
+ advanceReplicaSnapshot,
197
240
  getReplicaByLocalResource,
198
241
  getReplicaByResourceId,
199
242
  listReplicas,
@@ -61,6 +61,14 @@ async function handleReplicaAuthority(body, sendJson) {
61
61
  }
62
62
  }
63
63
 
64
+ async function handleReplicaRehydrate(body, sendJson) {
65
+ try {
66
+ sendJson(202, require('./attachments').requestRehydration({ resourceId: body?.resourceId }));
67
+ } catch (error) {
68
+ sendHandlerError(error, sendJson);
69
+ }
70
+ }
71
+
64
72
  async function handlePromotionStart(body, sendJson) {
65
73
  try {
66
74
  const opened = require('./docs').openDoc(body?.path);
@@ -264,4 +272,5 @@ module.exports = {
264
272
  handleReplicaAuthority,
265
273
  handleReplicaList,
266
274
  handleReplicaRegister,
275
+ handleReplicaRehydrate,
267
276
  };
@@ -29,6 +29,12 @@ function writeReleaseMetadata(lane, artifacts, extra = {}) {
29
29
  fs.writeFileSync(path.join(laneDir, 'desktop-release.json'), `${JSON.stringify({ lane, artifacts, ...extra }, null, 2)}\n`);
30
30
  }
31
31
 
32
+ function writeWindowsReleaseMetadata(lane, artifacts, extra = {}) {
33
+ const laneDir = path.join(tempRoot, lane);
34
+ fs.mkdirSync(laneDir, { recursive: true });
35
+ fs.writeFileSync(path.join(laneDir, 'desktop-release.windows.json'), `${JSON.stringify({ lane, platform: 'windows', artifacts, ...extra }, null, 2)}\n`);
36
+ }
37
+
32
38
  function writeLaneFile(lane, fileName, mtimeOffsetMs = 0) {
33
39
  const laneDir = path.join(tempRoot, lane);
34
40
  fs.mkdirSync(laneDir, { recursive: true });
@@ -72,6 +78,7 @@ test('desktop release server resolves stable bridge download targets from metada
72
78
 
73
79
  assert.deepEqual(parseDownloadPath('/main/download'), { lane: 'main', kind: 'dmg' });
74
80
  assert.deepEqual(parseDownloadPath('/main/download/zip'), { lane: 'main', kind: 'zip' });
81
+ assert.deepEqual(parseDownloadPath('/main/download/exe'), { lane: 'main', kind: 'exe' });
75
82
  assert.deepEqual(parseDownloadPath('/main/latest.dmg'), { lane: 'main', kind: 'dmg' });
76
83
  assert.equal(parseDownloadPath('/main/latest-mac.yml'), null);
77
84
  assert.equal(latestArtifactName({ artifacts }, 'dmg'), 'amalgm-local-ui-0.1.1-arm64.dmg');
@@ -81,6 +88,37 @@ test('desktop release server resolves stable bridge download targets from metada
81
88
  });
82
89
  });
83
90
 
91
+ test('desktop release server exposes the current Windows installer', async () => {
92
+ const exeName = 'amalgm-local-ui-0.1.2-x64.exe';
93
+ writeLaneFile('main', exeName);
94
+ writeWindowsReleaseMetadata('main', [exeName, `${exeName}.blockmap`, 'latest.yml'], {
95
+ version: '0.1.2',
96
+ primaryDownload: {
97
+ platform: 'windows',
98
+ kind: 'exe',
99
+ artifact: exeName,
100
+ size: 123,
101
+ sha512: 'windows-sha',
102
+ },
103
+ });
104
+
105
+ assert.equal(latestDownloadTarget('main', 'exe').artifactName, exeName);
106
+
107
+ const server = createServer();
108
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
109
+ const { port } = server.address();
110
+ try {
111
+ const response = await fetch(`http://127.0.0.1:${port}/main/download/exe`, { redirect: 'manual' });
112
+ assert.equal(response.status, 302);
113
+ assert.equal(
114
+ response.headers.get('location'),
115
+ `http://127.0.0.1:${port}/main/${exeName}?download=amalgm-Windows.exe`,
116
+ );
117
+ } finally {
118
+ await new Promise((resolve) => server.close(resolve));
119
+ }
120
+ });
121
+
84
122
  test('desktop release server honors explicit metadata artifact descriptors', () => {
85
123
  const metadata = {
86
124
  artifacts: [
@@ -0,0 +1,115 @@
1
+ 'use strict';
2
+
3
+ const test = require('node:test');
4
+ const assert = require('node:assert/strict');
5
+ const http = require('http');
6
+
7
+ const { createSharedRealtimeTunnel } = require('../../../../packages/amalgm/lib/shared-realtime-tunnel');
8
+
9
+ const RESOURCE = 'shr_tunnel_rehydrate1';
10
+
11
+ function sleep(ms) {
12
+ return new Promise((resolve) => { setTimeout(resolve, ms); });
13
+ }
14
+
15
+ async function until(predicate, label) {
16
+ for (let i = 0; i < 100; i += 1) {
17
+ if (predicate()) return;
18
+ await sleep(10);
19
+ }
20
+ assert.fail(`timed out waiting for ${label}`);
21
+ }
22
+
23
+ // The tunnel client against a real (local, in-test) state service: a join
24
+ // answered with shared_tail_pruned or shared_tail_too_large must turn into a
25
+ // rehydration request plus an attachment drain; any other join failure must not.
26
+ test('a pruned-tail join answer triggers rehydration through the local state service', async () => {
27
+ const calls = [];
28
+ const server = http.createServer((req, res) => {
29
+ let body = '';
30
+ req.on('data', (chunk) => { body += chunk; });
31
+ req.on('end', () => {
32
+ calls.push({ method: req.method, path: req.url, body: body ? JSON.parse(body) : null });
33
+ if (req.url === '/state/shared/replicas') {
34
+ res.setHeader('content-type', 'application/json');
35
+ res.end(JSON.stringify({ replicas: [{ resourceId: RESOURCE, appliedVersion: 3 }] }));
36
+ return;
37
+ }
38
+ if (req.url.startsWith('/state/stream')) {
39
+ res.writeHead(200, { 'content-type': 'text/event-stream' });
40
+ res.write('\n');
41
+ return; // stays open: it is only a wake signal
42
+ }
43
+ res.setHeader('content-type', 'application/json');
44
+ res.end(JSON.stringify({ mutations: [], promotions: [], attachments: [] }));
45
+ });
46
+ });
47
+ await new Promise((resolve) => { server.listen(0, '127.0.0.1', resolve); });
48
+ const port = server.address().port;
49
+
50
+ const sentFrames = [];
51
+ const tunnel = createSharedRealtimeTunnel({
52
+ send: (frame) => { sentFrames.push(frame); return true; },
53
+ sendLarge: (frame) => { sentFrames.push(frame); return true; },
54
+ localPort: () => port,
55
+ localHeaders: (headers) => ({ ...headers }),
56
+ warn: () => {},
57
+ });
58
+
59
+ try {
60
+ tunnel.enable(['shared.mutation.v1']);
61
+ await until(
62
+ () => sentFrames.some((frame) => frame.type === 'shared.resource.join'),
63
+ 'the reconnect join',
64
+ );
65
+ const join = sentFrames.find((frame) => frame.type === 'shared.resource.join');
66
+ assert.equal(join.resource_id, RESOURCE);
67
+ assert.equal(join.after_version, 3, 'the join carries the durable replica cursor');
68
+
69
+ tunnel.handleFrame({
70
+ type: 'shared.error',
71
+ request_id: join.request_id,
72
+ resource_id: RESOURCE,
73
+ code: 'shared_tail_pruned',
74
+ message: 'versions after 3 were folded into snapshot v40',
75
+ status: 409,
76
+ retryable: false,
77
+ });
78
+ await until(
79
+ () => calls.some((call) => call.path === '/state/shared/replicas/rehydrate'),
80
+ 'the rehydration request',
81
+ );
82
+ const rehydrate = calls.find((call) => call.path === '/state/shared/replicas/rehydrate');
83
+ assert.equal(rehydrate.method, 'POST');
84
+ assert.deepEqual(rehydrate.body, { resourceId: RESOURCE });
85
+ await until(
86
+ () => calls.some((call) => call.path === '/state/shared/attachments/claim'),
87
+ 'the attachment drain that carries the rehydration',
88
+ );
89
+
90
+ // A join failure that is not about the tail must not trigger rehydration.
91
+ const rehydrations = () => calls.filter((call) => call.path === '/state/shared/replicas/rehydrate').length;
92
+ const before = rehydrations();
93
+ sentFrames.length = 0;
94
+ tunnel.enable(['shared.mutation.v1']); // re-pump: a fresh join for the same replica
95
+ await until(
96
+ () => sentFrames.some((frame) => frame.type === 'shared.resource.join'),
97
+ 'the second join',
98
+ );
99
+ const secondJoin = sentFrames.find((frame) => frame.type === 'shared.resource.join');
100
+ tunnel.handleFrame({
101
+ type: 'shared.error',
102
+ request_id: secondJoin.request_id,
103
+ resource_id: RESOURCE,
104
+ code: 'shared_resource_read_forbidden',
105
+ message: 'shared resource is unavailable',
106
+ status: 403,
107
+ retryable: false,
108
+ });
109
+ await sleep(50);
110
+ assert.equal(rehydrations(), before, 'a permission failure is not a rehydration signal');
111
+ } finally {
112
+ tunnel.stop();
113
+ server.close();
114
+ }
115
+ });
@@ -463,3 +463,68 @@ test('an executable file stays executable through a live write', () => {
463
463
  committedAt: '2026-07-15T20:00:03.000Z',
464
464
  });
465
465
  });
466
+
467
+ // Compaction on the authority prunes official rows the moment a verified
468
+ // snapshot covers them (past the retention window). A replica whose cursor
469
+ // fell behind that floor recovers through a fresh snapshot attachment — and
470
+ // because applying a full Yjs state is a CRDT merge, its unsynced local
471
+ // edits survive the jump.
472
+ test('a pruned-tail replica rehydrates: snapshot merge keeps unsynced local edits and jumps the cursor', () => {
473
+ const recipientResourceId = 'shr_qrstuvwxyzabcdef';
474
+ const recipientPath = path.join(docDir, 'recipient.md');
475
+ const opened = docs.openDoc(recipientPath);
476
+
477
+ // An unsynced local edit: journaled for the cloud, never acknowledged.
478
+ const client = clientFromState(opened.state);
479
+ const localUpdate = captureEdit(client, (text) => text.insert(0, 'local-unsynced '));
480
+ docs.applyDocMutations(recipientPath, [
481
+ { mutationId: 'mut_rehydrate_local_1', update: localUpdate },
482
+ ]);
483
+
484
+ // Meanwhile the cloud moved to version 40 and pruned the tail. Its
485
+ // snapshot contains an edit this replica never received.
486
+ const cloudDoc = clientFromState(opened.state);
487
+ cloudDoc.getText('content').insert(cloudDoc.getText('content').length, 'cloud-edit\n');
488
+ const cloudSnapshot = Buffer.from(Y.encodeStateAsUpdate(cloudDoc));
489
+ const checksum = require('crypto').createHash('sha256').update(cloudSnapshot).digest('hex');
490
+
491
+ const { requestRehydration } = require('../state/attachments');
492
+ const requested = requestRehydration({ resourceId: recipientResourceId });
493
+ assert.equal(requested.requeued, true, 'the original attachment row is the durable request');
494
+ assert.equal(requested.attachment.state, 'pending');
495
+ const claims = claimAttachments();
496
+ assert.ok(claims.some((claim) => claim.attachmentId === requested.attachment.attachmentId));
497
+
498
+ const attached = acknowledgeAttachment(requested.attachment.attachmentId, {
499
+ resourceId: recipientResourceId,
500
+ authorityEpoch: 3,
501
+ headVersion: 40,
502
+ snapshotVersion: 40,
503
+ snapshotChecksum: checksum,
504
+ snapshotBase64: cloudSnapshot.toString('base64'),
505
+ contract: 'text-yjs@1',
506
+ schemaVersion: 1,
507
+ });
508
+ assert.equal(attached.attachment.state, 'attached');
509
+
510
+ const replica = getReplicaByResourceId(recipientResourceId);
511
+ assert.equal(replica.appliedVersion, 40, 'the cursor jumps to the snapshot');
512
+ assert.equal(replica.snapshotVersion, 40);
513
+
514
+ const text = fs.readFileSync(recipientPath, 'utf8');
515
+ assert.ok(text.includes('local-unsynced '), 'the unsynced local edit survives rehydration');
516
+ assert.ok(text.includes('cloud-edit'), 'the official cloud state arrives');
517
+
518
+ // The local edit is still owed to the cloud: rehydration never empties the outbox.
519
+ const pending = getMutation(`resource:${recipientResourceId}`, 'mut_rehydrate_local_1');
520
+ assert.notEqual(pending.state, 'synced');
521
+ });
522
+
523
+ test('a promotion-born replica (which never attached) gets an attachment row for rehydration', () => {
524
+ const { requestRehydration } = require('../state/attachments');
525
+ const requested = requestRehydration({ resourceId });
526
+ assert.equal(requested.requeued, false, 'the sharer has no attachment history; one is created');
527
+ assert.equal(requested.attachment.state, 'pending');
528
+ assert.equal(requested.attachment.resourceId, resourceId);
529
+ assert.equal(requested.attachment.localPath, target);
530
+ });
@@ -0,0 +1,88 @@
1
+ 'use strict';
2
+
3
+ const assert = require('node:assert/strict');
4
+ const fs = require('node:fs');
5
+ const os = require('node:os');
6
+ const path = require('node:path');
7
+ const { spawn } = require('node:child_process');
8
+ const test = require('node:test');
9
+
10
+ test('Windows publisher uploads local artifacts and activates the web download', async () => {
11
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-windows-publish-'));
12
+ const buildDir = path.join(tempRoot, 'build');
13
+ const dataRoot = path.join(tempRoot, 'data');
14
+ const uploadToken = 'windows-publish-test-token';
15
+ fs.mkdirSync(buildDir, { recursive: true });
16
+ fs.writeFileSync(path.join(buildDir, 'amalgm-local-ui-0.1.201-x64.exe'), 'fake windows installer');
17
+ fs.writeFileSync(path.join(buildDir, 'amalgm-local-ui-0.1.201-x64.exe.blockmap'), 'fake blockmap');
18
+ fs.writeFileSync(path.join(buildDir, 'latest.yml'), [
19
+ 'version: 0.1.201',
20
+ 'path: amalgm-local-ui-0.1.201-x64.exe',
21
+ 'sha512: fake-sha',
22
+ '',
23
+ ].join('\n'));
24
+
25
+ const previousDataRoot = process.env.DATA_ROOT;
26
+ const previousUploadToken = process.env.UPLOAD_TOKEN;
27
+ process.env.DATA_ROOT = dataRoot;
28
+ process.env.UPLOAD_TOKEN = uploadToken;
29
+ const serverPath = path.resolve(__dirname, '../../../../infra/desktop-release-server/server.js');
30
+ delete require.cache[serverPath];
31
+ const { createServer } = require(serverPath);
32
+ const server = createServer();
33
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
34
+ const { port } = server.address();
35
+
36
+ try {
37
+ const scriptPath = path.resolve(__dirname, '../../../../scripts/publish-windows-release.mjs');
38
+ const child = spawn(process.execPath, [scriptPath, 'main'], {
39
+ cwd: path.resolve(__dirname, '../../../..'),
40
+ env: {
41
+ ...process.env,
42
+ AMALGM_WINDOWS_BUILD_DIR: buildDir,
43
+ AMALGM_DESKTOP_RELEASE_UPLOAD_URL: `http://127.0.0.1:${port}`,
44
+ AMALGM_DESKTOP_RELEASE_UPLOAD_TOKEN: uploadToken,
45
+ AMALGM_DESKTOP_ENGINE_SHA: 'engine-test-sha',
46
+ AMALGM_DESKTOP_UI_SHA: 'ui-test-sha',
47
+ },
48
+ stdio: ['ignore', 'pipe', 'pipe'],
49
+ });
50
+ let stdout = '';
51
+ let stderr = '';
52
+ child.stdout.on('data', (chunk) => { stdout += chunk; });
53
+ child.stderr.on('data', (chunk) => { stderr += chunk; });
54
+ const status = await new Promise((resolve, reject) => {
55
+ const timer = setTimeout(() => {
56
+ child.kill('SIGKILL');
57
+ reject(new Error('Windows publisher test timed out'));
58
+ }, 30_000);
59
+ child.once('error', (error) => {
60
+ clearTimeout(timer);
61
+ reject(error);
62
+ });
63
+ child.once('exit', (code) => {
64
+ clearTimeout(timer);
65
+ resolve(code);
66
+ });
67
+ });
68
+
69
+ assert.equal(status, 0, [stdout, stderr].filter(Boolean).join('\n'));
70
+ assert.equal(fs.existsSync(path.join(dataRoot, 'main', 'amalgm-local-ui-0.1.201-x64.exe')), true);
71
+ assert.equal(fs.existsSync(path.join(dataRoot, 'main', 'latest.yml')), true);
72
+ const metadata = JSON.parse(fs.readFileSync(path.join(dataRoot, 'main', 'desktop-release.windows.json'), 'utf8'));
73
+ assert.equal(metadata.version, '0.1.201');
74
+ assert.equal(metadata.primaryDownload.kind, 'exe');
75
+ assert.equal(metadata.uiSha, 'ui-test-sha');
76
+
77
+ const response = await fetch(`http://127.0.0.1:${port}/main/download/exe`, { redirect: 'manual' });
78
+ assert.equal(response.status, 302);
79
+ assert.match(response.headers.get('location'), /amalgm-local-ui-0\.1\.201-x64\.exe/);
80
+ } finally {
81
+ await new Promise((resolve) => server.close(resolve));
82
+ if (previousDataRoot === undefined) delete process.env.DATA_ROOT;
83
+ else process.env.DATA_ROOT = previousDataRoot;
84
+ if (previousUploadToken === undefined) delete process.env.UPLOAD_TOKEN;
85
+ else process.env.UPLOAD_TOKEN = previousUploadToken;
86
+ fs.rmSync(tempRoot, { recursive: true, force: true });
87
+ }
88
+ });