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.
- package/dist/server/agent/collaborationStore.js +115 -41
- package/dist/server/agent/collaborationTaskStore.js +12 -15
- package/dist/server/agent/integrationCli.js +28 -0
- package/dist/server/agent/integrationJournal.js +273 -0
- package/dist/server/agent/integrationServer.js +91 -7
- package/dist/server/agent/integrationSessions.js +50 -15
- package/dist/server/agent/integrationStore.js +146 -109
- package/dist/server/agent/registry.js +1 -1
- package/dist/server/cli.js +6 -4
- package/dist/server/entry.js +2 -0
- package/dist/server/federation/runtime.js +37 -7
- package/dist/server/federation/sessionAccess.js +171 -0
- package/dist/server/todos/store.js +19 -35
- package/dist/server/utils/jsonStorageBudget.js +105 -0
- package/dist/server/utils/termdockState.js +5 -0
- package/docs/integrations.md +248 -0
- package/package.json +1 -1
- package/runtime-manifest.json +2 -2
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
74
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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]);
|