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
|
@@ -6,6 +6,7 @@ import path from 'node:path';
|
|
|
6
6
|
import crypto from 'node:crypto';
|
|
7
7
|
import { directLocalId, directRemoteAddress } from './collaborationDirectProtocol.js';
|
|
8
8
|
import { hasUnconfirmedTerminalWrite } from './collaborationProtocol.js';
|
|
9
|
+
import { JsonStorageBudget } from '../utils/jsonStorageBudget.js';
|
|
9
10
|
import { chatPresentation, validChatUIValues } from '../shared/chatPresentation.js';
|
|
10
11
|
import { COLLAB_NAME_FORBIDDEN, COLLAB_LIMITS, CollaborationError, STATUS_RANK, canonicalShortId, drawCollaborationId, newCollaborationId, resolveIdPrefix, validateExtras } from './collaborationProtocol.js';
|
|
11
12
|
function newerRevision(a, b) {
|
|
@@ -37,7 +38,7 @@ function pruneRoles(group, sessionIds) {
|
|
|
37
38
|
}
|
|
38
39
|
/** Public collaboration facts never expose legacy read guesses. */
|
|
39
40
|
export function terminalMessage(message, raw = false) {
|
|
40
|
-
const { readAt: _readAt, readSource: _readSource, ...body } = message;
|
|
41
|
+
const { readAt: _readAt, readSource: _readSource, retainUntil: _retainUntil, ...body } = message;
|
|
41
42
|
const legacyConsumption = message.deliverySource === 'consumer_read';
|
|
42
43
|
return { ...body, status: legacyConsumption ? 'pending' : message.status === 'read' ? 'delivered' : message.status,
|
|
43
44
|
deliveredAt: legacyConsumption ? null : message.deliveredAt,
|
|
@@ -87,12 +88,20 @@ export class CollaborationStore {
|
|
|
87
88
|
groupCreationAvailable = true;
|
|
88
89
|
groupRenamesAvailable = true;
|
|
89
90
|
groupArchivesAvailable = true;
|
|
91
|
+
storage;
|
|
92
|
+
committedMessageIds = new Set();
|
|
93
|
+
committedPendingIds = new Set();
|
|
94
|
+
historyBytes;
|
|
90
95
|
/** `drawId` is the random source behind newCollaborationId. It is a
|
|
91
96
|
* constructor seam so tests can force a collision and prove the guard
|
|
92
97
|
* below actually skips it; production always uses the CSPRNG default. */
|
|
93
|
-
constructor(filePath, drawId = drawCollaborationId) {
|
|
98
|
+
constructor(filePath, drawId = drawCollaborationId, options = {}) {
|
|
94
99
|
this.filePath = filePath;
|
|
95
100
|
this.drawId = drawId;
|
|
101
|
+
this.storage = new JsonStorageBudget(filePath, 'collaboration-messages', 'TERMDOCK_MESSAGE_STORAGE_MAX_BYTES', 512 * 1024 * 1024, () => new CollaborationError('MESSAGE_STORAGE_FULL', '消息新增内容达到容量预算;待投递消息、投递确认和消费水位保留,请消费已有消息或调整 TERMDOCK_MESSAGE_STORAGE_MAX_BYTES', 503), options);
|
|
102
|
+
this.historyBytes = options.historyBytes ?? Number(process.env.TERMDOCK_MESSAGE_HISTORY_BYTES ?? 64 * 1024 * 1024);
|
|
103
|
+
if (!Number.isSafeInteger(this.historyBytes) || this.historyBytes < 1)
|
|
104
|
+
throw new CollaborationError('INVALID_STORAGE_CONFIG', 'TERMDOCK_MESSAGE_HISTORY_BYTES must be a positive safe integer');
|
|
96
105
|
let initializeChatAttention = false;
|
|
97
106
|
try {
|
|
98
107
|
const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
@@ -183,11 +192,14 @@ export class CollaborationStore {
|
|
|
183
192
|
&& message.toPrincipalId && !chatPresentation(message.content, message.metadata).ui?.streaming).map(message => message.id);
|
|
184
193
|
}
|
|
185
194
|
this.persistedDocument = JSON.stringify(this.document);
|
|
195
|
+
this.committedMessageIds = new Set(this.document.messages.map(message => message.id));
|
|
196
|
+
this.committedPendingIds = new Set(this.document.messages.filter(message => message.status === 'pending').map(message => message.id));
|
|
186
197
|
// Persist before serving any indices so an idle restart cannot move the
|
|
187
198
|
// baseline forward and swallow replies received since the first migration.
|
|
188
199
|
if (initializeChatAttention)
|
|
189
200
|
this.persist(true);
|
|
190
201
|
}
|
|
202
|
+
storageStatus() { return { ...this.storage.status(), history_target_bytes: this.historyBytes }; }
|
|
191
203
|
list() {
|
|
192
204
|
return this.document.groups.filter((group) => !group.deleted && !group.sessionChatSessionId).sort((a, b) => b.updatedAt - a.updatedAt);
|
|
193
205
|
}
|
|
@@ -282,7 +294,7 @@ export class CollaborationStore {
|
|
|
282
294
|
throw new CollaborationError('GROUP_CREATE_TARGET_UNAVAILABLE', 'Original local group was deleted or changed; this key cannot create a replacement', 409);
|
|
283
295
|
// A previous directory-fsync failure may have committed the key without
|
|
284
296
|
// confirming durability. Reconfirm the current group+key before ACK.
|
|
285
|
-
this.
|
|
297
|
+
this.persistLocalGroup();
|
|
286
298
|
return structuredClone(previous.group);
|
|
287
299
|
}
|
|
288
300
|
if (Object.keys(this.document.localGroupRequests ?? {}).length >= 2000)
|
|
@@ -294,9 +306,43 @@ export class CollaborationStore {
|
|
|
294
306
|
} };
|
|
295
307
|
// persist restores the last committed document on failure. A post-rename
|
|
296
308
|
// fsync failure retains the committed request so retries cannot duplicate.
|
|
297
|
-
this.
|
|
309
|
+
this.persistLocalGroup();
|
|
298
310
|
return structuredClone(group);
|
|
299
311
|
}
|
|
312
|
+
persistLocalGroup() {
|
|
313
|
+
const proposed = this.document;
|
|
314
|
+
try {
|
|
315
|
+
if (!this.storage.status().commit_unconfirmed) {
|
|
316
|
+
this.persist(true);
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
const serialized = JSON.stringify(proposed);
|
|
320
|
+
this.storage.reconfirm(serialized);
|
|
321
|
+
this.persistedDocument = serialized;
|
|
322
|
+
this.committedMessageIds = new Set(proposed.messages.map(message => message.id));
|
|
323
|
+
this.committedPendingIds = new Set(proposed.messages.filter(message => message.status === 'pending').map(message => message.id));
|
|
324
|
+
for (const listener of this.changeListeners) {
|
|
325
|
+
try {
|
|
326
|
+
listener();
|
|
327
|
+
}
|
|
328
|
+
catch { /* A durable receipt can be replayed again. */ }
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
catch (error) {
|
|
332
|
+
// Keep a post-rename request visible for idempotent retry, but never
|
|
333
|
+
// report success or admit another write before its durability is confirmed.
|
|
334
|
+
this.document = JSON.parse(this.persistedDocument);
|
|
335
|
+
try {
|
|
336
|
+
const serialized = JSON.stringify(proposed);
|
|
337
|
+
if (fs.readFileSync(this.filePath, 'utf8') === serialized) {
|
|
338
|
+
this.document = proposed;
|
|
339
|
+
this.persistedDocument = serialized;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
catch { /* Retain the normal rollback when the replacement is not proven. */ }
|
|
343
|
+
throw error;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
300
346
|
/** Exact local lookup; no prefix/name resolution or private group configuration. */
|
|
301
347
|
getLocalGroupMetadata(id) {
|
|
302
348
|
if (typeof id !== 'string' || !/^[a-zA-Z0-9._-]{1,80}$/.test(id))
|
|
@@ -335,7 +381,7 @@ export class CollaborationStore {
|
|
|
335
381
|
if (!target || target.deleted || target.federated)
|
|
336
382
|
throw new CollaborationError('GROUP_ARCHIVE_TARGET_UNAVAILABLE', 'Original local archive target is unavailable; no replacement is created', 409);
|
|
337
383
|
const group = this.getLocalGroupMetadata(id);
|
|
338
|
-
this.
|
|
384
|
+
this.persistLocalGroup();
|
|
339
385
|
return { group, applied: { ...previous.applied }, replayed: true };
|
|
340
386
|
}
|
|
341
387
|
const current = this.getLocalGroupMetadata(id);
|
|
@@ -354,7 +400,7 @@ export class CollaborationStore {
|
|
|
354
400
|
this.document = { ...this.document, groups: this.document.groups.map(group => group.id === id
|
|
355
401
|
? { ...group, updatedAt, ...(archivedAt !== undefined ? { archivedAt } : { archivedAt: undefined }) } : group),
|
|
356
402
|
localGroupArchives: { ...this.document.localGroupArchives, [requestKey]: { hash, applied } } };
|
|
357
|
-
this.
|
|
403
|
+
this.persistLocalGroup();
|
|
358
404
|
return { group: { ...applied }, applied: { ...applied }, replayed: false };
|
|
359
405
|
}
|
|
360
406
|
/** Display metadata and its original receipt share a durable commit. */
|
|
@@ -378,7 +424,7 @@ export class CollaborationStore {
|
|
|
378
424
|
if (!target || target.deleted || target.federated)
|
|
379
425
|
throw new CollaborationError('GROUP_RENAME_TARGET_UNAVAILABLE', 'Original local rename target is unavailable; no replacement is created', 409);
|
|
380
426
|
const group = this.getLocalGroupMetadata(id);
|
|
381
|
-
this.
|
|
427
|
+
this.persistLocalGroup();
|
|
382
428
|
return { group, applied: { ...previous.applied }, replayed: true };
|
|
383
429
|
}
|
|
384
430
|
const current = this.getLocalGroupMetadata(id);
|
|
@@ -393,7 +439,7 @@ export class CollaborationStore {
|
|
|
393
439
|
const applied = { id, name, updatedAt, ...(target.archivedAt !== undefined ? { archivedAt: target.archivedAt } : {}) };
|
|
394
440
|
this.document = { ...this.document, groups: this.document.groups.map(group => group.id === id ? { ...group, name, updatedAt } : group),
|
|
395
441
|
localGroupRenames: { ...this.document.localGroupRenames, [requestKey]: { hash, applied } } };
|
|
396
|
-
this.
|
|
442
|
+
this.persistLocalGroup();
|
|
397
443
|
return { group: { ...applied }, applied: { ...applied }, replayed: false };
|
|
398
444
|
}
|
|
399
445
|
setMemberLaunchProfile(groupId, sessionId, profile) {
|
|
@@ -799,7 +845,7 @@ export class CollaborationStore {
|
|
|
799
845
|
reply.metadata = { ...reply.metadata, ...extras.metadata, openui: { ...extras.metadata?.openui, version: 2, patch: false, revision: Number(revision) + 1 } };
|
|
800
846
|
reply.responseKind = next.streaming ? 'progress' : 'result';
|
|
801
847
|
(this.document.idempotency ??= {})[key] = { hash, ids: [reply.id], expiresAt: Date.now() + COLLAB_LIMITS.idempotency_retention_ms };
|
|
802
|
-
this.persist(true);
|
|
848
|
+
this.persist(true, true);
|
|
803
849
|
return reply;
|
|
804
850
|
}
|
|
805
851
|
/** References only: callers can select a bounded page before projecting bodies. */
|
|
@@ -921,7 +967,7 @@ export class CollaborationStore {
|
|
|
921
967
|
const diagnostic = this.diagnostic(messageId);
|
|
922
968
|
if (diagnostic)
|
|
923
969
|
this.recordTransport(messageId, { ...diagnostic, next_retry_at: null });
|
|
924
|
-
this.persist();
|
|
970
|
+
this.persist(true);
|
|
925
971
|
}
|
|
926
972
|
markDelivered(messageIds) {
|
|
927
973
|
return this.updateStatus(messageIds, 'delivered');
|
|
@@ -948,7 +994,7 @@ export class CollaborationStore {
|
|
|
948
994
|
return updated;
|
|
949
995
|
});
|
|
950
996
|
if (changed.length > 0)
|
|
951
|
-
this.persist();
|
|
997
|
+
this.persist(true);
|
|
952
998
|
return changed;
|
|
953
999
|
}
|
|
954
1000
|
federationSnapshot() {
|
|
@@ -1259,17 +1305,22 @@ export class CollaborationStore {
|
|
|
1259
1305
|
commitCursor(sessionId, cursor, consumer) {
|
|
1260
1306
|
if (!consumer || consumer.length > 128)
|
|
1261
1307
|
throw new CollaborationError('INVALID_CONSUMER', 'consumer must contain 1–128 characters');
|
|
1308
|
+
let key, sequence;
|
|
1262
1309
|
try {
|
|
1263
1310
|
const decoded = JSON.parse(Buffer.from(cursor, 'base64url').toString());
|
|
1264
1311
|
if (JSON.parse(decoded.scope)[0] !== sessionId || !Number.isSafeInteger(decoded.sequence) || decoded.sequence < 0 || decoded.sequence > (this.document.sequence ?? 0))
|
|
1265
1312
|
throw new Error();
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
this.persist();
|
|
1313
|
+
key = JSON.stringify([decoded.scope, consumer]);
|
|
1314
|
+
sequence = decoded.sequence;
|
|
1269
1315
|
}
|
|
1270
1316
|
catch {
|
|
1271
1317
|
throw new CollaborationError('INVALID_CURSOR', 'Invalid consumption cursor');
|
|
1272
1318
|
}
|
|
1319
|
+
const previous = this.document.consumers?.[key];
|
|
1320
|
+
if (previous !== undefined && sequence <= previous)
|
|
1321
|
+
return;
|
|
1322
|
+
(this.document.consumers ??= {})[key] = sequence;
|
|
1323
|
+
this.persist(true);
|
|
1273
1324
|
}
|
|
1274
1325
|
acceptFragment(fragment) {
|
|
1275
1326
|
const group = this.getGroup(fragment.group_id);
|
|
@@ -1309,36 +1360,55 @@ export class CollaborationStore {
|
|
|
1309
1360
|
this.persist();
|
|
1310
1361
|
return { message_id: fragment.message_id, received, total: state.total, complete: received === state.total };
|
|
1311
1362
|
}
|
|
1312
|
-
persist(durable = false) {
|
|
1363
|
+
persist(durable = false, contentGrowth = false) {
|
|
1313
1364
|
const now = Date.now();
|
|
1314
|
-
for (const [key, record] of Object.entries(this.document.idempotency ?? {}))
|
|
1315
|
-
if (record.expiresAt <= now)
|
|
1316
|
-
delete this.document.idempotency[key];
|
|
1317
|
-
const protectedIds = new Set(Object.values(this.document.idempotency ?? {}).flatMap((record) => record.ids));
|
|
1318
|
-
const history = this.document.messages.filter((message) => message.status !== 'pending' && !protectedIds.has(message.id)).slice(-MAX_MESSAGES);
|
|
1319
|
-
const retained = new Set(history.map((message) => message.id));
|
|
1320
|
-
this.document.messages = this.document.messages.filter((message) => {
|
|
1321
|
-
if (message.status === 'pending' || protectedIds.has(message.id) || retained.has(message.id))
|
|
1322
|
-
return true;
|
|
1323
|
-
(this.document.prunedThrough ??= {})[message.toSessionId] = Math.max(this.document.prunedThrough?.[message.toSessionId] ?? 0, message.sequence ?? 0);
|
|
1324
|
-
if (message.fromSessionId)
|
|
1325
|
-
(this.document.prunedSenders ??= {})[message.fromSessionId] = true;
|
|
1326
|
-
delete this.document.transport?.[message.id];
|
|
1327
|
-
return false;
|
|
1328
|
-
});
|
|
1329
|
-
const messageIds = new Set(this.document.messages.map(message => message.id));
|
|
1330
|
-
this.document.sessionChatAttentionBaseline = this.document.sessionChatAttentionBaseline?.filter(id => messageIds.has(id));
|
|
1331
|
-
const chatIds = new Set(this.document.groups.filter(group => !group.deleted && group.sessionChatSessionId).map(group => group.id));
|
|
1332
|
-
for (const [id, ids] of Object.entries(this.document.sessionChatViewed ?? {})) {
|
|
1333
|
-
if (!chatIds.has(id))
|
|
1334
|
-
delete this.document.sessionChatViewed[id];
|
|
1335
|
-
else
|
|
1336
|
-
this.document.sessionChatViewed[id] = ids.filter(messageId => messageIds.has(messageId));
|
|
1337
|
-
}
|
|
1338
|
-
const temporaryPath = `${this.filePath}.${process.pid}.tmp`;
|
|
1339
1365
|
try {
|
|
1366
|
+
for (const [key, record] of Object.entries(this.document.idempotency ?? {}))
|
|
1367
|
+
if (record.expiresAt <= now)
|
|
1368
|
+
delete this.document.idempotency[key];
|
|
1369
|
+
const protectedIds = new Set(Object.values(this.document.idempotency ?? {}).flatMap((record) => record.ids));
|
|
1370
|
+
for (const message of this.document.messages) {
|
|
1371
|
+
if (message.status !== 'pending' && this.committedPendingIds.has(message.id))
|
|
1372
|
+
message.retainUntil = now + 5 * 60_000;
|
|
1373
|
+
if ((message.retainUntil ?? 0) > now)
|
|
1374
|
+
protectedIds.add(message.id);
|
|
1375
|
+
}
|
|
1376
|
+
const history = this.document.messages.filter((message) => message.status !== 'pending' && !protectedIds.has(message.id)).slice(-MAX_MESSAGES);
|
|
1377
|
+
const retained = new Set(history.map((message) => message.id));
|
|
1378
|
+
const sizes = new Map(this.document.messages.map(message => [message.id, Buffer.byteLength(JSON.stringify(message)) + 1]));
|
|
1379
|
+
let messageBytes = this.document.messages.reduce((sum, message) => sum + (message.status === 'pending' || protectedIds.has(message.id) || retained.has(message.id) ? sizes.get(message.id) : 0), 0);
|
|
1380
|
+
// Existing count retention also needs a byte target. Pending messages and
|
|
1381
|
+
// unexpired deduplication receipts are never sacrificed to meet it.
|
|
1382
|
+
for (const message of history)
|
|
1383
|
+
if (messageBytes > this.historyBytes) {
|
|
1384
|
+
retained.delete(message.id);
|
|
1385
|
+
messageBytes -= sizes.get(message.id);
|
|
1386
|
+
}
|
|
1387
|
+
this.document.messages = this.document.messages.filter((message) => {
|
|
1388
|
+
if (message.status === 'pending' || protectedIds.has(message.id) || retained.has(message.id))
|
|
1389
|
+
return true;
|
|
1390
|
+
(this.document.prunedThrough ??= {})[message.toSessionId] = Math.max(this.document.prunedThrough?.[message.toSessionId] ?? 0, message.sequence ?? 0);
|
|
1391
|
+
if (message.fromSessionId)
|
|
1392
|
+
(this.document.prunedSenders ??= {})[message.fromSessionId] = true;
|
|
1393
|
+
delete this.document.transport?.[message.id];
|
|
1394
|
+
return false;
|
|
1395
|
+
});
|
|
1396
|
+
const messageIds = new Set(this.document.messages.map(message => message.id));
|
|
1397
|
+
this.document.sessionChatAttentionBaseline = this.document.sessionChatAttentionBaseline?.filter(id => messageIds.has(id));
|
|
1398
|
+
const chatIds = new Set(this.document.groups.filter(group => !group.deleted && group.sessionChatSessionId).map(group => group.id));
|
|
1399
|
+
for (const [id, ids] of Object.entries(this.document.sessionChatViewed ?? {})) {
|
|
1400
|
+
if (!chatIds.has(id))
|
|
1401
|
+
delete this.document.sessionChatViewed[id];
|
|
1402
|
+
else
|
|
1403
|
+
this.document.sessionChatViewed[id] = ids.filter(messageId => messageIds.has(messageId));
|
|
1404
|
+
}
|
|
1405
|
+
const temporaryPath = `${this.filePath}.${process.pid}.tmp`;
|
|
1340
1406
|
fs.mkdirSync(path.dirname(this.filePath), { recursive: true });
|
|
1341
1407
|
const serialized = JSON.stringify(this.document);
|
|
1408
|
+
const newMessage = this.document.messages.some(message => !this.committedMessageIds.has(message.id));
|
|
1409
|
+
this.storage.admit(serialized, !newMessage && !contentGrowth);
|
|
1410
|
+
// Enqueue success must survive a crash before a delivery worker runs.
|
|
1411
|
+
durable ||= newMessage || contentGrowth;
|
|
1342
1412
|
const descriptor = fs.openSync(temporaryPath, 'w', 0o600);
|
|
1343
1413
|
try {
|
|
1344
1414
|
fs.writeFileSync(descriptor, serialized);
|
|
@@ -1349,7 +1419,6 @@ export class CollaborationStore {
|
|
|
1349
1419
|
fs.closeSync(descriptor);
|
|
1350
1420
|
}
|
|
1351
1421
|
fs.renameSync(temporaryPath, this.filePath);
|
|
1352
|
-
this.persistedDocument = serialized;
|
|
1353
1422
|
if (durable) {
|
|
1354
1423
|
const directory = fs.openSync(path.dirname(this.filePath), 'r');
|
|
1355
1424
|
try {
|
|
@@ -1359,6 +1428,10 @@ export class CollaborationStore {
|
|
|
1359
1428
|
fs.closeSync(directory);
|
|
1360
1429
|
}
|
|
1361
1430
|
}
|
|
1431
|
+
this.persistedDocument = serialized;
|
|
1432
|
+
this.storage.committed(serialized);
|
|
1433
|
+
this.committedMessageIds = new Set(this.document.messages.map(message => message.id));
|
|
1434
|
+
this.committedPendingIds = new Set(this.document.messages.filter(message => message.status === 'pending').map(message => message.id));
|
|
1362
1435
|
for (const listener of this.changeListeners) {
|
|
1363
1436
|
try {
|
|
1364
1437
|
listener();
|
|
@@ -1367,6 +1440,7 @@ export class CollaborationStore {
|
|
|
1367
1440
|
}
|
|
1368
1441
|
}
|
|
1369
1442
|
catch (error) {
|
|
1443
|
+
this.storage.failed();
|
|
1370
1444
|
this.document = JSON.parse(this.persistedDocument);
|
|
1371
1445
|
throw error;
|
|
1372
1446
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { GOAL_EXPERIENCE_GUIDE } from './collaborationTaskGuidance.js';
|
|
2
2
|
import { randomBytes, createHash } from 'node:crypto';
|
|
3
|
-
import { readFileSync
|
|
4
|
-
import {
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
import { JsonStorageBudget } from '../utils/jsonStorageBudget.js';
|
|
5
5
|
import { collaborationResultPresentation } from './collaborationResultPresentation.js';
|
|
6
6
|
import { taskExecution, taskPurpose } from './collaborationTaskPurpose.js';
|
|
7
7
|
import { CollaborationError } from './collaborationProtocol.js';
|
|
@@ -121,13 +121,13 @@ function validateSnapshot(task) {
|
|
|
121
121
|
}
|
|
122
122
|
/** Atomic document writes keep decisions and their outgoing messages in one transaction. */
|
|
123
123
|
export class CollaborationTaskStore {
|
|
124
|
-
file;
|
|
125
124
|
listeners = new Set();
|
|
126
125
|
subscribe(listener) { this.listeners.add(listener); return () => { this.listeners.delete(listener); }; }
|
|
127
126
|
snapshot() { return structuredClone(this.document.tasks); }
|
|
128
127
|
document = { version: 1, tasks: [], outbox: [], requests: {} };
|
|
129
|
-
|
|
130
|
-
|
|
128
|
+
storage;
|
|
129
|
+
constructor(file, options = {}) {
|
|
130
|
+
this.storage = new JsonStorageBudget(file, 'collaboration-tasks', 'TERMDOCK_TASK_STORAGE_MAX_BYTES', 64 * 1024 * 1024, () => new CollaborationError('TASK_STORAGE_FULL', '任务新增内容达到容量预算;已有任务仍可确认、收尾或关闭,管理员可配置 TERMDOCK_TASK_STORAGE_MAX_BYTES', 409), options);
|
|
131
131
|
try {
|
|
132
132
|
const doc = JSON.parse(readFileSync(file, 'utf8'));
|
|
133
133
|
if (doc.version !== 1 || !Array.isArray(doc.tasks) || !Array.isArray(doc.outbox) || !doc.requests)
|
|
@@ -139,7 +139,8 @@ export class CollaborationTaskStore {
|
|
|
139
139
|
throw error;
|
|
140
140
|
}
|
|
141
141
|
}
|
|
142
|
-
|
|
142
|
+
storageStatus() { return this.storage.status(); }
|
|
143
|
+
transaction(mutate, origin, control = false) {
|
|
143
144
|
const next = structuredClone(this.document);
|
|
144
145
|
const result = mutate(next);
|
|
145
146
|
if (origin) {
|
|
@@ -154,12 +155,7 @@ export class CollaborationTaskStore {
|
|
|
154
155
|
if (next.tasks.some(task => Buffer.byteLength(JSON.stringify(task)) > 512_000))
|
|
155
156
|
throw new CollaborationError('TASK_HISTORY_LIMIT', '此任务记录达到 500 KiB,请建立后续任务并保留本任务为历史', 409);
|
|
156
157
|
const serialized = JSON.stringify(next);
|
|
157
|
-
|
|
158
|
-
throw new CollaborationError('TASK_STORAGE_FULL', '任务记录已达到保存上限,请导出历史后整理', 409);
|
|
159
|
-
mkdirSync(dirname(this.file), { recursive: true, mode: 0o700 });
|
|
160
|
-
const temporary = `${this.file}.${process.pid}.tmp`;
|
|
161
|
-
writeFileSync(temporary, serialized, { mode: 0o600 });
|
|
162
|
-
renameSync(temporary, this.file);
|
|
158
|
+
this.storage.write(serialized, control);
|
|
163
159
|
this.document = next;
|
|
164
160
|
for (const listener of this.listeners) {
|
|
165
161
|
try {
|
|
@@ -679,7 +675,8 @@ export class CollaborationTaskStore {
|
|
|
679
675
|
delete task.automationIssue;
|
|
680
676
|
doc.requests[request.key] = { hash: request.hash, taskId, eventId: task.events.at(-1)?.id };
|
|
681
677
|
return task;
|
|
682
|
-
}, origin)
|
|
678
|
+
}, origin, ['close', 'pause', 'accept', 'review'].includes(input.kind)
|
|
679
|
+
|| input.kind === 'report' && ['ack', 'complete', 'failed', 'blocked'].includes(input.status ?? ''));
|
|
683
680
|
}
|
|
684
681
|
touchParent(doc, task, content) {
|
|
685
682
|
const root = task.workflow?.rootTaskId ? doc.tasks.find(t => t.id === task.workflow.rootTaskId) : undefined;
|
|
@@ -895,7 +892,7 @@ export class CollaborationTaskStore {
|
|
|
895
892
|
&& !doc.outbox.some(o => o.taskId === task.id && o.lastError)
|
|
896
893
|
&& !task.deliveries.some(d => d.attemptId === task.activeAttemptId && ['failed', 'expired'].includes(d.status)))
|
|
897
894
|
delete task.automationIssue;
|
|
898
|
-
});
|
|
895
|
+
}, undefined, true);
|
|
899
896
|
}
|
|
900
897
|
failed(outboxId, error) {
|
|
901
898
|
const current = this.document.outbox.find(o => o.id === outboxId);
|
|
@@ -925,6 +922,6 @@ export class CollaborationTaskStore {
|
|
|
925
922
|
this.touchParent(doc, task, task.automationIssue);
|
|
926
923
|
}
|
|
927
924
|
}
|
|
928
|
-
});
|
|
925
|
+
}, undefined, true);
|
|
929
926
|
}
|
|
930
927
|
}
|
|
@@ -8,6 +8,12 @@ import { COLLAB_HELP, executeCollaborationCommand, duration } from './collaborat
|
|
|
8
8
|
import { getTermdockVersion } from '../utils/version.js';
|
|
9
9
|
export const INTEGRATION_HELP = `td integration — local scoped integration administration
|
|
10
10
|
groups | list
|
|
11
|
+
storage (read event capacity, warnings and metadata usage; local administrator only)
|
|
12
|
+
consumers (read confirmation watermarks, lag and retirement state)
|
|
13
|
+
consumer retire --principal <id> --consumer <name> --expected-sequence <N> --idempotency-key <key>
|
|
14
|
+
Explicitly ends a consumer's retention protection; preserves its watermark.
|
|
15
|
+
Its old name can no longer subscribe/ACK. Reconcile before using a new name.
|
|
16
|
+
Never retire a slow/idle consumer merely because its last ACK is old.
|
|
11
17
|
group get <exact-group-id>
|
|
12
18
|
group rename <exact-group-id> --name <name> --expected-updated-at <N> --idempotency-key <key>
|
|
13
19
|
Administrator only; minimum CLI/running service 1.4.319;
|
|
@@ -154,6 +160,28 @@ export async function runIntegrationAdmin(argv, port, adminToken, write) {
|
|
|
154
160
|
return 0;
|
|
155
161
|
}
|
|
156
162
|
const socketPath = integrationSocketPath(port);
|
|
163
|
+
if (action === 'storage' || action === 'consumers') {
|
|
164
|
+
if (argv.length !== 1)
|
|
165
|
+
throw new Error('Usage: td integration storage | consumers');
|
|
166
|
+
write(JSON.stringify(parsed(await request(socketPath, 'GET', `/admin/${action}`, undefined, headers(undefined, adminToken)))));
|
|
167
|
+
return 0;
|
|
168
|
+
}
|
|
169
|
+
if (action === 'consumer') {
|
|
170
|
+
const args = {};
|
|
171
|
+
if (argv[1] !== 'retire')
|
|
172
|
+
throw new Error('Usage: td integration consumer retire --principal <id> --consumer <name> --expected-sequence <N> --idempotency-key <key>');
|
|
173
|
+
for (let index = 2; index < argv.length; index += 2) {
|
|
174
|
+
if (!['--principal', '--consumer', '--expected-sequence', '--idempotency-key'].includes(argv[index]) || !argv[index + 1] || Object.hasOwn(args, argv[index]))
|
|
175
|
+
throw new Error('Invalid consumer retirement arguments');
|
|
176
|
+
args[argv[index]] = argv[index + 1];
|
|
177
|
+
}
|
|
178
|
+
if (Object.keys(args).length !== 4 || !/^(0|[1-9][0-9]*)$/.test(args['--expected-sequence']))
|
|
179
|
+
throw new Error('Consumer retirement requires all four arguments and an integer sequence');
|
|
180
|
+
write(JSON.stringify(parsed(await request(socketPath, 'POST', '/admin/consumers/retire', {
|
|
181
|
+
principal_id: args['--principal'], consumer: args['--consumer'], expected_sequence: Number(args['--expected-sequence']), idempotency_key: args['--idempotency-key'],
|
|
182
|
+
}, headers(undefined, adminToken)))));
|
|
183
|
+
return 0;
|
|
184
|
+
}
|
|
157
185
|
if (action === 'group' && ['get', 'rename', 'archive', 'unarchive'].includes(argv[1])) {
|
|
158
186
|
const operation = argv[1], id = argv[2], args = {};
|
|
159
187
|
const usage = 'Usage: td integration group get <exact-id> | group rename <exact-id> --name <name> --expected-updated-at <N> --idempotency-key <key> | group archive|unarchive <exact-id> --expected-updated-at <N> --idempotency-key <key>';
|