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.
@@ -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.persist(true);
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.persist(true);
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.persist(true);
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.persist(true);
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.persist(true);
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.persist(true);
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
- const key = JSON.stringify([decoded.scope, consumer]);
1267
- (this.document.consumers ??= {})[key] = Math.max(this.document.consumers?.[key] ?? 0, decoded.sequence);
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, writeFileSync, mkdirSync, renameSync } from 'node:fs';
4
- import { dirname } from 'node:path';
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
- constructor(file) {
130
- this.file = file;
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
- transaction(mutate, origin) {
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
- if (Buffer.byteLength(serialized) > 64 * 1024 * 1024)
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>';