amalgm 0.1.158 → 0.1.159
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/package.json +1 -1
- package/runtime/scripts/amalgm-mcp/fs/rest.js +1 -1
- package/runtime/scripts/amalgm-mcp/index.js +2 -0
- package/runtime/scripts/amalgm-mcp/server/routes/state.js +4 -0
- package/runtime/scripts/amalgm-mcp/server/routes/workspace.js +16 -0
- package/runtime/scripts/amalgm-mcp/state/attachments.js +43 -0
- package/runtime/scripts/amalgm-mcp/state/db.js +35 -0
- package/runtime/scripts/amalgm-mcp/state/presence.js +175 -0
- package/runtime/scripts/amalgm-mcp/state/promotions.js +10 -0
- package/runtime/scripts/amalgm-mcp/state/rest.js +41 -0
- package/runtime/scripts/amalgm-mcp/state/shared-rest.js +20 -1
- package/runtime/scripts/amalgm-mcp/tests/state-presence.test.js +130 -0
- package/runtime/scripts/amalgm-mcp/tests/state-promotion-ownership.test.js +86 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-cards-store.test.js +326 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-cards.test.js +401 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-wall.test.js +148 -0
- package/runtime/scripts/amalgm-mcp/tools/shared-live-smoke.js +227 -0
- package/runtime/scripts/amalgm-mcp/workspace/card.js +199 -0
- package/runtime/scripts/amalgm-mcp/workspace/cards.js +437 -0
- package/runtime/scripts/amalgm-mcp/workspace/git.js +286 -0
- package/runtime/scripts/amalgm-mcp/workspace/identity.js +88 -0
- package/runtime/scripts/amalgm-mcp/workspace/rest.js +64 -0
- package/runtime/scripts/amalgm-mcp/workspace/wall.js +308 -0
package/package.json
CHANGED
|
@@ -12,7 +12,7 @@ const { appendStateEvent } = require('../state/events');
|
|
|
12
12
|
const { createFsSearchHandler } = require('./search');
|
|
13
13
|
|
|
14
14
|
const TEXT_EXTENSIONS = new Set([
|
|
15
|
-
'txt', 'md', 'json', 'js', 'ts', 'tsx', 'jsx', 'py', 'html', 'css',
|
|
15
|
+
'txt', 'md', 'json', 'jsonl', 'js', 'ts', 'tsx', 'jsx', 'py', 'html', 'css',
|
|
16
16
|
'xml', 'yaml', 'yml', 'sh', 'bash', 'env', 'gitignore', 'dockerfile',
|
|
17
17
|
'svg', 'csv', 'sql', 'toml', 'rs', 'go', 'rb', 'java', 'kt', 'swift',
|
|
18
18
|
'c', 'cpp', 'cs', 'php', 'scss', 'log', 'ini', 'cfg', 'conf',
|
|
@@ -127,6 +127,8 @@ async function boot() {
|
|
|
127
127
|
startAppRouteSyncLoop();
|
|
128
128
|
startHealthMonitor();
|
|
129
129
|
require('./state/ports').startPortsBridge();
|
|
130
|
+
require('./workspace/cards').startRepoCardService();
|
|
131
|
+
require('./workspace/wall').startWallService();
|
|
130
132
|
}
|
|
131
133
|
|
|
132
134
|
boot().catch((err) => {
|
|
@@ -59,6 +59,10 @@ async function handleStateRoutes(ctx) {
|
|
|
59
59
|
await stateRest.handleResourceEmit(await ctx.readJsonBody(), ctx.sendJson);
|
|
60
60
|
return true;
|
|
61
61
|
}
|
|
62
|
+
if (ctx.pathname === '/state/presence') {
|
|
63
|
+
await stateRest.handlePresenceUpdate(await ctx.readJsonBody(), ctx.sendJson);
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
62
66
|
if (ctx.pathname === '/state/docs/open') {
|
|
63
67
|
await stateRest.handleDocOpen(await ctx.readJsonBody(), ctx.sendJson);
|
|
64
68
|
return true;
|
|
@@ -51,6 +51,22 @@ async function handleWorkspaceRoutes(ctx) {
|
|
|
51
51
|
workspaceRest.handleWorktreeDelete(await ctx.readJsonBody(), ctx.sendJson);
|
|
52
52
|
return true;
|
|
53
53
|
}
|
|
54
|
+
if (ctx.pathname === '/workspace/repos' && ctx.method === 'GET') {
|
|
55
|
+
workspaceRest.handleRepoCards(ctx.sendJson);
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
if (ctx.pathname === '/workspace/repos/refresh' && ctx.method === 'POST') {
|
|
59
|
+
await workspaceRest.handleRepoCardsRefresh(ctx.sendJson);
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
if (ctx.pathname === '/workspace/sync/stats' && ctx.method === 'GET') {
|
|
63
|
+
workspaceRest.handleWorkspaceSyncStats(ctx.sendJson);
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
if (ctx.pathname === '/workspace/machines' && ctx.method === 'GET') {
|
|
67
|
+
workspaceRest.handleWorkspaceMachines(ctx.sendJson);
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
54
70
|
return false;
|
|
55
71
|
}
|
|
56
72
|
|
|
@@ -231,11 +231,54 @@ function getAttachment(attachmentId, options = {}) {
|
|
|
231
231
|
);
|
|
232
232
|
}
|
|
233
233
|
|
|
234
|
+
/** The one durable attachment request for a resource, if any. */
|
|
235
|
+
function getAttachmentByResource(resourceId, options = {}) {
|
|
236
|
+
const database = options.database || openLocalDb();
|
|
237
|
+
return normalizeRow(
|
|
238
|
+
cachedStatement(database, 'SELECT * FROM shared_resource_attachments WHERE resource_id = ?')
|
|
239
|
+
.get(resourceId),
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Cycle a refused (or stuck) attachment request back to pending — for a
|
|
245
|
+
* binding whose cloud answer may have changed since the refusal, e.g. a
|
|
246
|
+
* derived-id resource that lost its creation race and exists now. Unlike
|
|
247
|
+
* requestRehydration this needs no replica: it retries the first binding.
|
|
248
|
+
*/
|
|
249
|
+
function retryAttachment(resourceIdInput, options = {}) {
|
|
250
|
+
const resourceId = requiredText(resourceIdInput, 'resourceId', 132);
|
|
251
|
+
if (!SHARED_RESOURCE_ID.test(resourceId)) throw invalid('resourceId has an invalid format');
|
|
252
|
+
const database = options.database || openLocalDb();
|
|
253
|
+
const now = new Date().toISOString();
|
|
254
|
+
const result = cachedStatement(database, `
|
|
255
|
+
UPDATE shared_resource_attachments
|
|
256
|
+
SET attachment_state = 'pending',
|
|
257
|
+
next_attempt_at = NULL,
|
|
258
|
+
last_error = NULL,
|
|
259
|
+
updated_at = ?
|
|
260
|
+
WHERE resource_id = ? AND attachment_state != 'attached'
|
|
261
|
+
`).run(now, resourceId);
|
|
262
|
+
if (!result.changes) throw invalid(`no retryable attachment for ${resourceId}`, 404);
|
|
263
|
+
const attachment = getAttachmentByResource(resourceId, { database });
|
|
264
|
+
appendStateEvent({
|
|
265
|
+
resource: 'shared:attachments',
|
|
266
|
+
op: 'update',
|
|
267
|
+
id: attachment.attachmentId,
|
|
268
|
+
sharedResourceId: resourceId,
|
|
269
|
+
value: { state: 'pending' },
|
|
270
|
+
source: 'shared:attachment-retry',
|
|
271
|
+
});
|
|
272
|
+
return attachment;
|
|
273
|
+
}
|
|
274
|
+
|
|
234
275
|
module.exports = {
|
|
235
276
|
acknowledgeAttachment,
|
|
236
277
|
claimAttachments,
|
|
237
278
|
enqueueAttachment,
|
|
238
279
|
failAttachment,
|
|
239
280
|
getAttachment,
|
|
281
|
+
getAttachmentByResource,
|
|
240
282
|
requestRehydration,
|
|
283
|
+
retryAttachment,
|
|
241
284
|
};
|
|
@@ -58,6 +58,7 @@ function openLocalDb() {
|
|
|
58
58
|
}
|
|
59
59
|
|
|
60
60
|
function migrate(database = openLocalDb()) {
|
|
61
|
+
dropLegacyRepoCards(database);
|
|
61
62
|
database.exec(`
|
|
62
63
|
CREATE TABLE IF NOT EXISTS event_log (
|
|
63
64
|
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
@@ -531,6 +532,29 @@ function migrate(database = openLocalDb()) {
|
|
|
531
532
|
CREATE INDEX IF NOT EXISTS workspaces_location_type_idx
|
|
532
533
|
ON workspaces(location_type);
|
|
533
534
|
|
|
535
|
+
CREATE TABLE IF NOT EXISTS repo_replicas (
|
|
536
|
+
git_dir TEXT PRIMARY KEY,
|
|
537
|
+
replica_id TEXT NOT NULL UNIQUE,
|
|
538
|
+
root_commit TEXT,
|
|
539
|
+
created_at TEXT NOT NULL
|
|
540
|
+
);
|
|
541
|
+
|
|
542
|
+
CREATE TABLE IF NOT EXISTS repo_cards (
|
|
543
|
+
replica_id TEXT PRIMARY KEY,
|
|
544
|
+
path TEXT NOT NULL,
|
|
545
|
+
git_dir TEXT NOT NULL,
|
|
546
|
+
root TEXT NOT NULL,
|
|
547
|
+
health TEXT NOT NULL,
|
|
548
|
+
fingerprint TEXT NOT NULL,
|
|
549
|
+
generation INTEGER NOT NULL,
|
|
550
|
+
first_seen_at TEXT NOT NULL,
|
|
551
|
+
updated_at TEXT NOT NULL,
|
|
552
|
+
card_json TEXT NOT NULL
|
|
553
|
+
);
|
|
554
|
+
|
|
555
|
+
CREATE INDEX IF NOT EXISTS repo_cards_path_idx
|
|
556
|
+
ON repo_cards(path);
|
|
557
|
+
|
|
534
558
|
CREATE TABLE IF NOT EXISTS browser_profiles (
|
|
535
559
|
id TEXT PRIMARY KEY,
|
|
536
560
|
name TEXT NOT NULL,
|
|
@@ -621,6 +645,17 @@ function ensureColumn(database, tableName, columnName, declaration) {
|
|
|
621
645
|
database.exec(`ALTER TABLE ${tableName} ADD COLUMN ${columnName} ${declaration}`);
|
|
622
646
|
}
|
|
623
647
|
|
|
648
|
+
// repo-card identity moved from the path to minted replica ids
|
|
649
|
+
// (repo-card@2). Cards are derived observations, so the v1 table is
|
|
650
|
+
// dropped rather than migrated — the next refresh rebuilds every card
|
|
651
|
+
// straight from git.
|
|
652
|
+
function dropLegacyRepoCards(database) {
|
|
653
|
+
const columns = database.prepare('PRAGMA table_info(repo_cards)').all();
|
|
654
|
+
if (columns.length > 0 && !columns.some((column) => column.name === 'replica_id')) {
|
|
655
|
+
database.exec('DROP TABLE repo_cards');
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
624
659
|
// ---------------------------------------------------------------------------
|
|
625
660
|
// Lazily created tables. The DDL lives here so this file owns every schema
|
|
626
661
|
// statement, but these tables are only created when their feature is first
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The ephemeral presence lane (realtime-sdk.md §6, "Ephemeral").
|
|
5
|
+
*
|
|
6
|
+
* Presence is the channel connection made visible: per resource, the set of
|
|
7
|
+
* live bindings (sessions) and the small opaque position blob each one last
|
|
8
|
+
* published. Everything here is the inverse of the durable lane in events.js:
|
|
9
|
+
* never written to the event log, never in snapshots or replay, expired by
|
|
10
|
+
* TTL instead of retained — a caret that outlives its author is a lie, where
|
|
11
|
+
* a doc byte must outlive everything.
|
|
12
|
+
*
|
|
13
|
+
* Ownership rules the wire shape enforces:
|
|
14
|
+
* - Identity is stamped HERE from the enrolled account (workspace/identity),
|
|
15
|
+
* never accepted from the publisher. A blob cannot impersonate.
|
|
16
|
+
* - The blob is opaque. Only the surface bound to the same resource on the
|
|
17
|
+
* other end knows its coordinate system (text caret, canvas x,y, nothing).
|
|
18
|
+
* - Absence is the default presence must re-earn: entries live TTL_MS from
|
|
19
|
+
* their last publish, and a session's death (stream teardown or expiry)
|
|
20
|
+
* removes it everywhere without its cooperation.
|
|
21
|
+
*
|
|
22
|
+
* Broadcast shape: the full peer list per resource, re-sent on every change.
|
|
23
|
+
* Removal is implicit (absent from the list), so there is no ordering, no
|
|
24
|
+
* seq, and nothing to replay — receivers replace, never merge.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const { EventEmitter } = require('events');
|
|
28
|
+
|
|
29
|
+
const emitter = new EventEmitter();
|
|
30
|
+
emitter.setMaxListeners(200);
|
|
31
|
+
|
|
32
|
+
/** A peer whose publisher has gone silent this long is gone, no exceptions. */
|
|
33
|
+
const PRESENCE_TTL_MS = 30_000;
|
|
34
|
+
const SWEEP_INTERVAL_MS = 10_000;
|
|
35
|
+
/** Positions are small facts; anything bigger is data smuggling on the lane. */
|
|
36
|
+
const MAX_BLOB_JSON_BYTES = 8 * 1024;
|
|
37
|
+
|
|
38
|
+
/** resource -> (session -> { session, user, blob, at, activeAt }) */
|
|
39
|
+
const channels = new Map();
|
|
40
|
+
let sweepTimer = null;
|
|
41
|
+
|
|
42
|
+
function requireString(value, field) {
|
|
43
|
+
if (typeof value !== 'string' || !value.trim()) {
|
|
44
|
+
const error = new Error(`presence requires a non-empty "${field}" string`);
|
|
45
|
+
error.statusCode = 400;
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
return value.trim();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function stampedUser() {
|
|
52
|
+
// Lazy require: identity reads config/db at load, and presence must stay
|
|
53
|
+
// importable in tests that stub neither.
|
|
54
|
+
try {
|
|
55
|
+
return require('../workspace/identity').userId() || null;
|
|
56
|
+
} catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function peersOf(resource) {
|
|
62
|
+
const peers = channels.get(resource);
|
|
63
|
+
return peers ? Array.from(peers.values()) : [];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function broadcast(resource) {
|
|
67
|
+
emitter.emit('presence', { resource, peers: peersOf(resource) });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function ensureSweeper() {
|
|
71
|
+
if (sweepTimer) return;
|
|
72
|
+
sweepTimer = setInterval(() => {
|
|
73
|
+
const cutoff = Date.now() - PRESENCE_TTL_MS;
|
|
74
|
+
for (const [resource, peers] of channels) {
|
|
75
|
+
let changed = false;
|
|
76
|
+
for (const [session, entry] of peers) {
|
|
77
|
+
if (entry.at < cutoff) {
|
|
78
|
+
peers.delete(session);
|
|
79
|
+
changed = true;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (peers.size === 0) channels.delete(resource);
|
|
83
|
+
if (changed) broadcast(resource);
|
|
84
|
+
}
|
|
85
|
+
// An empty lane must not tick: the timer exists only while peers do.
|
|
86
|
+
if (channels.size === 0) {
|
|
87
|
+
clearInterval(sweepTimer);
|
|
88
|
+
sweepTimer = null;
|
|
89
|
+
}
|
|
90
|
+
}, SWEEP_INTERVAL_MS);
|
|
91
|
+
if (typeof sweepTimer.unref === 'function') sweepTimer.unref();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Publish one binding's presence on a resource. `blob` is the surface's
|
|
96
|
+
* opaque position state (may be null — present without a position);
|
|
97
|
+
* `active` marks input activity, so chrome can render idle separately from
|
|
98
|
+
* gone. Re-publishing is the heartbeat: it is the only way to stay present.
|
|
99
|
+
*/
|
|
100
|
+
function updatePresence(input) {
|
|
101
|
+
const resource = requireString(input?.resource, 'resource');
|
|
102
|
+
const session = requireString(input?.session, 'session');
|
|
103
|
+
const blob = input?.blob === undefined ? null : input.blob;
|
|
104
|
+
if (blob !== null && JSON.stringify(blob).length > MAX_BLOB_JSON_BYTES) {
|
|
105
|
+
const error = new Error('presence blob exceeds the ephemeral lane size limit');
|
|
106
|
+
error.statusCode = 413;
|
|
107
|
+
throw error;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const now = Date.now();
|
|
111
|
+
let peers = channels.get(resource);
|
|
112
|
+
if (!peers) {
|
|
113
|
+
peers = new Map();
|
|
114
|
+
channels.set(resource, peers);
|
|
115
|
+
}
|
|
116
|
+
const previous = peers.get(session);
|
|
117
|
+
peers.set(session, {
|
|
118
|
+
session,
|
|
119
|
+
user: stampedUser(),
|
|
120
|
+
blob,
|
|
121
|
+
at: now,
|
|
122
|
+
activeAt: input?.active ? now : (previous?.activeAt ?? now),
|
|
123
|
+
});
|
|
124
|
+
ensureSweeper();
|
|
125
|
+
broadcast(resource);
|
|
126
|
+
return { resource, session, peers: peers.size };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Explicit goodbye for one binding on one resource. */
|
|
130
|
+
function leavePresence(input) {
|
|
131
|
+
const resource = requireString(input?.resource, 'resource');
|
|
132
|
+
const session = requireString(input?.session, 'session');
|
|
133
|
+
const peers = channels.get(resource);
|
|
134
|
+
if (!peers || !peers.delete(session)) return { resource, session, peers: peers?.size ?? 0 };
|
|
135
|
+
if (peers.size === 0) channels.delete(resource);
|
|
136
|
+
broadcast(resource);
|
|
137
|
+
return { resource, session, peers: peers?.size ?? 0 };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* A session's connection died: remove it from every resource it was present
|
|
142
|
+
* on. The stream teardown calls this — the "dead connection" layer between
|
|
143
|
+
* the clean goodbye and TTL expiry.
|
|
144
|
+
*/
|
|
145
|
+
function evictSession(session) {
|
|
146
|
+
if (typeof session !== 'string' || !session) return;
|
|
147
|
+
for (const [resource, peers] of Array.from(channels)) {
|
|
148
|
+
if (!peers.delete(session)) continue;
|
|
149
|
+
if (peers.size === 0) channels.delete(resource);
|
|
150
|
+
broadcast(resource);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Current state as broadcast frames — sent to a stream on connect so a late
|
|
156
|
+
* joiner sees existing peers immediately instead of waiting out their next
|
|
157
|
+
* heartbeat. Empty resources produce nothing.
|
|
158
|
+
*/
|
|
159
|
+
function snapshotFrames() {
|
|
160
|
+
return Array.from(channels.keys(), (resource) => ({ resource, peers: peersOf(resource) }));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function subscribePresence(callback) {
|
|
164
|
+
emitter.on('presence', callback);
|
|
165
|
+
return () => emitter.off('presence', callback);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
module.exports = {
|
|
169
|
+
PRESENCE_TTL_MS,
|
|
170
|
+
evictSession,
|
|
171
|
+
leavePresence,
|
|
172
|
+
snapshotFrames,
|
|
173
|
+
subscribePresence,
|
|
174
|
+
updatePresence,
|
|
175
|
+
};
|
|
@@ -314,10 +314,20 @@ function getPromotion(promotionId, options = {}) {
|
|
|
314
314
|
);
|
|
315
315
|
}
|
|
316
316
|
|
|
317
|
+
/** The one durable promotion request for a resource, if any. */
|
|
318
|
+
function getPromotionByResource(resourceId, options = {}) {
|
|
319
|
+
const database = options.database || openLocalDb();
|
|
320
|
+
return normalizeRow(
|
|
321
|
+
cachedStatement(database, 'SELECT * FROM shared_resource_promotions WHERE resource_id = ?')
|
|
322
|
+
.get(resourceId),
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
|
|
317
326
|
module.exports = {
|
|
318
327
|
acknowledgePromotion,
|
|
319
328
|
claimPromotions,
|
|
320
329
|
enqueuePromotion,
|
|
321
330
|
failPromotion,
|
|
322
331
|
getPromotion,
|
|
332
|
+
getPromotionByResource,
|
|
323
333
|
};
|
|
@@ -7,6 +7,7 @@ const {
|
|
|
7
7
|
subscribeStateEvents,
|
|
8
8
|
} = require('./events');
|
|
9
9
|
const appResources = require('./app-resources');
|
|
10
|
+
const presence = require('./presence');
|
|
10
11
|
|
|
11
12
|
function sendHandlerError(error, sendJson) {
|
|
12
13
|
const statusCode = Number(error?.statusCode) || 500;
|
|
@@ -128,6 +129,21 @@ async function handleDocWrite(body, sendJson) {
|
|
|
128
129
|
}
|
|
129
130
|
}
|
|
130
131
|
|
|
132
|
+
// The ephemeral lane's one REST door: publish (the heartbeat) or leave.
|
|
133
|
+
// Identity is stamped server-side in state/presence — the body never
|
|
134
|
+
// carries who you are, only where you are (the opaque surface blob).
|
|
135
|
+
async function handlePresenceUpdate(body, sendJson) {
|
|
136
|
+
try {
|
|
137
|
+
if (body?.leave) {
|
|
138
|
+
sendJson(200, presence.leavePresence(body));
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
sendJson(200, presence.updatePresence(body));
|
|
142
|
+
} catch (error) {
|
|
143
|
+
sendHandlerError(error, sendJson);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
131
147
|
async function handleResourceList(sendJson) {
|
|
132
148
|
try {
|
|
133
149
|
sendJson(200, { resources: appResources.listAppResources() });
|
|
@@ -158,6 +174,13 @@ function handleStream(req, res, query) {
|
|
|
158
174
|
// ignored — the snapshot IS the resume point.
|
|
159
175
|
const bootstrap = String(query.bootstrap || '').trim() === '1';
|
|
160
176
|
const after = bootstrap ? 0 : parsePositiveInt(query.after, 0);
|
|
177
|
+
// The client's binding session, when it has one: ties this connection to
|
|
178
|
+
// the presence the same session publishes over POST /state/presence, so a
|
|
179
|
+
// dead stream takes its presence down with it (the layer between the clean
|
|
180
|
+
// goodbye and TTL expiry). Optional — presence-less clients pass nothing.
|
|
181
|
+
const presenceSession = typeof query.session === 'string' && query.session.trim()
|
|
182
|
+
? query.session.trim()
|
|
183
|
+
: null;
|
|
161
184
|
|
|
162
185
|
res.writeHead(200, {
|
|
163
186
|
'Content-Type': 'text/event-stream; charset=utf-8',
|
|
@@ -195,12 +218,17 @@ function handleStream(req, res, query) {
|
|
|
195
218
|
// on a 'close' that never fires is how dead streams pile up.
|
|
196
219
|
let heartbeat = null;
|
|
197
220
|
let unsubscribe = null;
|
|
221
|
+
let unsubscribePresence = null;
|
|
198
222
|
let tornDown = false;
|
|
199
223
|
const teardown = () => {
|
|
200
224
|
if (tornDown) return;
|
|
201
225
|
tornDown = true;
|
|
202
226
|
if (heartbeat) clearInterval(heartbeat);
|
|
203
227
|
if (unsubscribe) unsubscribe();
|
|
228
|
+
if (unsubscribePresence) unsubscribePresence();
|
|
229
|
+
// The connection was the presence: its death removes this session's
|
|
230
|
+
// entries everywhere, and the broadcast tells every other stream.
|
|
231
|
+
if (presenceSession) presence.evictSession(presenceSession);
|
|
204
232
|
try {
|
|
205
233
|
res.end();
|
|
206
234
|
} catch {
|
|
@@ -265,6 +293,18 @@ function handleStream(req, res, query) {
|
|
|
265
293
|
}
|
|
266
294
|
}
|
|
267
295
|
|
|
296
|
+
// Presence rides the same stream as its own frame type — never through the
|
|
297
|
+
// event log, never with a seq, invisible to replay/backlog. Current state
|
|
298
|
+
// goes out first (a late joiner must not wait out other peers' heartbeats),
|
|
299
|
+
// after the snapshot/backlog so ordered-bootstrap clients have their
|
|
300
|
+
// snapshot before any presence frame arrives.
|
|
301
|
+
for (const frame of presence.snapshotFrames()) {
|
|
302
|
+
writeSafe(() => sendSseControl(res, 'presence', frame));
|
|
303
|
+
}
|
|
304
|
+
unsubscribePresence = presence.subscribePresence((frame) => {
|
|
305
|
+
writeSafe(() => sendSseControl(res, 'presence', frame));
|
|
306
|
+
});
|
|
307
|
+
|
|
268
308
|
// A named event (not an SSE comment) so EventSource clients can observe it
|
|
269
309
|
// and detect a silently dead stream, e.g. tunnel frame drops. It doubles as
|
|
270
310
|
// the server-side liveness probe: writing to a dead socket tears down.
|
|
@@ -288,6 +328,7 @@ module.exports = {
|
|
|
288
328
|
handleDocUpdate,
|
|
289
329
|
handleDocWrite,
|
|
290
330
|
handleEvents,
|
|
331
|
+
handlePresenceUpdate,
|
|
291
332
|
handleResourceEmit,
|
|
292
333
|
handleResourceList,
|
|
293
334
|
handleResourceRegister,
|
|
@@ -12,7 +12,9 @@ const fs = require('fs');
|
|
|
12
12
|
|
|
13
13
|
function sendHandlerError(error, sendJson) {
|
|
14
14
|
const statusCode = Number(error?.statusCode) || 500;
|
|
15
|
-
|
|
15
|
+
const payload = { error: error?.message || 'internal error' };
|
|
16
|
+
if (typeof error?.code === 'string' && error.code) payload.code = error.code;
|
|
17
|
+
sendJson(statusCode, payload);
|
|
16
18
|
}
|
|
17
19
|
|
|
18
20
|
function parsePositiveInt(value, fallback) {
|
|
@@ -71,6 +73,23 @@ async function handleReplicaRehydrate(body, sendJson) {
|
|
|
71
73
|
|
|
72
74
|
async function handlePromotionStart(body, sendJson) {
|
|
73
75
|
try {
|
|
76
|
+
// One owner per file (workspace-sync.md §1). A file tracked by a
|
|
77
|
+
// repository is git-owned: it syncs through commits on the workspace
|
|
78
|
+
// rail, and it may join the doc rail only via a future explicit
|
|
79
|
+
// ownership handoff — never by ambient promotion.
|
|
80
|
+
if (typeof body?.path === 'string' && body.path) {
|
|
81
|
+
const owner = await require('../workspace/git').gitOwnerOf(body.path);
|
|
82
|
+
if (owner) {
|
|
83
|
+
throw Object.assign(
|
|
84
|
+
new Error(
|
|
85
|
+
`${body.path} is git-owned: tracked by the repository at ${owner.toplevel}. `
|
|
86
|
+
+ 'Repository files sync through git (commits are the sync points), not the doc rail. '
|
|
87
|
+
+ 'If this file truly belongs on the rail, untrack it first (git rm --cached <file>).',
|
|
88
|
+
),
|
|
89
|
+
{ statusCode: 409, code: 'file_git_owned' },
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
74
93
|
const opened = require('./docs').openDoc(body?.path);
|
|
75
94
|
const result = require('./promotions').enqueuePromotion({
|
|
76
95
|
promotionId: body?.promotionId || `prm_${crypto.randomBytes(16).toString('base64url')}`,
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const test = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const os = require('os');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const { EventEmitter } = require('events');
|
|
9
|
+
|
|
10
|
+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-presence-test-'));
|
|
11
|
+
process.env.AMALGM_DIR = path.join(tempRoot, '.amalgm');
|
|
12
|
+
|
|
13
|
+
const { closeLocalDb } = require('../state/db');
|
|
14
|
+
const { listEventsAfter } = require('../state/events');
|
|
15
|
+
const presence = require('../state/presence');
|
|
16
|
+
const rest = require('../state/rest');
|
|
17
|
+
|
|
18
|
+
test.after(() => {
|
|
19
|
+
closeLocalDb();
|
|
20
|
+
fs.rmSync(tempRoot, { recursive: true, force: true });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
function fakeRequest() {
|
|
24
|
+
return new EventEmitter();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function fakeResponse() {
|
|
28
|
+
const res = new EventEmitter();
|
|
29
|
+
res.writes = [];
|
|
30
|
+
res.destroyed = false;
|
|
31
|
+
res.writableEnded = false;
|
|
32
|
+
res.writeHead = () => res;
|
|
33
|
+
res.write = (chunk) => {
|
|
34
|
+
res.writes.push(String(chunk));
|
|
35
|
+
return true;
|
|
36
|
+
};
|
|
37
|
+
res.end = () => {
|
|
38
|
+
res.writableEnded = true;
|
|
39
|
+
};
|
|
40
|
+
return res;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function presenceFrames(res) {
|
|
44
|
+
return res.writes
|
|
45
|
+
.map((write, index) => (write === 'event: presence\n' ? JSON.parse(res.writes[index + 1].replace(/^data: /, '')) : null))
|
|
46
|
+
.filter(Boolean);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
test('publish → broadcast; leave → removal broadcast; frames replace wholesale', () => {
|
|
50
|
+
const frames = [];
|
|
51
|
+
const unsubscribe = presence.subscribePresence((frame) => frames.push(frame));
|
|
52
|
+
|
|
53
|
+
presence.updatePresence({ resource: 'doc:t1', session: 'ps_a', blob: { kind: 'text-caret@1', anchor: 1, head: 4 } });
|
|
54
|
+
presence.updatePresence({ resource: 'doc:t1', session: 'ps_b', blob: null });
|
|
55
|
+
assert.equal(frames.length, 2);
|
|
56
|
+
assert.equal(frames[1].peers.length, 2, 'second frame carries the full list');
|
|
57
|
+
|
|
58
|
+
presence.leavePresence({ resource: 'doc:t1', session: 'ps_a' });
|
|
59
|
+
const last = frames[frames.length - 1];
|
|
60
|
+
assert.deepEqual(last.peers.map((peer) => peer.session), ['ps_b'], 'leave broadcasts the survivor list');
|
|
61
|
+
|
|
62
|
+
presence.leavePresence({ resource: 'doc:t1', session: 'ps_b' });
|
|
63
|
+
assert.equal(frames[frames.length - 1].peers.length, 0, 'last leave broadcasts an empty list');
|
|
64
|
+
unsubscribe();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('the lane never touches the durable event log', () => {
|
|
68
|
+
const before = listEventsAfter(0).length;
|
|
69
|
+
presence.updatePresence({ resource: 'doc:t2', session: 'ps_x', blob: { p: 1 } });
|
|
70
|
+
presence.leavePresence({ resource: 'doc:t2', session: 'ps_x' });
|
|
71
|
+
assert.equal(listEventsAfter(0).length, before, 'presence must not journal');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('identity cannot ride in the payload: user is stamped server-side', () => {
|
|
75
|
+
const frames = [];
|
|
76
|
+
const unsubscribe = presence.subscribePresence((frame) => frames.push(frame));
|
|
77
|
+
presence.updatePresence({ resource: 'doc:t3', session: 'ps_i', user: 'liar', blob: null });
|
|
78
|
+
const peer = frames[0].peers[0];
|
|
79
|
+
assert.notEqual(peer.user, 'liar', 'client-supplied user must be ignored');
|
|
80
|
+
presence.leavePresence({ resource: 'doc:t3', session: 'ps_i' });
|
|
81
|
+
unsubscribe();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('oversized blobs are refused — the lane carries positions, not data', () => {
|
|
85
|
+
assert.throws(
|
|
86
|
+
() => presence.updatePresence({ resource: 'doc:t4', session: 'ps_big', blob: { pad: 'x'.repeat(9000) } }),
|
|
87
|
+
/size limit/,
|
|
88
|
+
);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('a stream gets current presence on connect, live frames after, and evicts its session on close', () => {
|
|
92
|
+
presence.updatePresence({ resource: 'doc:t5', session: 'ps_existing', blob: { at: 1 } });
|
|
93
|
+
|
|
94
|
+
const req = fakeRequest();
|
|
95
|
+
const res = fakeResponse();
|
|
96
|
+
rest.handleStream(req, res, { session: 'ps_stream' });
|
|
97
|
+
|
|
98
|
+
const catchUp = presenceFrames(res);
|
|
99
|
+
assert.ok(
|
|
100
|
+
catchUp.some((frame) => frame.resource === 'doc:t5'),
|
|
101
|
+
'connect must deliver existing presence without waiting for a heartbeat',
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
presence.updatePresence({ resource: 'doc:t5', session: 'ps_stream', blob: { at: 2 } });
|
|
105
|
+
const live = presenceFrames(res);
|
|
106
|
+
assert.equal(live[live.length - 1].peers.length, 2, 'live broadcasts reach the open stream');
|
|
107
|
+
|
|
108
|
+
// The connection dies → its session's presence goes with it, and the
|
|
109
|
+
// other streams hear about it (our own subscriber here is torn down).
|
|
110
|
+
const frames = [];
|
|
111
|
+
const unsubscribe = presence.subscribePresence((frame) => frames.push(frame));
|
|
112
|
+
req.emit('close');
|
|
113
|
+
const after = frames.find((frame) => frame.resource === 'doc:t5');
|
|
114
|
+
assert.ok(after, 'teardown broadcasts the eviction');
|
|
115
|
+
assert.deepEqual(after.peers.map((peer) => peer.session), ['ps_existing'], 'only the dead session is evicted');
|
|
116
|
+
|
|
117
|
+
presence.leavePresence({ resource: 'doc:t5', session: 'ps_existing' });
|
|
118
|
+
unsubscribe();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('presence frames are absent from replay surfaces', () => {
|
|
122
|
+
presence.updatePresence({ resource: 'doc:t6', session: 'ps_r', blob: null });
|
|
123
|
+
const req = fakeRequest();
|
|
124
|
+
const res = fakeResponse();
|
|
125
|
+
rest.handleStream(req, res, {});
|
|
126
|
+
const stateFrames = res.writes.filter((write) => write.startsWith('event: state\n'));
|
|
127
|
+
assert.equal(stateFrames.length, 0, 'no presence rides the durable frame type');
|
|
128
|
+
req.emit('close');
|
|
129
|
+
presence.leavePresence({ resource: 'doc:t6', session: 'ps_r' });
|
|
130
|
+
});
|