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 +1 -1
- package/lib/service.js +48 -2
- package/lib/shared-realtime-tunnel.js +28 -2
- package/package.json +1 -1
- package/runtime/scripts/amalgm-mcp/server/routes/state.js +4 -0
- package/runtime/scripts/amalgm-mcp/state/attachments.js +54 -0
- package/runtime/scripts/amalgm-mcp/state/docs.js +31 -13
- package/runtime/scripts/amalgm-mcp/state/replicas.js +43 -0
- package/runtime/scripts/amalgm-mcp/state/shared-rest.js +9 -0
- package/runtime/scripts/amalgm-mcp/tests/desktop-release-server.test.js +38 -0
- package/runtime/scripts/amalgm-mcp/tests/shared-tunnel-rehydration.test.js +115 -0
- package/runtime/scripts/amalgm-mcp/tests/state-shared-replica.test.js +65 -0
- package/runtime/scripts/amalgm-mcp/tests/windows-release-publish.test.js +88 -0
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
|
-
|
|
243
|
+
const requestId = `join_${crypto.randomBytes(12).toString('hex')}`;
|
|
244
|
+
const queued = send({
|
|
243
245
|
type: 'shared.resource.join',
|
|
244
|
-
request_id:
|
|
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
|
@@ -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
|
|
1049
|
-
if (
|
|
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 =
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
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
|
+
});
|