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,171 @@
|
|
|
1
|
+
import { createHmac, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { CollaborationError } from '../agent/collaborationProtocol.js';
|
|
5
|
+
const readActions = ['session.view', 'session.chat-view', 'session.terminal-view', 'session.metadata'];
|
|
6
|
+
const writeActions = [...readActions, 'session.chat-send', 'session.input', 'session.resize'];
|
|
7
|
+
/** Local integrations may lend access to their own sessions without lending their
|
|
8
|
+
* credential. The encrypted peer must prove the device identity in the ticket. */
|
|
9
|
+
export class SessionAccessStore {
|
|
10
|
+
file;
|
|
11
|
+
authorized;
|
|
12
|
+
now;
|
|
13
|
+
key;
|
|
14
|
+
states;
|
|
15
|
+
used = new Map();
|
|
16
|
+
constructor(file, authorized, now = Date.now) {
|
|
17
|
+
this.file = file;
|
|
18
|
+
this.authorized = authorized;
|
|
19
|
+
this.now = now;
|
|
20
|
+
try {
|
|
21
|
+
const saved = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
22
|
+
if (saved.version !== 1 || typeof saved.key !== 'string' || !saved.states)
|
|
23
|
+
throw new Error('Invalid session access store');
|
|
24
|
+
this.key = Buffer.from(saved.key, 'base64');
|
|
25
|
+
this.states = saved.states;
|
|
26
|
+
if (this.key.length !== 32)
|
|
27
|
+
throw new Error('Invalid session access key');
|
|
28
|
+
if (saved.used !== undefined && (!Array.isArray(saved.used) || saved.used.some((entry) => !Array.isArray(entry) || entry.length !== 2 || typeof entry[0] !== 'string' || !Number.isSafeInteger(entry[1])))) {
|
|
29
|
+
throw new Error('Invalid consumed session tickets');
|
|
30
|
+
}
|
|
31
|
+
this.used = new Map((saved.used ?? []).filter(([, expiry]) => expiry > this.now()));
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
if (error.code !== 'ENOENT')
|
|
35
|
+
throw error;
|
|
36
|
+
this.key = randomBytes(32);
|
|
37
|
+
this.states = {};
|
|
38
|
+
this.save(this.states);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
save(states, used = this.used) {
|
|
42
|
+
fs.mkdirSync(path.dirname(this.file), { recursive: true, mode: 0o700 });
|
|
43
|
+
const temp = `${this.file}.${randomUUID()}.tmp`;
|
|
44
|
+
const fd = fs.openSync(temp, 'wx', 0o600);
|
|
45
|
+
try {
|
|
46
|
+
fs.writeFileSync(fd, JSON.stringify({ version: 1, key: this.key.toString('base64'), states, used: [...used] }));
|
|
47
|
+
fs.fsyncSync(fd);
|
|
48
|
+
}
|
|
49
|
+
finally {
|
|
50
|
+
fs.closeSync(fd);
|
|
51
|
+
}
|
|
52
|
+
fs.renameSync(temp, this.file);
|
|
53
|
+
const directory = fs.openSync(path.dirname(this.file), 'r');
|
|
54
|
+
try {
|
|
55
|
+
fs.fsyncSync(directory);
|
|
56
|
+
}
|
|
57
|
+
finally {
|
|
58
|
+
fs.closeSync(directory);
|
|
59
|
+
}
|
|
60
|
+
this.states = states;
|
|
61
|
+
this.used = used;
|
|
62
|
+
}
|
|
63
|
+
/** Non-mutating recovery metadata; never disclose lease identities or keys. */
|
|
64
|
+
revision(sessionId, principalId) {
|
|
65
|
+
const state = this.states[sessionId];
|
|
66
|
+
if (!this.authorized(sessionId, principalId) || state && state.principalId !== principalId) {
|
|
67
|
+
throw new CollaborationError('SESSION_ACCESS_DENIED', 'SESSION_ACCESS_DENIED', 409);
|
|
68
|
+
}
|
|
69
|
+
return { fence: state?.fence || 0, readerFence: state?.readers?.fence || 0, exclusive: state?.exclusive === true };
|
|
70
|
+
}
|
|
71
|
+
/** Exclusive ownership also fences ordinary session-scoped invitations. The
|
|
72
|
+
* federation runtime preserves separately authenticated full-service admins. */
|
|
73
|
+
restrictsNativeWriter(sessionId, action) {
|
|
74
|
+
const state = this.states[sessionId];
|
|
75
|
+
return state?.exclusive === true && this.authorized(sessionId, state.principalId)
|
|
76
|
+
&& ['session.input', 'session.chat-send', 'session.resize'].includes(action);
|
|
77
|
+
}
|
|
78
|
+
update(sessionId, principalId, input) {
|
|
79
|
+
const old = this.states[sessionId], lease = input?.lease, now = this.now();
|
|
80
|
+
if (!this.authorized(sessionId, principalId) || !Number.isSafeInteger(input?.fence) || input.fence < 1
|
|
81
|
+
|| input.exclusive !== undefined && typeof input.exclusive !== 'boolean'
|
|
82
|
+
|| old && (old.principalId !== principalId || input.fence < old.fence))
|
|
83
|
+
throw new CollaborationError('SESSION_ACCESS_STALE', 'SESSION_ACCESS_STALE', 409);
|
|
84
|
+
if (lease !== null && (!lease || !/^[a-zA-Z0-9-]{16,80}$/.test(lease.id) || typeof lease.userId !== 'string' || !lease.userId || lease.userId.length > 256
|
|
85
|
+
|| typeof lease.deviceId !== 'string' || lease.deviceId.length > 160 || !/^[a-zA-Z0-9]*$/.test(lease.deviceId)
|
|
86
|
+
|| !Number.isSafeInteger(lease.expiresAt) || lease.expiresAt <= now || lease.expiresAt > now + 125000))
|
|
87
|
+
throw new CollaborationError('SESSION_ACCESS_INVALID_LEASE', 'SESSION_ACCESS_INVALID_LEASE', 409);
|
|
88
|
+
if (old && input.fence === old.fence && (old.lease?.id !== lease?.id || old.lease?.userId !== lease?.userId
|
|
89
|
+
|| old.lease?.deviceId && old.lease.deviceId !== lease?.deviceId || old.lease && lease && lease.expiresAt < old.lease.expiresAt
|
|
90
|
+
|| input.exclusive !== undefined && input.exclusive !== (old.exclusive === true)))
|
|
91
|
+
throw new CollaborationError('SESSION_ACCESS_CONFLICT', 'SESSION_ACCESS_CONFLICT', 409);
|
|
92
|
+
this.save({ ...this.states, [sessionId]: { ...old, principalId, fence: input.fence, lease,
|
|
93
|
+
...(input.exclusive === undefined ? {} : { exclusive: input.exclusive }) } });
|
|
94
|
+
return { accepted: true, fence: input.fence };
|
|
95
|
+
}
|
|
96
|
+
/** Readers have an independent revision: renewing or releasing the writer must
|
|
97
|
+
* not resize, disconnect or upgrade an observer. The caller owns membership. */
|
|
98
|
+
updateReaders(sessionId, principalId, input) {
|
|
99
|
+
const old = this.states[sessionId], now = this.now();
|
|
100
|
+
if (!this.authorized(sessionId, principalId) || old && old.principalId !== principalId || !Number.isSafeInteger(input?.fence) || input.fence < 1
|
|
101
|
+
|| !Array.isArray(input.leases) || input.leases.length > 32 || old?.readers && input.fence < old.readers.fence)
|
|
102
|
+
throw new CollaborationError('SESSION_ACCESS_STALE', 'SESSION_ACCESS_STALE', 409);
|
|
103
|
+
const ids = new Set();
|
|
104
|
+
for (const lease of input.leases) {
|
|
105
|
+
if (!lease || !/^[a-zA-Z0-9-]{16,80}$/.test(lease.id) || ids.has(lease.id) || typeof lease.userId !== 'string' || !lease.userId || lease.userId.length > 256
|
|
106
|
+
|| typeof lease.deviceId !== 'string' || !/^[a-zA-Z0-9]{0,160}$/.test(lease.deviceId) || !Number.isSafeInteger(lease.expiresAt) || lease.expiresAt <= now || lease.expiresAt > now + 125000)
|
|
107
|
+
throw new CollaborationError('SESSION_ACCESS_INVALID_LEASE', 'SESSION_ACCESS_INVALID_LEASE', 409);
|
|
108
|
+
ids.add(lease.id);
|
|
109
|
+
}
|
|
110
|
+
if (old?.readers && input.fence === old.readers.fence && JSON.stringify(old.readers.leases) !== JSON.stringify(input.leases))
|
|
111
|
+
throw new CollaborationError('SESSION_ACCESS_CONFLICT', 'SESSION_ACCESS_CONFLICT', 409);
|
|
112
|
+
const generations = Object.fromEntries(input.leases.map(lease => {
|
|
113
|
+
const previous = old?.readers?.leases.find(reader => reader.id === lease.id);
|
|
114
|
+
const sameIdentity = previous?.userId === lease.userId && previous?.deviceId === lease.deviceId;
|
|
115
|
+
return [lease.id, sameIdentity ? old?.readers?.generations[lease.id] ?? input.fence : input.fence];
|
|
116
|
+
}));
|
|
117
|
+
this.save({ ...this.states, [sessionId]: { ...old, principalId, fence: old?.fence || 0, lease: old?.lease || null, readers: { fence: input.fence, leases: input.leases, generations } } });
|
|
118
|
+
return { accepted: true, fence: input.fence };
|
|
119
|
+
}
|
|
120
|
+
ticket(sessionId, principalId, deviceId, readerId) {
|
|
121
|
+
const state = this.states[sessionId];
|
|
122
|
+
const lease = readerId ? state?.readers?.leases.find(x => x.id === readerId) : state?.lease;
|
|
123
|
+
if (!lease || state.principalId !== principalId || lease.deviceId !== deviceId || !deviceId || lease.expiresAt <= this.now()
|
|
124
|
+
|| !this.authorized(sessionId, principalId))
|
|
125
|
+
throw new CollaborationError('SESSION_ACCESS_DENIED', 'SESSION_ACCESS_DENIED', 409);
|
|
126
|
+
const claims = { aud: 'termdock-session-access', sessionId, principalId, deviceId, leaseId: lease.id,
|
|
127
|
+
fence: readerId ? state.readers.generations[readerId] : state.fence, ...(readerId ? { readOnly: true } : {}), exp: Math.min(lease.expiresAt, this.now() + 30000), jti: randomUUID() };
|
|
128
|
+
const body = Buffer.from(JSON.stringify(claims)).toString('base64url');
|
|
129
|
+
return `session.${body}.${createHmac('sha256', this.key).update(body).digest('base64url')}`;
|
|
130
|
+
}
|
|
131
|
+
consume(ticket, subjectId) {
|
|
132
|
+
if (typeof ticket !== 'string' || ticket.length > 4096)
|
|
133
|
+
throw new CollaborationError('SESSION_ACCESS_INVALID_TICKET', 'SESSION_ACCESS_INVALID_TICKET', 409);
|
|
134
|
+
const [prefix, body, signature, extra] = ticket.split('.');
|
|
135
|
+
if (prefix !== 'session' || !body || !signature || extra)
|
|
136
|
+
throw new CollaborationError('SESSION_ACCESS_INVALID_TICKET', 'SESSION_ACCESS_INVALID_TICKET', 409);
|
|
137
|
+
const expected = createHmac('sha256', this.key).update(body).digest(), supplied = Buffer.from(signature, 'base64url');
|
|
138
|
+
if (supplied.length !== expected.length || !timingSafeEqual(supplied, expected))
|
|
139
|
+
throw new CollaborationError('SESSION_ACCESS_INVALID_TICKET', 'SESSION_ACCESS_INVALID_TICKET', 409);
|
|
140
|
+
const claims = JSON.parse(Buffer.from(body, 'base64url').toString());
|
|
141
|
+
const used = new Map([...this.used].filter(([, expiry]) => expiry > this.now()));
|
|
142
|
+
if (claims.aud !== 'termdock-session-access' || !Number.isSafeInteger(claims.exp) || claims.exp <= this.now() || claims.exp > this.now() + 35000
|
|
143
|
+
|| typeof claims.jti !== 'string' || !claims.jti || used.has(claims.jti) || used.size > 4096 || !this.valid(claims, subjectId))
|
|
144
|
+
throw new CollaborationError('SESSION_ACCESS_DENIED', 'SESSION_ACCESS_DENIED', 409);
|
|
145
|
+
used.set(claims.jti, claims.exp);
|
|
146
|
+
// Admission is successful only after the single-use decision is durable.
|
|
147
|
+
try {
|
|
148
|
+
this.save(this.states, used);
|
|
149
|
+
}
|
|
150
|
+
catch (error) {
|
|
151
|
+
// A rename may already have committed before a directory fsync fails.
|
|
152
|
+
// Never retry the same admission in this process after an uncertain write.
|
|
153
|
+
this.used = used;
|
|
154
|
+
throw error;
|
|
155
|
+
}
|
|
156
|
+
return claims;
|
|
157
|
+
}
|
|
158
|
+
valid(claims, subjectId) {
|
|
159
|
+
const state = this.states[claims.sessionId];
|
|
160
|
+
const lease = claims.readOnly === true ? state?.readers?.leases.find(x => x.id === claims.leaseId) : state?.lease;
|
|
161
|
+
return !!lease && claims.deviceId === subjectId && lease.deviceId === subjectId && lease.id === claims.leaseId
|
|
162
|
+
&& (claims.readOnly === true ? state.readers.generations[claims.leaseId] === claims.fence : state.fence === claims.fence) && state.principalId === claims.principalId && lease.expiresAt > this.now() && this.authorized(claims.sessionId, claims.principalId);
|
|
163
|
+
}
|
|
164
|
+
allows(claims, subjectId, action, sessionId) {
|
|
165
|
+
return this.valid(claims, subjectId) && sessionId === claims.sessionId && (claims.readOnly === true ? readActions : writeActions).includes(action);
|
|
166
|
+
}
|
|
167
|
+
grant(claims, subjectId) {
|
|
168
|
+
return this.valid(claims, subjectId) ? [{ id: claims.leaseId, subjectId, scope: { kind: 'sessions', sessionIds: [claims.sessionId] },
|
|
169
|
+
actions: claims.readOnly === true ? readActions : writeActions, expiresAt: (claims.readOnly === true ? this.states[claims.sessionId].readers.leases.find(x => x.id === claims.leaseId) : this.states[claims.sessionId].lease).expiresAt }] : [];
|
|
170
|
+
}
|
|
171
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import { canClaimTodo } from './claimEligibility.js';
|
|
3
|
-
import
|
|
3
|
+
import { JsonStorageBudget } from '../utils/jsonStorageBudget.js';
|
|
4
4
|
import { randomUUID, randomBytes, createHash } from 'node:crypto';
|
|
5
5
|
import { nextAutomationRunAt, normalizeAutomationSchedule } from '../agent/automationStore.js';
|
|
6
6
|
export class TodoError extends Error {
|
|
@@ -45,10 +45,10 @@ export function collectionSessionPolicy(input) {
|
|
|
45
45
|
}
|
|
46
46
|
const blank = () => ({ version: 1, todos: [], collectors: [], runs: [], requests: {}, defaults: { agentSlug: '', command: '', cwd: '', prompt: '' } });
|
|
47
47
|
export class TodoStore {
|
|
48
|
-
file;
|
|
49
48
|
doc = blank();
|
|
50
|
-
|
|
51
|
-
|
|
49
|
+
storage;
|
|
50
|
+
constructor(file, options = {}) {
|
|
51
|
+
this.storage = new JsonStorageBudget(file, 'todos', 'TERMDOCK_TODO_STORAGE_MAX_BYTES', 32 * 1024 * 1024, () => new TodoError('待办新增内容已达容量上限;可完成或释放已有工作,或调整 TERMDOCK_TODO_STORAGE_MAX_BYTES', 409), options);
|
|
52
52
|
try {
|
|
53
53
|
const doc = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
54
54
|
if (doc.version !== 1 || !Array.isArray(doc.todos) || !Array.isArray(doc.collectors) || !Array.isArray(doc.runs) || !doc.requests || !doc.defaults)
|
|
@@ -63,36 +63,18 @@ export class TodoStore {
|
|
|
63
63
|
run.error = '服务重启中断了启动确认,请检查已有终端后重新发起';
|
|
64
64
|
run.updatedAt = Date.now();
|
|
65
65
|
}
|
|
66
|
-
});
|
|
66
|
+
}, true);
|
|
67
67
|
}
|
|
68
68
|
catch (error) {
|
|
69
69
|
if (error.code !== 'ENOENT')
|
|
70
70
|
throw error;
|
|
71
71
|
}
|
|
72
72
|
}
|
|
73
|
-
|
|
73
|
+
storageStatus() { return this.storage.status(); }
|
|
74
|
+
transaction(change, control = false) {
|
|
74
75
|
const next = structuredClone(this.doc), result = change(next);
|
|
75
76
|
const bytes = JSON.stringify(next);
|
|
76
|
-
|
|
77
|
-
throw new TodoError('待办记录已达容量上限,请整理后重试', 409);
|
|
78
|
-
fs.mkdirSync(path.dirname(this.file), { recursive: true, mode: 0o700 });
|
|
79
|
-
const temporary = `${this.file}.${process.pid}.tmp`;
|
|
80
|
-
const fd = fs.openSync(temporary, 'w', 0o600);
|
|
81
|
-
try {
|
|
82
|
-
fs.writeFileSync(fd, bytes);
|
|
83
|
-
fs.fsyncSync(fd);
|
|
84
|
-
}
|
|
85
|
-
finally {
|
|
86
|
-
fs.closeSync(fd);
|
|
87
|
-
}
|
|
88
|
-
fs.renameSync(temporary, this.file);
|
|
89
|
-
const directory = fs.openSync(path.dirname(this.file), 'r');
|
|
90
|
-
try {
|
|
91
|
-
fs.fsyncSync(directory);
|
|
92
|
-
}
|
|
93
|
-
finally {
|
|
94
|
-
fs.closeSync(directory);
|
|
95
|
-
}
|
|
77
|
+
this.storage.write(bytes, control);
|
|
96
78
|
this.doc = next;
|
|
97
79
|
return structuredClone(result);
|
|
98
80
|
}
|
|
@@ -174,7 +156,7 @@ export class TodoStore {
|
|
|
174
156
|
todo.revision++;
|
|
175
157
|
todo.updatedAt = Date.now();
|
|
176
158
|
return todo;
|
|
177
|
-
});
|
|
159
|
+
}, input.title === undefined && input.description === undefined && (input.status === 'completed' || input.status === 'ignored'));
|
|
178
160
|
}
|
|
179
161
|
defaults(input) { const config = launchConfig(input, this.doc.defaults); return this.transaction(doc => doc.defaults = config); }
|
|
180
162
|
saveCollector(input) {
|
|
@@ -297,6 +279,8 @@ export class TodoStore {
|
|
|
297
279
|
return this.hash({ todoIds: input.todoIds, note: input.note, sessionId: input.sessionId ?? null, config: input.sessionId ? null : input.config });
|
|
298
280
|
}
|
|
299
281
|
mark(id, update) {
|
|
282
|
+
if (update.error)
|
|
283
|
+
update = { ...update, error: update.error.slice(0, 4000) };
|
|
300
284
|
return this.transaction(doc => {
|
|
301
285
|
const run = doc.runs.find(r => r.id === id);
|
|
302
286
|
if (!run)
|
|
@@ -312,18 +296,18 @@ export class TodoStore {
|
|
|
312
296
|
}
|
|
313
297
|
run.updatedAt = Date.now();
|
|
314
298
|
return run;
|
|
315
|
-
});
|
|
299
|
+
}, true);
|
|
316
300
|
}
|
|
317
301
|
automaticFailed(id, error) {
|
|
318
302
|
this.transaction(doc => {
|
|
319
303
|
const todo = doc.todos.find(t => t.id === id);
|
|
320
304
|
if (todo?.status === 'open' && todo.autoProcessing?.state === 'pending') {
|
|
321
305
|
todo.autoProcessing.state = 'failed';
|
|
322
|
-
todo.autoProcessing.error = error;
|
|
306
|
+
todo.autoProcessing.error = error.slice(0, 4000);
|
|
323
307
|
todo.updatedAt = Date.now();
|
|
324
308
|
todo.revision++;
|
|
325
309
|
}
|
|
326
|
-
});
|
|
310
|
+
}, true);
|
|
327
311
|
}
|
|
328
312
|
claim(input) {
|
|
329
313
|
if (![input.source, input.collectorId, input.todoId].some(value => typeof value === 'string' && value.trim()))
|
|
@@ -385,7 +369,7 @@ export class TodoStore {
|
|
|
385
369
|
todo.revision++;
|
|
386
370
|
todo.updatedAt = Date.now();
|
|
387
371
|
return todo;
|
|
388
|
-
});
|
|
372
|
+
}, true);
|
|
389
373
|
}
|
|
390
374
|
renewClaim(id, token, minutes) {
|
|
391
375
|
if (!Number.isInteger(minutes) || minutes < 1 || minutes > 1440)
|
|
@@ -400,7 +384,7 @@ export class TodoStore {
|
|
|
400
384
|
todo.revision++;
|
|
401
385
|
todo.updatedAt = Date.now();
|
|
402
386
|
return todo;
|
|
403
|
-
});
|
|
387
|
+
}, true);
|
|
404
388
|
}
|
|
405
389
|
bindCollectionSession(sessionId, runtimeIdentity) {
|
|
406
390
|
this.transaction(doc => {
|
|
@@ -408,7 +392,7 @@ export class TodoStore {
|
|
|
408
392
|
if (run.kind === 'collect' && run.sessionId === sessionId && run.collectionSession?.owned !== false && !run.collectionSession?.recycledAt) {
|
|
409
393
|
run.collectionSession = { ...collectionSessionPolicy(run.collectionSession), ...run.collectionSession, owned: true, runtimeIdentity };
|
|
410
394
|
}
|
|
411
|
-
});
|
|
395
|
+
}, true);
|
|
412
396
|
}
|
|
413
397
|
recycled(sessionId) {
|
|
414
398
|
this.transaction(doc => {
|
|
@@ -416,7 +400,7 @@ export class TodoStore {
|
|
|
416
400
|
if (run.kind === 'collect' && run.sessionId === sessionId && run.collectionSession?.owned !== false) {
|
|
417
401
|
run.collectionSession = { ...collectionSessionPolicy(run.collectionSession), owned: true, recycledAt: Date.now() };
|
|
418
402
|
}
|
|
419
|
-
});
|
|
403
|
+
}, true);
|
|
420
404
|
}
|
|
421
405
|
report(id, status, content) {
|
|
422
406
|
if (!['complete', 'blocked', 'failed'].includes(status))
|
|
@@ -434,7 +418,7 @@ export class TodoStore {
|
|
|
434
418
|
run.result = result;
|
|
435
419
|
run.updatedAt = Date.now();
|
|
436
420
|
return run;
|
|
437
|
-
});
|
|
421
|
+
}, true);
|
|
438
422
|
}
|
|
439
423
|
hash(value) { return createHash('sha256').update(JSON.stringify(value)).digest('hex'); }
|
|
440
424
|
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { atomicIntegrationWrite } from '../agent/integrationJournal.js';
|
|
5
|
+
const digest = (value) => createHash('sha256').update(value).digest('hex');
|
|
6
|
+
/** Admission limits protect against new work, not against recording the outcome
|
|
7
|
+
* of work already admitted. Domain stores explicitly mark bounded control writes;
|
|
8
|
+
* ordinary content edits/new requests still need capacity. No history is erased. */
|
|
9
|
+
export class JsonStorageBudget {
|
|
10
|
+
file;
|
|
11
|
+
label;
|
|
12
|
+
full;
|
|
13
|
+
bytes = 0;
|
|
14
|
+
fingerprint;
|
|
15
|
+
warning = '';
|
|
16
|
+
uncertain = false;
|
|
17
|
+
maxBytes;
|
|
18
|
+
warnPercent;
|
|
19
|
+
constructor(file, label, setting, fallback, full, options = {}) {
|
|
20
|
+
this.file = file;
|
|
21
|
+
this.label = label;
|
|
22
|
+
this.full = full;
|
|
23
|
+
this.maxBytes = options.maxBytes ?? Number(process.env[setting] ?? fallback);
|
|
24
|
+
this.warnPercent = options.warnPercent ?? Number(process.env.TERMDOCK_STORAGE_WARN_PERCENT ?? 80);
|
|
25
|
+
if (!Number.isSafeInteger(this.maxBytes) || this.maxBytes < 1 || !Number.isSafeInteger(this.warnPercent) || this.warnPercent < 1 || this.warnPercent > 100)
|
|
26
|
+
throw new Error(`Invalid storage configuration: ${setting} / TERMDOCK_STORAGE_WARN_PERCENT`);
|
|
27
|
+
try {
|
|
28
|
+
const raw = fs.readFileSync(file);
|
|
29
|
+
this.bytes = raw.length;
|
|
30
|
+
this.fingerprint = digest(raw);
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
if (error.code !== 'ENOENT')
|
|
34
|
+
throw error;
|
|
35
|
+
}
|
|
36
|
+
this.report();
|
|
37
|
+
}
|
|
38
|
+
status() {
|
|
39
|
+
return { store: this.label, bytes: this.bytes, max_bytes: this.maxBytes, warning_percent: this.warnPercent,
|
|
40
|
+
state: this.bytes >= this.maxBytes ? 'full' : this.bytes >= this.maxBytes * this.warnPercent / 100 ? 'warning' : 'ok',
|
|
41
|
+
control_writes_use_admission_budget: false, commit_unconfirmed: this.uncertain };
|
|
42
|
+
}
|
|
43
|
+
report() {
|
|
44
|
+
const state = this.status().state;
|
|
45
|
+
if (state !== 'ok' && state !== this.warning)
|
|
46
|
+
console.warn('[storage-capacity]', JSON.stringify(this.status()));
|
|
47
|
+
this.warning = state;
|
|
48
|
+
}
|
|
49
|
+
admit(serialized, control = false) {
|
|
50
|
+
if (this.uncertain)
|
|
51
|
+
throw new Error(`${this.label}: storage commit unconfirmed; reopen before writing`);
|
|
52
|
+
const bytes = Buffer.byteLength(serialized);
|
|
53
|
+
// Shrinking an already oversized legacy document must remain possible.
|
|
54
|
+
if (!control && bytes > this.maxBytes && bytes > this.bytes) {
|
|
55
|
+
if (this.warning !== 'rejected')
|
|
56
|
+
console.warn('[storage-capacity]', JSON.stringify({ ...this.status(), rejected_bytes: bytes }));
|
|
57
|
+
this.warning = 'rejected';
|
|
58
|
+
throw this.full();
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
committed(serialized) { this.bytes = Buffer.byteLength(serialized); this.fingerprint = digest(serialized); this.report(); }
|
|
62
|
+
failed() {
|
|
63
|
+
try {
|
|
64
|
+
this.uncertain = digest(fs.readFileSync(this.file)) !== this.fingerprint;
|
|
65
|
+
}
|
|
66
|
+
catch (readError) {
|
|
67
|
+
this.uncertain = readError.code !== 'ENOENT' || this.fingerprint !== undefined;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/** Confirm the exact already-written document without overwriting an
|
|
71
|
+
* uncertain commit or another writer's state. Used by durable receipt replay. */
|
|
72
|
+
reconfirm(serialized) {
|
|
73
|
+
const expected = Buffer.from(serialized), descriptor = fs.openSync(this.file, 'r');
|
|
74
|
+
try {
|
|
75
|
+
if (!fs.readFileSync(descriptor).equals(expected))
|
|
76
|
+
throw new Error(`${this.label}: persisted state changed; reopen before confirming`);
|
|
77
|
+
fs.fsyncSync(descriptor);
|
|
78
|
+
}
|
|
79
|
+
finally {
|
|
80
|
+
fs.closeSync(descriptor);
|
|
81
|
+
}
|
|
82
|
+
const directory = fs.openSync(path.dirname(this.file), 'r');
|
|
83
|
+
try {
|
|
84
|
+
fs.fsyncSync(directory);
|
|
85
|
+
}
|
|
86
|
+
finally {
|
|
87
|
+
fs.closeSync(directory);
|
|
88
|
+
}
|
|
89
|
+
if (!fs.readFileSync(this.file).equals(expected))
|
|
90
|
+
throw new Error(`${this.label}: persisted state changed; reopen before confirming`);
|
|
91
|
+
this.committed(serialized);
|
|
92
|
+
this.uncertain = false;
|
|
93
|
+
}
|
|
94
|
+
write(serialized, control = false) {
|
|
95
|
+
this.admit(serialized, control);
|
|
96
|
+
try {
|
|
97
|
+
atomicIntegrationWrite(this.file, fd => fs.writeFileSync(fd, serialized));
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
this.failed();
|
|
101
|
+
throw error;
|
|
102
|
+
}
|
|
103
|
+
this.committed(serialized);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -28,6 +28,11 @@ export function isProcessRunning(pid) {
|
|
|
28
28
|
return false;
|
|
29
29
|
}
|
|
30
30
|
}
|
|
31
|
+
export function readLocalApiCa(state, fallbackPath) {
|
|
32
|
+
if (state?.httpsCaPath)
|
|
33
|
+
return fs.readFileSync(state.httpsCaPath);
|
|
34
|
+
return fs.existsSync(fallbackPath) ? fs.readFileSync(fallbackPath) : undefined;
|
|
35
|
+
}
|
|
31
36
|
export function readJsonFile(filePath) {
|
|
32
37
|
try {
|
|
33
38
|
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|