@volter/supercode-teams 0.3.159 → 0.3.161
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/connector/index.mjs +2 -2
- package/live-sessions.mjs +12 -3
- package/machine-records.mjs +113 -45
- package/machine.mjs +75 -63
- package/mail/carrier.mjs +202 -157
- package/mail/change-stream.mjs +143 -0
- package/mail/delivery-status.mjs +42 -0
- package/mail/hooks.mjs +90 -15
- package/mail/reads.mjs +2 -1
- package/mail/relay-routing.mjs +41 -0
- package/mail/relay.mjs +120 -204
- package/mail/send.mjs +56 -32
- package/mail/sqlite-store.mjs +166 -16
- package/mail/sqlite.mjs +42 -4
- package/package.json +1 -1
- package/record-state.mjs +143 -0
package/connector/index.mjs
CHANGED
|
@@ -1117,10 +1117,10 @@ export class TeamsConnector {
|
|
|
1117
1117
|
let started=null;
|
|
1118
1118
|
try{
|
|
1119
1119
|
const seen=this.origins[key]?.capture_stamps?.[options.ruleId];
|
|
1120
|
-
//
|
|
1120
|
+
// An unchanged stamp cannot satisfy a held full capture or a native bundle ready to be taken.
|
|
1121
1121
|
const artifact=(()=>{try{return statSync(realpathSync(options.locator.storage.path));}catch{return null;}})();
|
|
1122
1122
|
const nativeOwed=Boolean(this.origins[key]?.native_deferred&&artifact&&this.nativeReady(key,artifact));
|
|
1123
|
-
if(options.live&&!nativeOwed&&seen?.rule_revision===options.ruleRevision&&seen.stamp===this.sessionStamp(options))continue;
|
|
1123
|
+
if(options.live&&!nativeOwed&&!this.redactionHolds.needed(key)&&seen?.rule_revision===options.ruleRevision&&seen.stamp===this.sessionStamp(options))continue;
|
|
1124
1124
|
const artifactBefore=statSync(realpathSync(options.locator.storage.path));
|
|
1125
1125
|
// A session is loaded again no sooner than ten times its last load took (a 0.5 s load: every 5 s; a load
|
|
1126
1126
|
// that takes minutes: every LARGE_CAPTURE_EVERY_MS), so a live session streams while the lanes stay light.
|
package/live-sessions.mjs
CHANGED
|
@@ -478,11 +478,20 @@ export class LiveSessions extends EventEmitter {
|
|
|
478
478
|
return this.paneRoots.find((root) => root.pid === entry.pid)?.pane ?? entry.tmux ?? null;
|
|
479
479
|
}
|
|
480
480
|
|
|
481
|
+
/** Delivery never uses the registry's tmux label: a headless process can
|
|
482
|
+
* inherit another session's label. Only this daemon's pane/process reading
|
|
483
|
+
* binds input to the addressed native conversation. */
|
|
484
|
+
#claudeDeliveryPane(entry) {
|
|
485
|
+
return this.paneRoots.find((root) => root.activity?.harness === 'claude-code'
|
|
486
|
+
&& root.activity.session_id === entry.sessionId)?.pane ?? null;
|
|
487
|
+
}
|
|
488
|
+
|
|
481
489
|
/** The daemon's live panes and their root processes (`[{ pane, pid }]`), as the records read them: the panes of the
|
|
482
490
|
* sessions whose own records name none (Grok's; Claude's on Windows). */
|
|
483
|
-
panes(roots) {
|
|
484
|
-
const placed = () => JSON.stringify([...[...this.grok].map(([id, entry]) => [id, entry.pane]), ...[...this.claude.values()].map((entry) => [entry.sessionId, this.#claudePane(entry)])]);
|
|
491
|
+
panes(roots, { current = true } = {}) {
|
|
492
|
+
const placed = () => JSON.stringify([this.panesCurrent === true, ...[...this.grok].map(([id, entry]) => [id, entry.pane]), ...[...this.claude.values()].map((entry) => [entry.sessionId, this.#claudePane(entry), this.#claudeDeliveryPane(entry)])]);
|
|
485
493
|
const before = placed();
|
|
494
|
+
this.panesCurrent = current;
|
|
486
495
|
this.paneRoots = (roots ?? []).filter((root) => root?.pane && Number.isInteger(root.pid));
|
|
487
496
|
for (const entry of this.grok.values()) this.#grokPane(entry);
|
|
488
497
|
if (placed() !== before) this.#changed();
|
|
@@ -656,7 +665,7 @@ export class LiveSessions extends EventEmitter {
|
|
|
656
665
|
if (entry.name.startsWith(RELAY_PREFIX) || !alive(entry.pid)) continue;
|
|
657
666
|
const address = this.#address('claude-code', entry.sessionId);
|
|
658
667
|
if (seen.has(address)) continue; seen.add(address);
|
|
659
|
-
out.push({ address, name: `${entry.name}@${this.machine}`, status: entry.status, door: 'native', pid: entry.pid, cwd: entry.cwd, pane: this.#claudePane(entry), tools: this.hasTools(entry.pid), socket: entry.socket ?? null });
|
|
668
|
+
out.push({ address, name: `${entry.name}@${this.machine}`, status: entry.status, door: 'native', pid: entry.pid, cwd: entry.cwd, pane: this.#claudePane(entry), delivery_pane: this.#claudeDeliveryPane(entry), panes_current: this.panesCurrent === true, tools: this.hasTools(entry.pid), socket: entry.socket ?? null });
|
|
660
669
|
}
|
|
661
670
|
const codex = new Set([...this.codexRunning.keys(), ...this.codexPaneOf.keys()]);
|
|
662
671
|
for (const sessionId of codex) {
|
package/machine-records.mjs
CHANGED
|
@@ -5,8 +5,7 @@
|
|
|
5
5
|
// its mail folder's file events); a subscriber gets a snapshot and then every change, and holds the latest of each
|
|
6
6
|
// machine it follows. A held record says how current it is: its producer's time, its stream's state and why it is not
|
|
7
7
|
// open. A machine with no record is said as unread, with why; nothing reads a stale or missing record as absent.
|
|
8
|
-
import {
|
|
9
|
-
import { dirname } from 'node:path';
|
|
8
|
+
import { RecordState } from './record-state.mjs';
|
|
10
9
|
|
|
11
10
|
/** How many of each mailbox's newest messages a record carries beside its unread mail: enough for `show` of what a
|
|
12
11
|
* session was just sent or just read, small enough that a busy machine's record stays a few hundred kilobytes. */
|
|
@@ -31,20 +30,25 @@ const TOO_OLD_RETRY_MS = 60_000;
|
|
|
31
30
|
* (a mailbox's address). `touch(section, detail)` is an event; changed sections reach every subscriber as one delta.
|
|
32
31
|
*/
|
|
33
32
|
export class RecordProducer {
|
|
34
|
-
constructor({ machine, readers, log = () => {} }) {
|
|
33
|
+
constructor({ machine, readers, store, log = () => {} }) {
|
|
35
34
|
this.machine = machine; this.readers = readers; this.log = log;
|
|
36
|
-
this.
|
|
37
|
-
this.
|
|
35
|
+
this.state = new RecordState(store, 'producer'); this.changes = store.changes;
|
|
36
|
+
this.record = this.state.load(machine)?.record ?? { machine, seq: 0, at: null, sessions: null, panes: null, capabilities: null, mail: {} };
|
|
37
|
+
this.unread = { ...this.record.unread };
|
|
38
|
+
if (this.record.seq) for (const section of SECTIONS) this.unread[section] = 'held from the previous daemon; awaiting this source\'s current snapshot';
|
|
39
|
+
this.record.unread = { ...this.unread };
|
|
38
40
|
this.subscribers = new Map(); this.due = new Map(); this.timer = null; this.reading = null; this.lastRead = 0; // write → its granted sections (null: all)
|
|
39
41
|
this.retries = new Map(); // section → { timer, backoff }: a failed read holds its retry until a read passes
|
|
40
42
|
}
|
|
41
43
|
/** Every section read once (the record's first snapshot), and the stream's heartbeat started. */
|
|
42
44
|
start() {
|
|
43
45
|
for (const section of SECTIONS) this.touch(section);
|
|
44
|
-
this.beat = setInterval(() => {
|
|
46
|
+
this.beat = setInterval(() => {
|
|
47
|
+
for (const subscriber of this.subscribers.values()) if (subscriber.expected === this.record.seq) subscriber.send({ type: 'beat', machine: this.machine, seq: subscriber.expected, cursor: subscriber.cursor, at: Date.now() });
|
|
48
|
+
}, BEAT_MS);
|
|
45
49
|
this.beat.unref?.();
|
|
46
50
|
}
|
|
47
|
-
stop() { this.stopped = true; clearInterval(this.beat); clearTimeout(this.timer); for (const { timer } of this.retries.values()) clearTimeout(timer); this.retries.clear(); this.subscribers.
|
|
51
|
+
stop() { this.stopped = true; clearInterval(this.beat); clearTimeout(this.timer); for (const { timer } of this.retries.values()) clearTimeout(timer); this.retries.clear(); for (const subscriber of this.subscribers.values()) subscriber.end(); }
|
|
48
52
|
/** A section's read failed: it is read again after a backoff (2 s doubling to 5 min) until a read passes. */
|
|
49
53
|
retry(section, detail) {
|
|
50
54
|
if (this.stopped) return;
|
|
@@ -82,8 +86,10 @@ export class RecordProducer {
|
|
|
82
86
|
// a read in progress takes what is due when it ends (its finally schedules the next settle)
|
|
83
87
|
if (this.reading) return;
|
|
84
88
|
const due = this.due; this.due = new Map();
|
|
89
|
+
let commitFailed = false;
|
|
85
90
|
this.reading = (async () => {
|
|
86
91
|
const changed = {};
|
|
92
|
+
const record = { ...this.record };
|
|
87
93
|
if (this.noted) { this.noted = false; changed.unread = { ...this.unread }; }
|
|
88
94
|
for (const [section, details] of due) {
|
|
89
95
|
const reader = this.readers[section];
|
|
@@ -91,15 +97,17 @@ export class RecordProducer {
|
|
|
91
97
|
try {
|
|
92
98
|
if (section === 'mail' && !details.has('*')) {
|
|
93
99
|
const boxes = await reader([...details]);
|
|
94
|
-
|
|
100
|
+
if (this.stopped) return;
|
|
101
|
+
const mail = { ...record.mail };
|
|
95
102
|
for (const address of details) { if (boxes[address]) mail[address] = boxes[address]; else delete mail[address]; }
|
|
96
|
-
if (JSON.stringify(mail) !== JSON.stringify(
|
|
103
|
+
if (JSON.stringify(mail) !== JSON.stringify(record.mail)) { record.mail = mail; changed.mail = Object.fromEntries([...details].map((address) => [address, mail[address] ?? null])); }
|
|
97
104
|
} else {
|
|
98
105
|
const value = await reader(null);
|
|
99
|
-
if (
|
|
106
|
+
if (this.stopped) return;
|
|
107
|
+
if (JSON.stringify(value) !== JSON.stringify(record[section])) {
|
|
100
108
|
// the sessions go as rows changed and gone (by address), not the whole list each turn
|
|
101
|
-
changed[section] = section === 'mail' ? { '*': value } : section === 'sessions' && Array.isArray(
|
|
102
|
-
|
|
109
|
+
changed[section] = section === 'mail' ? { '*': value } : section === 'sessions' && Array.isArray(record.sessions) && Array.isArray(value) ? rowDelta(record.sessions, value) : value;
|
|
110
|
+
record[section] = value;
|
|
103
111
|
}
|
|
104
112
|
}
|
|
105
113
|
if (this.unread[section]) { delete this.unread[section]; changed.unread = { ...this.unread }; }
|
|
@@ -111,21 +119,63 @@ export class RecordProducer {
|
|
|
111
119
|
this.retry(section, details.has('*') ? null : [...details]);
|
|
112
120
|
}
|
|
113
121
|
}
|
|
114
|
-
if (!Object.keys(changed).length) return;
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
122
|
+
if (this.stopped || !Object.keys(changed).length) return;
|
|
123
|
+
record.seq += 1; record.at = Date.now(); record.unread = { ...this.unread };
|
|
124
|
+
record.cursor = this.state.save(record, changed, { publish: true });
|
|
125
|
+
this.record = record;
|
|
118
126
|
})();
|
|
119
127
|
// reads are spaced from the end of the last one: a read longer than the interval is never followed at once
|
|
120
|
-
try { await this.reading; }
|
|
128
|
+
try { await this.reading; }
|
|
129
|
+
catch (error) {
|
|
130
|
+
if (!this.stopped) {
|
|
131
|
+
commitFailed = true;
|
|
132
|
+
this.log(`machine record: its changed rows could not be committed: ${error.message}`);
|
|
133
|
+
for (const [section, details] of due) this.retry(section, details.has('*') ? null : [...details]);
|
|
134
|
+
if (!due.size) this.retry('mail', null);
|
|
135
|
+
this.noted = true;
|
|
136
|
+
}
|
|
137
|
+
} finally { this.reading = null; this.lastRead = Date.now(); if (!commitFailed && (this.due.size || this.noted)) this.schedule(); }
|
|
121
138
|
}
|
|
122
139
|
/** A subscriber: its snapshot now, then each change. Answers its unsubscribe. */
|
|
123
140
|
/** A subscriber: its snapshot now, then each change, with only the sections `allowed` (null: all; a section not
|
|
124
141
|
* granted is left out, said in its `unread`). Answers its unsubscribe. */
|
|
125
|
-
subscribe(write, allowed = null) {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
142
|
+
subscribe(write, allowed = null, { after = null, sequence = null, sections = null } = {}) {
|
|
143
|
+
const scope = allowed ? [...allowed].sort() : null;
|
|
144
|
+
const subscriber = { cursor: after, expected: sequence, closed: false };
|
|
145
|
+
subscriber.end = () => { subscriber.closed = true; subscriber.stop?.(); this.subscribers.delete(write); };
|
|
146
|
+
subscriber.send = (frame) => {
|
|
147
|
+
if (subscriber.closed) return;
|
|
148
|
+
try { write(JSON.stringify(frame) + '\n'); } catch { subscriber.end(); }
|
|
149
|
+
};
|
|
150
|
+
this.subscribers.set(write, subscriber);
|
|
151
|
+
const resumable = Number.isSafeInteger(sequence) && sequence >= 0 && JSON.stringify(scope) === JSON.stringify(sections);
|
|
152
|
+
subscriber.stop = this.changes.follow((page) => {
|
|
153
|
+
if (subscriber.closed || this.stopped) return;
|
|
154
|
+
if (page.error) { subscriber.end(); return; }
|
|
155
|
+
const snapshot = () => {
|
|
156
|
+
const cursor = this.changes.cursor();
|
|
157
|
+
subscriber.resumed = true;
|
|
158
|
+
subscriber.expected = this.record.seq; subscriber.cursor = cursor;
|
|
159
|
+
subscriber.send({ type: 'snapshot', record: { ...grantedRecord(this.record, allowed), cursor, sections_granted: scope } });
|
|
160
|
+
return cursor;
|
|
161
|
+
};
|
|
162
|
+
if (page.reset || subscriber.expected == null) return snapshot();
|
|
163
|
+
if (!subscriber.resumed) {
|
|
164
|
+
subscriber.resumed = true;
|
|
165
|
+
subscriber.send({ type: 'resume', machine: this.machine, seq: subscriber.expected, cursor: after, sections_granted: scope });
|
|
166
|
+
}
|
|
167
|
+
for (const change of page.changes) {
|
|
168
|
+
if (change.key.machine !== this.machine) continue;
|
|
169
|
+
if (change.value?.seq !== subscriber.expected + 1) return snapshot();
|
|
170
|
+
subscriber.expected = change.value.seq;
|
|
171
|
+
subscriber.cursor = { id: page.cursor.id, sequence: change.sequence };
|
|
172
|
+
subscriber.send({ type: 'delta', machine: this.machine, seq: subscriber.expected, cursor: subscriber.cursor, at: change.value.at, sections: granted(change.value.sections, allowed) });
|
|
173
|
+
}
|
|
174
|
+
if (!page.more && subscriber.expected !== this.record.seq) return snapshot();
|
|
175
|
+
subscriber.cursor = page.cursor;
|
|
176
|
+
subscriber.send({ type: 'cursor', machine: this.machine, seq: subscriber.expected, cursor: page.cursor, caught_up: !page.more });
|
|
177
|
+
}, { after: resumable ? after : null, topics: ['machine-record'], initial: true });
|
|
178
|
+
return subscriber.end;
|
|
129
179
|
}
|
|
130
180
|
}
|
|
131
181
|
|
|
@@ -179,28 +229,24 @@ function notGranted(error) {
|
|
|
179
229
|
* said so, its last record kept as stale.
|
|
180
230
|
*/
|
|
181
231
|
export class RecordHolder {
|
|
182
|
-
constructor({ self, open, file, log = () => {} }) {
|
|
232
|
+
constructor({ self, open, file, store, log = () => {} }) {
|
|
183
233
|
this.self = self; this.open = open; this.file = file; this.log = log;
|
|
234
|
+
this.state = new RecordState(store, 'holder');
|
|
184
235
|
this.held = new Map(); // machine → { record, state: open|opening|closed|too-old|unlinked, since, why, received_at, stream, retry, backoff }
|
|
185
236
|
this.load();
|
|
186
237
|
}
|
|
187
238
|
load() {
|
|
188
|
-
|
|
189
|
-
try { saved = JSON.parse(readFileSync(this.file, 'utf8')).machines ?? {}; } catch { /* none kept yet */ }
|
|
239
|
+
this.state.importLegacy(this.file);
|
|
190
240
|
// a record kept from before this daemon started is stale until its machine's stream gives it again
|
|
191
|
-
for (const [machine, kept] of
|
|
241
|
+
for (const [machine, kept] of this.state.all()) this.held.set(machine, { record: kept.record, state: 'closed', since: kept.meta?.received_at ?? null, why: 'held from before this daemon started; not read since', received_at: kept.meta?.received_at ?? null });
|
|
192
242
|
}
|
|
193
|
-
save() {
|
|
194
|
-
if (this.
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
const temp = `${this.file}.${process.pid}.tmp`;
|
|
201
|
-
writeFileSync(temp, JSON.stringify({ version: 1, machines }) + '\n', { mode: 0o600 }); renameSync(temp, this.file);
|
|
202
|
-
} catch (error) { this.log(`machine records could not be kept: ${error.message}`); }
|
|
203
|
-
}, 500).unref?.();
|
|
243
|
+
save(machine, changed = {}) {
|
|
244
|
+
if (this.closed) return;
|
|
245
|
+
const held = this.held.get(machine);
|
|
246
|
+
const meta = { received_at: held?.received_at ?? null, stream: held?.state ?? 'closed', since: held?.since ?? null, why: held?.why ?? null };
|
|
247
|
+
if (held?.record) this.state.save(held.record, changed, { meta });
|
|
248
|
+
else if (held) this.state.saveEmpty(machine, meta);
|
|
249
|
+
else this.state.remove(machine);
|
|
204
250
|
}
|
|
205
251
|
/** The linked machines of this machine's team, from the presence stream (its own events): followed or let go. */
|
|
206
252
|
follow(items) {
|
|
@@ -208,9 +254,7 @@ export class RecordHolder {
|
|
|
208
254
|
const linked = new Set((items ?? []).filter((item) => item.linked).map((item) => normal(item.name)).filter((name) => name && name !== this.self));
|
|
209
255
|
// a machine no longer in the team is forgotten (its record too); one in it but not linked is offline: unlinked,
|
|
210
256
|
// its last record kept as offline (readers leave it out, as they leave out any machine that is not online)
|
|
211
|
-
let
|
|
212
|
-
for (const machine of [...this.held.keys()]) if (!team.has(machine)) { this.let(machine, 'no longer in the team'); this.held.delete(machine); forgot = true; }
|
|
213
|
-
if (forgot) this.save();
|
|
257
|
+
for (const machine of [...this.held.keys()]) if (!team.has(machine)) { this.let(machine, 'no longer in the team'); this.held.delete(machine); this.state.remove(machine); }
|
|
214
258
|
for (const [machine, held] of this.held) if (!linked.has(machine) && held.state !== 'unlinked') this.let(machine, 'offline: not linked to the team\'s server');
|
|
215
259
|
for (const machine of linked) { const held = this.held.get(machine); if (!held || held.state === 'unlinked' || ((held.state === 'closed' || held.state === 'too-old') && !held.retry)) this.subscribe(machine); }
|
|
216
260
|
}
|
|
@@ -219,12 +263,14 @@ export class RecordHolder {
|
|
|
219
263
|
clearTimeout(held.retry); held.retry = null; clearTimeout(held.quiet); held.attempt = null;
|
|
220
264
|
try { held.stream?.close?.(); held.stream?.destroy?.(); } catch { /* closed */ }
|
|
221
265
|
Object.assign(held, { stream: null, state: 'unlinked', since: Date.now(), why });
|
|
266
|
+
this.save(machine);
|
|
222
267
|
}
|
|
223
268
|
subscribe(machine) {
|
|
224
269
|
const held = this.held.get(machine) ?? { record: null, received_at: null, backoff: 0 };
|
|
225
270
|
this.held.set(machine, held);
|
|
226
271
|
clearTimeout(held.retry); held.retry = null; clearTimeout(held.quiet);
|
|
227
272
|
Object.assign(held, { state: 'opening', why: null });
|
|
273
|
+
this.save(machine);
|
|
228
274
|
let buffer = '';
|
|
229
275
|
const attempt = {}; held.attempt = attempt;
|
|
230
276
|
const closed = (why, state = 'closed') => {
|
|
@@ -234,6 +280,7 @@ export class RecordHolder {
|
|
|
234
280
|
const stream = held.stream;
|
|
235
281
|
held.attempt = null;
|
|
236
282
|
Object.assign(held, { stream: null, state, since: Date.now(), why: String(why ?? 'the stream closed').slice(0, 300) });
|
|
283
|
+
this.save(machine);
|
|
237
284
|
try { stream?.close?.(); } catch { /* closing */ }
|
|
238
285
|
// opened again after a backoff, 2 s doubling to 5 min: an obligation of this machine, never a sweep. A daemon too
|
|
239
286
|
// old to serve records is asked again every TOO_OLD_RETRY_MS: only its update answers it (asked every 8-16 s, it
|
|
@@ -251,6 +298,7 @@ export class RecordHolder {
|
|
|
251
298
|
// the opener attaches these at once, before the stream can deliver anything: the snapshot and each delta after it
|
|
252
299
|
// are applied in order, none lost to a listener attached late
|
|
253
300
|
const sink = {
|
|
301
|
+
resume: held.record?.cursor ? { after: held.record.cursor, sequence: held.record.seq, sections: held.record.sections_granted ?? null } : {},
|
|
254
302
|
data: (chunk) => {
|
|
255
303
|
if (held.attempt !== attempt) return;
|
|
256
304
|
heard();
|
|
@@ -260,7 +308,9 @@ export class RecordHolder {
|
|
|
260
308
|
const line = buffer.slice(0, at); buffer = buffer.slice(at + 1);
|
|
261
309
|
if (!line) continue;
|
|
262
310
|
let frame; try { frame = JSON.parse(line); } catch { continue; }
|
|
263
|
-
|
|
311
|
+
let gap;
|
|
312
|
+
try { gap = this.apply(machine, held, frame); }
|
|
313
|
+
catch (error) { closed(`its record rows could not be applied: ${error.message}`); return; }
|
|
264
314
|
if (gap) { closed(gap); return; }
|
|
265
315
|
}
|
|
266
316
|
},
|
|
@@ -273,7 +323,7 @@ export class RecordHolder {
|
|
|
273
323
|
}, (error) => {
|
|
274
324
|
// refused for want of any grant (the machine's or its records door's): what was read under the grant is not
|
|
275
325
|
// kept, here or in the saved file; a grant given again reads it afresh
|
|
276
|
-
if (held.attempt === attempt && notGranted(error) && held.record) { held.record = null; held.received_at = null; this.save(); }
|
|
326
|
+
if (held.attempt === attempt && notGranted(error) && held.record) { held.record = null; held.received_at = null; this.save(machine); }
|
|
277
327
|
// a daemon from before machine records (supercode v0.5.200) has no such door: too old to read, not a stream
|
|
278
328
|
// that closed; asked again on the same backoff, so an update is read once it runs
|
|
279
329
|
const message = String(error?.message ?? error);
|
|
@@ -282,19 +332,35 @@ export class RecordHolder {
|
|
|
282
332
|
});
|
|
283
333
|
}
|
|
284
334
|
apply(machine, held, frame) {
|
|
335
|
+
let changed = {};
|
|
285
336
|
if (frame.type === 'snapshot' && frame.record) {
|
|
337
|
+
if (frame.record.machine !== machine) return `the snapshot names ${frame.record.machine}, not ${machine}`;
|
|
286
338
|
held.record = frame.record;
|
|
339
|
+
changed = { sessions: frame.record.sessions, mail: { '*': frame.record.mail ?? {} }, panes: frame.record.panes, capabilities: frame.record.capabilities, unread: frame.record.unread ?? {} };
|
|
340
|
+
} else if (frame.type === 'resume') {
|
|
341
|
+
if (!held.record || frame.seq !== held.record.seq || frame.cursor?.id !== held.record.cursor?.id || JSON.stringify(frame.sections_granted) !== JSON.stringify(held.record.sections_granted)) return 'the resumed record does not match the held snapshot or its grants';
|
|
342
|
+
Object.assign(held, { state: 'catching-up', why: 'resuming its retained row changes' });
|
|
343
|
+
this.save(machine);
|
|
344
|
+
return null;
|
|
345
|
+
} else if (frame.type === 'cursor') {
|
|
346
|
+
if (!held.record || frame.seq !== held.record.seq || frame.cursor?.id !== held.record.cursor?.id) return 'the record cursor does not match its applied sequence';
|
|
347
|
+
held.record.cursor = frame.cursor;
|
|
348
|
+
Object.assign(held, { state: frame.caught_up ? 'open' : 'catching-up', why: frame.caught_up ? null : 'resuming its retained row changes', received_at: Date.now(), backoff: 0 });
|
|
349
|
+
this.save(machine);
|
|
350
|
+
return null;
|
|
287
351
|
} else if (frame.type === 'beat') {
|
|
288
352
|
// a beat at the held sequence keeps the record fresh; one past it is a delta this stream lost: read anew
|
|
289
353
|
if (held.state !== 'open' || frame.seq !== held.record?.seq) return `a beat at sequence ${frame.seq} does not match the held record (${held.state === 'open' ? held.record?.seq : 'no snapshot yet'}): read again from a snapshot`;
|
|
290
354
|
// a beat changes nothing kept: only how recently the machine answered
|
|
291
355
|
held.received_at = Date.now(); held.backoff = 0;
|
|
356
|
+
if (frame.cursor?.id === held.record.cursor?.id) { held.record.cursor = frame.cursor; this.save(machine); }
|
|
292
357
|
return null;
|
|
293
358
|
} else if (frame.type === 'delta') {
|
|
294
359
|
// a delta that does not follow the held sequence (or comes before this stream's snapshot) is a gap: the stream is
|
|
295
360
|
// read again from a snapshot
|
|
296
|
-
if (held.state
|
|
297
|
-
const record = { ...held.record, seq: frame.seq, at: frame.at };
|
|
361
|
+
if (!['open', 'catching-up'].includes(held.state) || frame.seq !== held.record?.seq + 1) return `a delta at sequence ${frame.seq} does not follow the held record (${held.record?.seq ?? 'no snapshot yet'}): read again from a snapshot`;
|
|
362
|
+
const record = { ...held.record, seq: frame.seq, at: frame.at, ...(frame.cursor ? { cursor: frame.cursor } : {}) };
|
|
363
|
+
if (frame.cursor && held.record.cursor?.id !== frame.cursor.id) return 'the delta names a different record database';
|
|
298
364
|
for (const [section, value] of Object.entries(frame.sections ?? {})) {
|
|
299
365
|
if (section === 'unread') record.unread = value;
|
|
300
366
|
else if (section === 'sessions' && value?.delta) {
|
|
@@ -310,9 +376,11 @@ export class RecordHolder {
|
|
|
310
376
|
} else record[section] = value;
|
|
311
377
|
}
|
|
312
378
|
held.record = record;
|
|
379
|
+
changed = frame.sections ?? {};
|
|
313
380
|
} else return null;
|
|
314
|
-
|
|
315
|
-
|
|
381
|
+
const catchingUp = held.state === 'catching-up' && frame.type !== 'snapshot';
|
|
382
|
+
Object.assign(held, { state: catchingUp ? 'catching-up' : 'open', since: held.state === 'open' ? held.since : Date.now(), why: catchingUp ? 'resuming its retained row changes' : null, received_at: Date.now(), backoff: 0, saidTooOld: false });
|
|
383
|
+
this.save(machine, changed);
|
|
316
384
|
return null;
|
|
317
385
|
}
|
|
318
386
|
/** Every followed machine's latest record and how current it is. */
|