termdock 1.4.383 → 1.4.384

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.
@@ -0,0 +1,273 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { createHash, randomUUID } from 'node:crypto';
4
+ import { CollaborationError } from './collaborationProtocol.js';
5
+ const segmentName = /^[a-f0-9-]{36}\.jsonl$/;
6
+ const digest = (bytes) => createHash('sha256').update(bytes).digest('hex');
7
+ /** File fsync precedes rename; directory fsync precedes reporting a commit. */
8
+ export function atomicIntegrationWrite(file, write) {
9
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
10
+ const temporary = `${file}.${process.pid}.${randomUUID()}.tmp`;
11
+ let descriptor;
12
+ try {
13
+ descriptor = fs.openSync(temporary, 'wx', 0o600);
14
+ write(descriptor);
15
+ fs.fsyncSync(descriptor);
16
+ fs.closeSync(descriptor);
17
+ descriptor = undefined;
18
+ fs.renameSync(temporary, file);
19
+ const directory = fs.openSync(path.dirname(file), 'r');
20
+ try {
21
+ fs.fsyncSync(directory);
22
+ }
23
+ finally {
24
+ fs.closeSync(directory);
25
+ }
26
+ }
27
+ finally {
28
+ if (descriptor !== undefined)
29
+ fs.closeSync(descriptor);
30
+ fs.rmSync(temporary, { force: true });
31
+ }
32
+ }
33
+ export function atomicJson(file, value) {
34
+ atomicIntegrationWrite(file, descriptor => fs.writeFileSync(descriptor, JSON.stringify(value)));
35
+ }
36
+ function writeBuffers(file, parts) {
37
+ atomicIntegrationWrite(file, descriptor => {
38
+ let index = 0, offset = 0;
39
+ while (index < parts.length) {
40
+ const vectors = parts.slice(index, index + 512);
41
+ if (offset)
42
+ vectors[0] = vectors[0].subarray(offset);
43
+ let written;
44
+ try {
45
+ written = fs.writevSync(descriptor, vectors);
46
+ }
47
+ catch (error) {
48
+ if (error.code === 'EINTR')
49
+ continue;
50
+ throw error;
51
+ }
52
+ if (written <= 0)
53
+ throw new Error('Integration segment write made no progress');
54
+ while (index < parts.length) {
55
+ const available = parts[index].length - offset;
56
+ if (written < available) {
57
+ offset += written;
58
+ break;
59
+ }
60
+ written -= available;
61
+ index++;
62
+ offset = 0;
63
+ if (!written)
64
+ break;
65
+ }
66
+ }
67
+ });
68
+ }
69
+ /** Immutable segments become visible ONLY through the committed manifest.
70
+ * A failed/crashed append leaves the old manifest authoritative. Orphans are
71
+ * collected after a later commit, never replayed as committed events. */
72
+ export class IntegrationJournal {
73
+ file;
74
+ segmentBytes;
75
+ directory;
76
+ segments = [];
77
+ legacy;
78
+ manifest;
79
+ uncertain = false;
80
+ committedEvents;
81
+ constructor(file, segmentBytes) {
82
+ this.file = file;
83
+ this.segmentBytes = segmentBytes;
84
+ this.directory = `${file}.segments`;
85
+ }
86
+ read() {
87
+ let raw;
88
+ try {
89
+ raw = fs.readFileSync(this.file);
90
+ }
91
+ catch (error) {
92
+ if (error.code === 'ENOENT')
93
+ return undefined;
94
+ throw error;
95
+ }
96
+ const document = JSON.parse(raw.toString('utf8'));
97
+ if (!document || !Array.isArray(document.principals) || !document.sources || !document.consumers || !document.sequences || !document.prunedThrough)
98
+ throw new Error('Invalid integration journal');
99
+ for (const [group, sequence] of Object.entries(document.sequences)) {
100
+ const floor = document.prunedThrough[group] ?? 0;
101
+ if (!Number.isSafeInteger(sequence) || sequence < 0 || !Number.isSafeInteger(floor) || floor < 0 || floor > sequence)
102
+ throw new Error('Invalid integration sequence metadata');
103
+ }
104
+ for (const value of Object.values(document.consumers))
105
+ if (!Number.isSafeInteger(value) || value < 0)
106
+ throw new Error('Invalid integration consumer watermark');
107
+ if (document.version === 1 && Array.isArray(document.events)) {
108
+ this.legacy = raw;
109
+ this.manifest = raw.toString('utf8');
110
+ return document;
111
+ }
112
+ if (document.version !== 2 || !Array.isArray(document.segments))
113
+ throw new Error('Invalid integration journal version');
114
+ const names = new Set(), events = [], previous = new Map();
115
+ for (const segment of document.segments) {
116
+ if (!segmentName.test(segment.name) || names.has(segment.name) || !Number.isSafeInteger(segment.bytes) || segment.bytes < 1
117
+ || !Number.isSafeInteger(segment.count) || segment.count < 1 || !/^[a-f0-9]{64}$/.test(segment.sha256))
118
+ throw new Error('Invalid integration segment descriptor');
119
+ names.add(segment.name);
120
+ const bytes = fs.readFileSync(path.join(this.directory, segment.name));
121
+ if (bytes.length !== segment.bytes || digest(bytes) !== segment.sha256 || bytes.at(-1) !== 10)
122
+ throw new Error('Integration segment integrity check failed');
123
+ const entries = bytes.toString('utf8').slice(0, -1).split('\n').map(line => JSON.parse(line));
124
+ if (entries.length !== segment.count)
125
+ throw new Error('Integration segment count mismatch');
126
+ for (const event of entries) {
127
+ if (!event || typeof event.group_id !== 'string' || typeof event.event_id !== 'string' || !Number.isSafeInteger(event.sequence)
128
+ || event.sequence <= (previous.get(event.group_id) ?? 0) || event.sequence > (document.sequences[event.group_id] ?? 0))
129
+ throw new Error('Invalid integration segment event');
130
+ previous.set(event.group_id, event.sequence);
131
+ events.push(event);
132
+ }
133
+ this.segments.push({ descriptor: segment, events: entries });
134
+ }
135
+ this.manifest = raw.toString('utf8');
136
+ this.committedEvents = events;
137
+ const { segments: _, version: _version, ...metadata } = document;
138
+ return { ...metadata, version: 1, events };
139
+ }
140
+ /** Migrates all existing events before any retention/byte reclamation. */
141
+ migrate(document, encode) {
142
+ if (this.legacy)
143
+ this.commit(document, encode);
144
+ }
145
+ pack(events, encode) {
146
+ const result = [];
147
+ let parts = [], chunk = [], bytes = 0;
148
+ const flush = () => {
149
+ if (!chunk.length)
150
+ return;
151
+ const descriptor = { name: `${randomUUID()}.jsonl`, bytes, count: chunk.length,
152
+ sha256: digest(Buffer.concat(parts)) };
153
+ writeBuffers(path.join(this.directory, descriptor.name), parts);
154
+ result.push({ descriptor, events: chunk });
155
+ parts = [];
156
+ chunk = [];
157
+ bytes = 0;
158
+ };
159
+ for (const event of events) {
160
+ const encoded = encode(event);
161
+ if (chunk.length && bytes + encoded.length + 1 > this.segmentBytes)
162
+ flush();
163
+ parts.push(encoded, Buffer.from('\n'));
164
+ chunk.push(event);
165
+ bytes += encoded.length + 1;
166
+ }
167
+ flush();
168
+ return result;
169
+ }
170
+ commit(document, encode) {
171
+ if (this.uncertain)
172
+ throw new CollaborationError('EVENT_COMMIT_UNCONFIRMED', 'Reopen integration storage to reconcile an uncertain manifest commit', 503);
173
+ // Detect another writer or a stale store before overwriting its ACK/identity.
174
+ let current;
175
+ try {
176
+ current = fs.readFileSync(this.file, 'utf8');
177
+ }
178
+ catch (error) {
179
+ if (error.code !== 'ENOENT')
180
+ throw error;
181
+ }
182
+ if (current !== this.manifest)
183
+ throw new CollaborationError('EVENT_JOURNAL_CHANGED', 'Integration storage changed; reopen before writing', 503);
184
+ let next = this.segments;
185
+ if (document.events !== this.committedEvents) {
186
+ const retained = new Set(document.events), known = new Set();
187
+ next = [];
188
+ for (const segment of this.segments) {
189
+ for (const event of segment.events)
190
+ known.add(event);
191
+ const kept = segment.events.filter(event => retained.has(event));
192
+ if (kept.length === segment.events.length)
193
+ next.push(segment);
194
+ else
195
+ next.push(...this.pack(kept, encode));
196
+ }
197
+ const added = document.events.filter(event => !known.has(event));
198
+ if (added.length) {
199
+ const tail = next.at(-1);
200
+ if (tail && tail.descriptor.bytes < this.segmentBytes) {
201
+ next.pop();
202
+ next.push(...this.pack([...tail.events, ...added], encode));
203
+ }
204
+ else
205
+ next.push(...this.pack(added, encode));
206
+ }
207
+ }
208
+ const { version: _, events: _events, ...metadata } = document;
209
+ const manifest = { ...metadata, version: 2, segments: next.map(segment => segment.descriptor) };
210
+ const serialized = JSON.stringify(manifest);
211
+ if (this.legacy) {
212
+ // Never overwrite a previously created recovery copy.
213
+ const backup = `${this.file}.v1-backup`;
214
+ if (fs.existsSync(backup)) {
215
+ if (!fs.readFileSync(backup).equals(this.legacy))
216
+ throw new Error('Existing integration migration backup differs');
217
+ }
218
+ else
219
+ atomicIntegrationWrite(backup, descriptor => fs.writeFileSync(descriptor, this.legacy));
220
+ }
221
+ try {
222
+ atomicIntegrationWrite(this.file, descriptor => fs.writeFileSync(descriptor, serialized));
223
+ }
224
+ catch (error) {
225
+ // rename may have happened before directory fsync failed. Neither claim
226
+ // success nor permit another write against an unknown in-memory version.
227
+ try {
228
+ this.uncertain = fs.readFileSync(this.file, 'utf8') !== this.manifest;
229
+ }
230
+ catch {
231
+ this.uncertain = true;
232
+ }
233
+ throw error;
234
+ }
235
+ this.manifest = serialized;
236
+ this.segments = next;
237
+ this.committedEvents = document.events;
238
+ this.legacy = undefined;
239
+ // GC failure cannot turn an already committed ACK into a reported failure.
240
+ try {
241
+ this.sweep();
242
+ }
243
+ catch { /* Next commit retries orphan collection. */ }
244
+ }
245
+ sweep() {
246
+ const keep = new Set(this.segments.map(segment => segment.descriptor.name));
247
+ let names;
248
+ try {
249
+ names = fs.readdirSync(this.directory);
250
+ }
251
+ catch (error) {
252
+ if (error.code === 'ENOENT')
253
+ return;
254
+ throw error;
255
+ }
256
+ for (const name of names) {
257
+ if (segmentName.test(name) && !keep.has(name))
258
+ fs.unlinkSync(path.join(this.directory, name));
259
+ const temporary = name.match(/^[a-f0-9-]{36}\.jsonl\.(\d+)\.[a-f0-9-]{36}\.tmp$/);
260
+ if (temporary) {
261
+ try {
262
+ process.kill(Number(temporary[1]), 0);
263
+ }
264
+ catch (error) {
265
+ if (error.code === 'ESRCH')
266
+ fs.unlinkSync(path.join(this.directory, name));
267
+ }
268
+ }
269
+ }
270
+ }
271
+ get metadataBytes() { return Buffer.byteLength(this.manifest ?? ''); }
272
+ get segmentCount() { return this.segments.length; }
273
+ }
@@ -9,6 +9,7 @@ import { terminalMessage } from './collaborationStore.js';
9
9
  import { IntegrationStore, INTEGRATION_PROTOCOL, INTEGRATION_OPERATIONS, atomicJson, integrationError } from './integrationStore.js';
10
10
  import { IntegrationSessions, publicIntegrationSession } from './integrationSessions.js';
11
11
  import { getTermdockVersion } from '../utils/version.js';
12
+ import { SessionAccessStore } from '../federation/sessionAccess.js';
12
13
  export function integrationSocketPath(port, home = homedir()) {
13
14
  const preferred = path.join(home, '.termdock', `integration-${port}.sock`);
14
15
  // Darwin's sockaddr_un is shorter than Linux's. Node can truncate a long
@@ -33,6 +34,7 @@ export class IntegrationRuntime {
33
34
  options;
34
35
  store;
35
36
  sessions;
37
+ sessionAccess;
36
38
  server;
37
39
  stops = [];
38
40
  streams = new Set();
@@ -46,8 +48,11 @@ export class IntegrationRuntime {
46
48
  releaseBatchRunning = false;
47
49
  constructor(options) {
48
50
  this.options = options;
49
- this.store = new IntegrationStore(path.join(options.directory, 'integrations.json'));
51
+ this.store = new IntegrationStore(path.join(options.directory, 'integrations.json'), 10000, options.eventStorage);
50
52
  this.sessions = new IntegrationSessions(path.join(options.directory, 'integration-sessions.json'), options.sessions);
53
+ this.sessionAccess = new SessionAccessStore(path.join(options.directory, 'session-access.json'), (id, principalId) => {
54
+ return this.sessions.permitsSessionAccess(id, principalId) && this.store.permits(principalId, 'session.access');
55
+ });
51
56
  this.releaseBatchFile = path.join(options.directory, 'session-release-batches.json');
52
57
  try {
53
58
  const saved = JSON.parse(fs.readFileSync(this.releaseBatchFile, 'utf8'));
@@ -74,7 +79,8 @@ export class IntegrationRuntime {
74
79
  this.sync();
75
80
  }
76
81
  catch (error) {
77
- this.failStreams(error);
82
+ if (!this.capacityBlocked(error))
83
+ this.failStreams(error);
78
84
  this.scheduleRetry();
79
85
  }
80
86
  };
@@ -104,6 +110,19 @@ export class IntegrationRuntime {
104
110
  if (!expected.length || given.length !== expected.length || !timingSafeEqual(given, expected))
105
111
  integrationError('INTEGRATION_ADMIN_REQUIRED', 'Local administration credential is required', 401);
106
112
  };
113
+ app.get('/admin/storage', run(req => {
114
+ admin(req);
115
+ return { ...this.store.storageStatus(),
116
+ related: { sessions: this.sessions.storageStatus(), tasks: options.taskStore.storageStatus(), messages: options.messages.storageStatus() } };
117
+ }));
118
+ app.get('/admin/consumers', run(req => { admin(req); return { consumers: this.store.consumerStatus() }; }));
119
+ app.post('/admin/consumers/retire', run(req => {
120
+ admin(req);
121
+ const body = req.body;
122
+ if (!body || typeof body !== 'object' || Array.isArray(body) || Object.keys(body).some(key => !['principal_id', 'consumer', 'expected_sequence', 'idempotency_key'].includes(key)))
123
+ integrationError('INVALID_CONSUMER_RETIREMENT', 'Specify principal_id, consumer, expected_sequence and idempotency_key only');
124
+ return this.store.retireConsumer(body.principal_id, body.consumer, body.expected_sequence, body.idempotency_key);
125
+ }));
107
126
  app.get('/admin/groups', run(req => { admin(req); return { groups: options.messages.listLocalGroupMetadata(req.query.include_archived === 'true') }; }));
108
127
  app.get('/admin/groups/:id', run(req => { admin(req); return { group: options.messages.getLocalGroupMetadata(req.params.id) }; }));
109
128
  app.post('/admin/groups/:id/rename', run(req => {
@@ -266,6 +285,30 @@ export class IntegrationRuntime {
266
285
  return { session: publicIntegrationSession(session) };
267
286
  }));
268
287
  app.get('/sessions/:id', run(async (req) => { const p = principal(req); this.authorize(p, 'session.read'); return { session: publicIntegrationSession(await this.sessions.get(p, req.params.id)) }; }));
288
+ app.post('/sessions/:id/access', run(async (req) => {
289
+ const p = principal(req);
290
+ this.authorize(p, 'session.access');
291
+ await this.sessions.get(p, req.params.id);
292
+ return this.sessionAccess.update(req.params.id, p.id, req.body);
293
+ }));
294
+ app.get('/sessions/:id/access-state', run(async (req) => {
295
+ const p = principal(req);
296
+ this.authorize(p, 'session.access');
297
+ const session = await this.sessions.get(p, req.params.id);
298
+ return { sessionId: session.session_id, ...this.sessionAccess.revision(session.session_id, p.id) };
299
+ }));
300
+ app.post('/sessions/:id/access-readers', run(async (req) => {
301
+ const p = principal(req);
302
+ this.authorize(p, 'session.access');
303
+ await this.sessions.get(p, req.params.id);
304
+ return this.sessionAccess.updateReaders(req.params.id, p.id, req.body);
305
+ }));
306
+ app.post('/sessions/:id/access-ticket', run(async (req) => {
307
+ const p = principal(req);
308
+ this.authorize(p, 'session.access');
309
+ await this.sessions.get(p, req.params.id);
310
+ return { ticket: this.sessionAccess.ticket(req.params.id, p.id, req.body?.deviceId, req.body?.readerId) };
311
+ }));
269
312
  app.post('/sessions/:id/restore', run(async (req) => { const p = principal(req); this.authorize(p, 'session.restore'); const session = await this.sessions.restore(p, req.params.id, req.body?.idempotency_key); this.sync(); return { session: publicIntegrationSession(session) }; }));
270
313
  app.post('/sessions/:id/retry-cold', run(async (req) => { const p = principal(req); this.authorize(p, 'session.create'); const session = await this.sessions.retryCold(p, req.params.id, req.body?.generation, req.body?.idempotency_key); this.sync(); return { session: publicIntegrationSession(session) }; }));
271
314
  app.post('/sessions/:id/release', run(async (req) => { const p = principal(req); this.authorize(p, 'session.release'); const session = await this.sessions.release(p, req.params.id, req.body?.generation, req.body?.idempotency_key); this.sync(); return { session: publicIntegrationSession(session) }; }));
@@ -284,10 +327,20 @@ export class IntegrationRuntime {
284
327
  }));
285
328
  app.post('/sessions/:id/rename', run(async (req) => { const p = principal(req); this.authorize(p, 'session.rename'); const session = await this.sessions.rename(p, req.params.id, req.body?.generation, req.body?.name, req.body?.idempotency_key); this.sync(); return { session: publicIntegrationSession(session) }; }));
286
329
  app.post('/sessions/:id/close', run(async (req) => { const p = principal(req); this.authorize(p, 'session.close'); const session = await this.sessions.closeSession(p, req.params.id, req.body?.generation, req.body?.idempotency_key); this.sync(); return { session: publicIntegrationSession(session) }; }));
330
+ app.delete('/sessions/:id', run(async (req) => { const p = principal(req); this.authorize(p, 'session.forget'); await this.sessions.forget(p, req.params.id, req.body?.generation); this.sync(); return { forgotten: true }; }));
287
331
  app.get('/events', run((req, res) => {
288
332
  const p = principal(req);
289
333
  this.authorize(p, 'events.read', req.query.group);
290
- this.sync();
334
+ // Capacity backpressure must not prevent a consumer from draining and
335
+ // confirming the already committed suffix that releases that capacity.
336
+ try {
337
+ this.sync();
338
+ }
339
+ catch (error) {
340
+ if (!this.capacityBlocked(error))
341
+ throw error;
342
+ this.scheduleRetry();
343
+ }
291
344
  this.stream(p, String(req.query.consumer ?? 'default'), req, res);
292
345
  }));
293
346
  app.post('/events/ack', run(req => {
@@ -303,9 +356,11 @@ export class IntegrationRuntime {
303
356
  this.server = createServer(app);
304
357
  }
305
358
  capabilities() {
306
- return { integration_protocol: INTEGRATION_PROTOCOL, server_version: getTermdockVersion(), local_only: true,
307
- integration_principal: true, group_scoped_permissions: true, durable_event_push: true,
359
+ return { integration_protocol: INTEGRATION_PROTOCOL, server_version: getTermdockVersion(), service_id: this.options.serviceId, local_only: true,
360
+ integration_principal: true, group_scoped_permissions: true, durable_event_push: true, session_access_tickets: true, session_access_readers: true,
361
+ session_access_exclusive: true, session_access_state: true,
308
362
  event_ack_semantics: 'durable_received', correlated_task_responses: true, accepted_task_comments: true,
363
+ segmented_event_storage: true, event_storage_diagnostics: true, explicit_consumer_retirement: true,
309
364
  idempotent_session_create: true, exact_session_restore: true, external_validation: false,
310
365
  startup_input_conditions: true, session_runtime_release: true, stable_session_identity: true, session_lifecycle_generation: true,
311
366
  session_resume_configuration: true, session_lifecycle_min_version: '1.4.309',
@@ -322,6 +377,7 @@ export class IntegrationRuntime {
322
377
  session_cold_retry: true, session_cold_retry_min_version: '1.4.314',
323
378
  session_rename: true, session_rename_min_version: '1.4.315',
324
379
  session_close: true, session_close_min_version: '1.4.315',
380
+ session_forget: true,
325
381
  session_close_diagnostics: true, session_close_diagnostics_min_version: '1.4.349',
326
382
  session_close_detached_registration: process.platform === 'linux', session_close_detached_registration_min_version: '1.4.349',
327
383
  admin_local_group_create: true, admin_local_group_create_min_version: '1.4.318',
@@ -549,7 +605,27 @@ export class IntegrationRuntime {
549
605
  }
550
606
  });
551
607
  updates.sort((a, b) => a.event.created_at - b.event.created_at);
552
- this.store.append(updates);
608
+ // Group sequences are independent. A large/stalled group's backpressure
609
+ // must not roll back otherwise admissible groups in the same source scan.
610
+ const batches = new Map();
611
+ for (const update of updates) {
612
+ const batch = batches.get(update.event.group_id) ?? [];
613
+ batch.push(update);
614
+ batches.set(update.event.group_id, batch);
615
+ }
616
+ let blocked;
617
+ for (const batch of batches.values()) {
618
+ try {
619
+ this.store.append(batch);
620
+ }
621
+ catch (error) {
622
+ if (!this.capacityBlocked(error))
623
+ throw error;
624
+ blocked ??= error;
625
+ }
626
+ }
627
+ if (blocked)
628
+ throw blocked;
553
629
  this.syncedGroups = groupVersion;
554
630
  }
555
631
  catch (error) {
@@ -567,6 +643,7 @@ export class IntegrationRuntime {
567
643
  for (const stream of this.streams)
568
644
  stream.end(`${JSON.stringify(this.error(error))}\n`);
569
645
  }
646
+ capacityBlocked(error) { return error instanceof CollaborationError && error.code === 'EVENT_STORAGE_FULL'; }
570
647
  scheduleRetry() {
571
648
  if (this.retry)
572
649
  return;
@@ -665,7 +742,14 @@ export class IntegrationRuntime {
665
742
  }
666
743
  });
667
744
  });
668
- this.sync();
745
+ try {
746
+ this.sync();
747
+ }
748
+ catch (error) {
749
+ if (!this.capacityBlocked(error))
750
+ throw error;
751
+ this.scheduleRetry();
752
+ }
669
753
  await this.sessions.refreshAll();
670
754
  void this.pumpReleaseBatches();
671
755
  }
@@ -2,7 +2,8 @@ import fs from 'node:fs';
2
2
  import { cleanMemberName } from './collaborationRoutes.js';
3
3
  import path from 'node:path';
4
4
  import { createHash, randomBytes, randomUUID } from 'node:crypto';
5
- import { atomicJson, integrationError } from './integrationStore.js';
5
+ import { integrationError } from './integrationStore.js';
6
+ import { JsonStorageBudget } from '../utils/jsonStorageBudget.js';
6
7
  import { SessionLifecycleCoordinator, assertMessageRuntime, assertReleaseFacts, assertReleaseGeneration } from './sessionLifecycle.js';
7
8
  import { resumeConfigurationFingerprint } from './sessionResumeConfiguration.js';
8
9
  import { NativeResumeOwnerError } from './nativeResumeOwner.js';
@@ -52,7 +53,6 @@ export function permittedCwd(profile, cwd) {
52
53
  /** Creation intent and exact launch configuration are durable before any input
53
54
  * enters a PTY. An ambiguous crash never starts another Agent automatically. */
54
55
  export class IntegrationSessions {
55
- file;
56
56
  adapter;
57
57
  bindingTimeout;
58
58
  doc = { version: 1, sessions: [], requests: {} };
@@ -70,10 +70,11 @@ export class IntegrationSessions {
70
70
  observeAgain = false;
71
71
  observationTimer;
72
72
  stopped = false;
73
- constructor(file, adapter, bindingTimeout = 120000) {
74
- this.file = file;
73
+ storage;
74
+ constructor(file, adapter, bindingTimeout = 120000, options = {}) {
75
75
  this.adapter = adapter;
76
76
  this.bindingTimeout = bindingTimeout;
77
+ this.storage = new JsonStorageBudget(file, 'integration-sessions', 'TERMDOCK_SESSION_STORAGE_MAX_BYTES', 64 * 1024 * 1024, () => new CollaborationError('SESSION_STORAGE_FULL', 'New recovery operations exceed capacity; existing outcomes and close/release remain recordable. Configure TERMDOCK_SESSION_STORAGE_MAX_BYTES', 503), options);
77
78
  try {
78
79
  const doc = JSON.parse(fs.readFileSync(file, 'utf8'));
79
80
  if (doc.version !== 1 || !Array.isArray(doc.sessions) || !doc.requests)
@@ -122,10 +123,13 @@ export class IntegrationSessions {
122
123
  }
123
124
  subscribe(listener) { this.listeners.add(listener); return () => { this.listeners.delete(listener); }; }
124
125
  snapshot() { return structuredClone(this.doc.sessions); }
125
- save() {
126
- if (Buffer.byteLength(JSON.stringify(this.doc)) > 64 * 1024 * 1024)
127
- integrationError('SESSION_STORAGE_FULL', 'Recovery descriptor journal is full; no new operation was committed', 503);
128
- atomicJson(this.file, this.doc);
126
+ permitsSessionAccess(id, principalId) {
127
+ const record = this.doc.sessions.find(s => s.session_id === id && s.principal_id === principalId);
128
+ return !!record && !['closed', 'closing', 'released', 'releasing'].includes(record.state);
129
+ }
130
+ storageStatus() { return this.storage.status(); }
131
+ save(admission = false) {
132
+ this.storage.write(JSON.stringify(this.doc), !admission);
129
133
  for (const listener of this.listeners) {
130
134
  try {
131
135
  listener();
@@ -133,13 +137,13 @@ export class IntegrationSessions {
133
137
  catch { /* Durable session state is reconciled on restart. */ }
134
138
  }
135
139
  }
136
- saveRecord(record) {
140
+ saveRecord(record, admission = false) {
137
141
  const before = this.doc.sessions;
138
142
  const seen = before.find(s => s.session_id === record.session_id)?.message_history_seen;
139
143
  this.doc.sessions = [...before.filter(s => s.session_id !== record.session_id), structuredClone({ ...record,
140
144
  ...(seen ? { message_history_seen: true } : {}) })];
141
145
  try {
142
- this.save();
146
+ this.save(admission);
143
147
  }
144
148
  catch (error) {
145
149
  this.doc.sessions = before;
@@ -159,6 +163,8 @@ export class IntegrationSessions {
159
163
  const previous = this.doc.requests[scoped];
160
164
  if (previous && previous.hash !== hash)
161
165
  integrationError('IDEMPOTENCY_CONFLICT', 'Key was already used for different session parameters', 409);
166
+ if (previous?.forgottenGroupId)
167
+ integrationError('SESSION_FORGOTTEN', 'The original session was permanently removed; use a new creation key', 409);
162
168
  return { scoped, hash, previous };
163
169
  }
164
170
  async create(principal, input) {
@@ -185,7 +191,7 @@ export class IntegrationSessions {
185
191
  record.startup_input = startupInput(record, now);
186
192
  this.doc.requests[request.scoped] = { hash: request.hash, sessionId: record.session_id };
187
193
  try {
188
- this.saveRecord(record);
194
+ this.saveRecord(record, true);
189
195
  }
190
196
  catch (error) {
191
197
  delete this.doc.requests[request.scoped];
@@ -396,7 +402,7 @@ export class IntegrationSessions {
396
402
  record.launch_diagnostics = beginLaunchDiagnostics(record.operation_id, record.generation, 'cold_retry');
397
403
  this.doc.requests[request.scoped] = { hash: request.hash, sessionId: id, operationId: record.operation_id, generation: record.generation };
398
404
  try {
399
- this.saveRecord(record);
405
+ this.saveRecord(record, true);
400
406
  }
401
407
  catch (error) {
402
408
  delete this.doc.requests[request.scoped];
@@ -441,7 +447,7 @@ export class IntegrationSessions {
441
447
  integrationError('SESSION_IDENTITY_MISMATCH', 'Existing process does not prove the exact native binding', 409);
442
448
  this.doc.requests[request.scoped] = { hash: request.hash, sessionId: id, operationId: record.operation_id, generation: record.generation };
443
449
  try {
444
- this.save();
450
+ this.save(true);
445
451
  }
446
452
  catch (error) {
447
453
  delete this.doc.requests[request.scoped];
@@ -473,7 +479,7 @@ export class IntegrationSessions {
473
479
  record.startup_input = startupInput(record, Date.now());
474
480
  this.doc.requests[request.scoped] = { hash: request.hash, sessionId: id, operationId: record.operation_id, generation: record.generation };
475
481
  try {
476
- this.saveRecord(record);
482
+ this.saveRecord(record, true);
477
483
  }
478
484
  catch (error) {
479
485
  delete this.doc.requests[request.scoped];
@@ -574,7 +580,7 @@ export class IntegrationSessions {
574
580
  record.rename_pending = true;
575
581
  this.doc.requests[request.scoped] = { hash: request.hash, sessionId: id, operationId: record.operation_id, generation: record.generation };
576
582
  try {
577
- this.saveRecord(record);
583
+ this.saveRecord(record, true);
578
584
  }
579
585
  catch (error) {
580
586
  delete this.doc.requests[request.scoped];
@@ -659,6 +665,35 @@ export class IntegrationSessions {
659
665
  }
660
666
  }));
661
667
  }
668
+ /** Remove a closed recovery descriptor, retaining only idempotency tombstones.
669
+ * Native provider history and audit events are deliberately outside this API. */
670
+ async forget(principal, id, generation) {
671
+ return this.lifecycle.run(id, async () => {
672
+ const record = this.doc.sessions.find(s => s.session_id === id && s.principal_id === principal.id && s.group_id === principal.groupId);
673
+ if (!record) {
674
+ if (Object.entries(this.doc.requests).some(([key, value]) => key.startsWith(principal.id + ':') && value.sessionId === id
675
+ && value.forgottenGroupId === principal.groupId && value.generation === generation))
676
+ return;
677
+ integrationError('INTEGRATION_SESSION_NOT_FOUND', 'Session is not owned by this integration', 404);
678
+ }
679
+ assertReleaseGeneration(record, generation);
680
+ if (record.state !== 'closed' || record.close_intent || record.terminal_binding || record.runtime_present !== false
681
+ || !this.adapter.runtimePresent || await this.adapter.runtimePresent(record)) {
682
+ integrationError('SESSION_NOT_CLOSED', 'Confirm a completed close before removing its recovery descriptor', 409);
683
+ }
684
+ this.assertManagementIdle(record);
685
+ const before = this.doc;
686
+ this.doc = { ...before, sessions: before.sessions.filter(s => s.session_id !== id), requests: Object.fromEntries(Object.entries(before.requests).map(([key, value]) => [key, key.startsWith(principal.id + ':') && value.sessionId === id
687
+ ? { hash: value.hash, sessionId: id, generation, forgottenGroupId: principal.groupId } : value])) };
688
+ try {
689
+ this.save();
690
+ }
691
+ catch (error) {
692
+ this.doc = before;
693
+ throw error;
694
+ }
695
+ });
696
+ }
662
697
  async release(principal, id, generation, key) {
663
698
  return this.mutate(principal, key, ['release', id, generation], () => this.lifecycle.run(id, async () => {
664
699
  const request = this.request(principal, key, ['release', id, generation]);