@pdpp/local-collector 0.1.0-beta.6 → 0.1.0-beta.8

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.
Files changed (26) hide show
  1. package/dist/local-collector/bin/pdpp-local-collector.js +580 -22
  2. package/dist/local-collector/src/runner.d.ts +1 -1
  3. package/dist/local-collector/src/runner.js +15 -1
  4. package/dist/polyfill-connectors/connectors/claude_code/index.js +85 -48
  5. package/dist/polyfill-connectors/connectors/codex/index.js +390 -108
  6. package/dist/polyfill-connectors/connectors/codex/parsers.js +5 -3
  7. package/dist/polyfill-connectors/src/bounded-file-preview.js +76 -0
  8. package/dist/polyfill-connectors/src/browser-handoff.js +38 -5
  9. package/dist/polyfill-connectors/src/collector-build-info.d.ts +8 -0
  10. package/dist/polyfill-connectors/src/collector-build-info.js +10 -0
  11. package/dist/polyfill-connectors/src/collector-runner.d.ts +54 -0
  12. package/dist/polyfill-connectors/src/collector-runner.js +250 -18
  13. package/dist/polyfill-connectors/src/connector-exit.js +62 -0
  14. package/dist/polyfill-connectors/src/connector-runtime-protocol.d.ts +41 -21
  15. package/dist/polyfill-connectors/src/connector-runtime.js +241 -30
  16. package/dist/polyfill-connectors/src/fingerprint-cursor.js +107 -0
  17. package/dist/polyfill-connectors/src/local-device-client.d.ts +17 -0
  18. package/dist/polyfill-connectors/src/local-device-client.js +69 -9
  19. package/dist/polyfill-connectors/src/local-device-outbox.d.ts +59 -0
  20. package/dist/polyfill-connectors/src/local-device-outbox.js +394 -5
  21. package/dist/polyfill-connectors/src/local-source-inventory.js +8 -1
  22. package/dist/polyfill-connectors/src/runner/index.d.ts +4 -3
  23. package/dist/polyfill-connectors/src/runner/index.js +4 -3
  24. package/dist/polyfill-connectors/src/safe-text-preview.js +13 -0
  25. package/dist/polyfill-connectors/src/static-secret-injection.js +155 -0
  26. package/package.json +1 -1
@@ -4,10 +4,12 @@ import { mkdirSync, writeFileSync } from "node:fs";
4
4
  import { delimiter, join } from "node:path";
5
5
  import { createInterface } from "node:readline";
6
6
  import { fileURLToPath } from "node:url";
7
+ import { buildAgentVersion } from "./collector-build-info.js";
7
8
  import { LocalDeviceClient, } from "./local-device-client.js";
8
9
  import { buildLocalDeviceRecordEnvelope, hashCanonicalJson, } from "./local-device-envelope.js";
9
10
  import { buildLocalDeviceOutboxId, LocalDeviceOutbox, } from "./local-device-outbox.js";
10
11
  import { assertPlacementOrThrow, COLLECTOR_RUNTIME_CAPABILITIES, } from "./runtime-capabilities.js";
12
+ const COLLECTOR_AGENT_VERSION = buildAgentVersion();
11
13
  export const COLLECTOR_STDERR_MAX_BYTES = 256 * 1024;
12
14
  const COLLECTOR_GAP_DETAILS_MAX_CHARS = 300;
13
15
  const KEYED_SECRET_RE = /\b(authorization|bearer|token|password|passwd|cookie|secret|otp|api[_-]?key)\b\s*[:=]\s*["']?[^"',\s}]+/gi;
@@ -17,7 +19,7 @@ const SCAN_BATCH_LIMIT_DETAIL_RE = /enqueued\s+\d+\s+batches\s+>=\s+(?:run batch
17
19
  const CONNECTOR_PROTOCOL_DEBUG_DIR_ENV = "PDPP_DEBUG_CONNECTOR_PROTOCOL_DIR";
18
20
  export const DEFAULT_COLLECTOR_OUTBOX_POLICY = Object.freeze({
19
21
  drainBatchSize: 4,
20
- leaseMs: 60_000,
22
+ leaseMs: 300_000,
21
23
  maxAttempts: 5,
22
24
  maxDrainDurationMs: 120_000,
23
25
  maxDrainIterations: 256,
@@ -25,6 +27,71 @@ export const DEFAULT_COLLECTOR_OUTBOX_POLICY = Object.freeze({
25
27
  maxQueueDepth: 10_000,
26
28
  retryBackoffMs: 30_000,
27
29
  });
30
+ export const DEFAULT_COLLECTOR_AUTO_PRUNE_POLICY = Object.freeze({
31
+ enabled: true,
32
+ keepRecentCount: 10_000,
33
+ });
34
+ export function resolveCollectorAutoPrunePolicy(override, env = process.env) {
35
+ const policy = { ...DEFAULT_COLLECTOR_AUTO_PRUNE_POLICY, ...(override ?? {}) };
36
+ const enabledRaw = env.PDPP_COLLECTOR_AUTO_PRUNE;
37
+ if (typeof enabledRaw === "string" && enabledRaw.trim() !== "") {
38
+ policy.enabled = !DISABLED_ENV_VALUES.has(enabledRaw.trim().toLowerCase());
39
+ }
40
+ const keepCount = parseNonNegativeInt(env.PDPP_COLLECTOR_AUTO_PRUNE_KEEP_COUNT);
41
+ if (keepCount !== null) {
42
+ policy.keepRecentCount = keepCount;
43
+ }
44
+ return policy;
45
+ }
46
+ const DISABLED_ENV_VALUES = new Set(["0", "false", "off", "no"]);
47
+ function parseNonNegativeInt(raw) {
48
+ if (typeof raw !== "string" || raw.trim() === "") {
49
+ return null;
50
+ }
51
+ const value = Number(raw.trim());
52
+ return Number.isSafeInteger(value) && value >= 0 ? value : null;
53
+ }
54
+ export function autoPruneSucceededOutbox(input) {
55
+ if (!input.policy.enabled) {
56
+ return { enabled: false, matched: 0, pruned: 0 };
57
+ }
58
+ const result = input.outbox.pruneSent({
59
+ dryRun: false,
60
+ keepCount: input.policy.keepRecentCount,
61
+ sourceInstanceId: input.sourceInstanceId,
62
+ });
63
+ return { enabled: true, matched: result.matched, pruned: result.pruned };
64
+ }
65
+ export const DEFAULT_COLLECTOR_AUTO_COMPACT_POLICY = Object.freeze({
66
+ enabled: true,
67
+ minReclaimableBytes: 512 * 1024 * 1024,
68
+ });
69
+ export function resolveCollectorAutoCompactPolicy(override, env = process.env) {
70
+ const policy = { ...DEFAULT_COLLECTOR_AUTO_COMPACT_POLICY, ...(override ?? {}) };
71
+ const enabledRaw = env.PDPP_COLLECTOR_AUTO_COMPACT;
72
+ if (typeof enabledRaw === "string" && enabledRaw.trim() !== "") {
73
+ policy.enabled = !DISABLED_ENV_VALUES.has(enabledRaw.trim().toLowerCase());
74
+ }
75
+ const minBytes = parseNonNegativeInt(env.PDPP_COLLECTOR_AUTO_COMPACT_MIN_RECLAIM_BYTES);
76
+ if (minBytes !== null) {
77
+ policy.minReclaimableBytes = minBytes;
78
+ }
79
+ return policy;
80
+ }
81
+ export function autoCompactOutboxIfBloated(input) {
82
+ if (!input.policy.enabled) {
83
+ return { compacted: false, enabled: false, reason: "disabled", reclaimedBytes: 0 };
84
+ }
85
+ const before = input.outbox.pageStats();
86
+ if (before.reclaimableBytes < input.policy.minReclaimableBytes) {
87
+ return { compacted: false, enabled: true, reason: "below_threshold", reclaimedBytes: 0 };
88
+ }
89
+ if (input.outbox.countNonSucceeded() > 0) {
90
+ return { compacted: false, enabled: true, reason: "lane_not_quiet", reclaimedBytes: 0 };
91
+ }
92
+ const result = input.outbox.compact();
93
+ return { compacted: true, enabled: true, reason: "compacted", reclaimedBytes: result.reclaimedBytes };
94
+ }
28
95
  const PACKAGE_ROOT = fileURLToPath(new URL("..", import.meta.url));
29
96
  const REPO_ROOT = join(PACKAGE_ROOT, "..", "..");
30
97
  export async function enrollCollector(config) {
@@ -34,6 +101,15 @@ export async function enrollCollector(config) {
34
101
  ...(config.deviceLabel ? { deviceLabel: config.deviceLabel } : {}),
35
102
  });
36
103
  }
104
+ export const COLLECTOR_COVERAGE_STATUSES = [
105
+ "collected",
106
+ "inventory_only",
107
+ "excluded",
108
+ "deferred",
109
+ "missing",
110
+ "unsupported",
111
+ "unaccounted",
112
+ ];
37
113
  export class CollectorStateReadError extends Error {
38
114
  constructor(message, cause) {
39
115
  super(message, { cause });
@@ -44,6 +120,8 @@ export async function runCollectorConnector(config) {
44
120
  throwIfAborted(config.abortSignal);
45
121
  const satisfiedBindings = assertPlacementOrThrow(config.connector, COLLECTOR_RUNTIME_CAPABILITIES);
46
122
  const policy = { ...DEFAULT_COLLECTOR_OUTBOX_POLICY, ...(config.outboxPolicy ?? {}) };
123
+ const autoPrunePolicy = resolveCollectorAutoPrunePolicy(config.autoPrune);
124
+ const autoCompactPolicy = resolveCollectorAutoCompactPolicy(config.autoCompact);
47
125
  const holderId = config.collectorHolderId ?? randomUUID();
48
126
  const outboxPath = config.outboxPath ?? config.queuePath;
49
127
  const outbox = new LocalDeviceOutbox({ path: outboxPath });
@@ -52,6 +130,7 @@ export async function runCollectorConnector(config) {
52
130
  deviceId: config.deviceId,
53
131
  deviceToken: config.deviceToken,
54
132
  });
133
+ let startingHeartbeatSent = false;
55
134
  try {
56
135
  const recoveredLeases = outbox.recoverExpiredLeases({ sourceInstanceId: config.sourceInstanceId });
57
136
  const preScanDrain = await drainCollectorOutbox({
@@ -65,6 +144,7 @@ export async function runCollectorConnector(config) {
65
144
  });
66
145
  const postDrainSummary = outbox.summary({ sourceInstanceId: config.sourceInstanceId });
67
146
  const skipResult = await maybeSkipScanForBacklog({
147
+ autoPrunePolicy,
68
148
  client,
69
149
  config,
70
150
  outbox,
@@ -83,6 +163,7 @@ export async function runCollectorConnector(config) {
83
163
  recordsPending: pendingOutboxWorkCount(postDrainSummary),
84
164
  });
85
165
  await client.heartbeat({
166
+ agent_version: COLLECTOR_AGENT_VERSION,
86
167
  connector_id: config.connector.connector_id,
87
168
  outbox: buildHeartbeatOutboxDiagnostics(postDrainSummary, {
88
169
  backlogOpen: countOpenBacklogGaps(outbox, config.sourceInstanceId),
@@ -91,6 +172,7 @@ export async function runCollectorConnector(config) {
91
172
  source_instance_id: config.sourceInstanceId,
92
173
  status: "starting",
93
174
  });
175
+ startingHeartbeatSent = true;
94
176
  const streamResult = await streamConnectorIntoOutbox({
95
177
  ...(config.abortSignal ? { abortSignal: config.abortSignal } : {}),
96
178
  batchSize: config.batchSize ?? 100,
@@ -130,11 +212,20 @@ export async function runCollectorConnector(config) {
130
212
  deferRecoveredGapCleanup: streamResult.scanBudgetExceeded,
131
213
  outbox,
132
214
  });
215
+ const prunedSent = autoPruneSucceededOutbox({
216
+ outbox,
217
+ policy: autoPrunePolicy,
218
+ sourceInstanceId: config.sourceInstanceId,
219
+ });
220
+ autoCompactOutboxIfBloated({ outbox, policy: autoCompactPolicy });
133
221
  const finalSummary = outbox.summary({ sourceInstanceId: config.sourceInstanceId });
134
222
  const recordsPending = pendingOutboxWorkCount(finalSummary);
135
223
  if (!checkpointResult.statePutFailed) {
224
+ const finalDeadLetterError = buildHeartbeatDeadLetterError(outbox, config.sourceInstanceId);
136
225
  await client.heartbeat({
226
+ agent_version: COLLECTOR_AGENT_VERSION,
137
227
  connector_id: config.connector.connector_id,
228
+ ...(finalDeadLetterError ? { last_error: finalDeadLetterError } : {}),
138
229
  outbox: buildHeartbeatOutboxDiagnostics(finalSummary, {
139
230
  backlogOpen: countOpenBacklogGaps(outbox, config.sourceInstanceId),
140
231
  }),
@@ -144,11 +235,13 @@ export async function runCollectorConnector(config) {
144
235
  });
145
236
  }
146
237
  return {
238
+ completeness: summarizeCollectorCompleteness(streamResult.coverageByStore),
147
239
  done,
148
240
  enqueuedBatches: enqueueResult.enqueuedBatches,
149
241
  flushedState: checkpointResult.flushedState,
150
242
  outboxSummary: finalSummary,
151
243
  priorState,
244
+ prunedSent,
152
245
  recordsQueued: enqueueResult.recordsQueued,
153
246
  recoveredLeases,
154
247
  satisfiedBindings,
@@ -159,10 +252,30 @@ export async function runCollectorConnector(config) {
159
252
  streamingBufferHighWaterMark: streamResult.bufferHighWaterMark,
160
253
  };
161
254
  }
255
+ catch (error) {
256
+ if (startingHeartbeatSent) {
257
+ await emitCorrectiveHeartbeatFromOutbox({ client, config, outbox, policy });
258
+ }
259
+ throw error;
260
+ }
162
261
  finally {
163
262
  outbox.close();
164
263
  }
165
264
  }
265
+ async function emitCorrectiveHeartbeatFromOutbox(input) {
266
+ const summary = input.outbox.summary({ sourceInstanceId: input.config.sourceInstanceId });
267
+ const deadLetterError = buildHeartbeatDeadLetterError(input.outbox, input.config.sourceInstanceId);
268
+ await safeHeartbeat(input.client, {
269
+ connector_id: input.config.connector.connector_id,
270
+ ...(deadLetterError ? { last_error: deadLetterError } : {}),
271
+ outbox: buildHeartbeatOutboxDiagnostics(summary, {
272
+ backlogOpen: countOpenBacklogGaps(input.outbox, input.config.sourceInstanceId),
273
+ }),
274
+ records_pending: pendingOutboxWorkCount(summary),
275
+ source_instance_id: input.config.sourceInstanceId,
276
+ status: heartbeatStatusForSummary(summary, input.policy),
277
+ });
278
+ }
166
279
  async function maybeSkipScanForBacklog(input) {
167
280
  if (!hasScanBlockingOutboxWork(input.outbox, input.config.sourceInstanceId, input.policy)) {
168
281
  return null;
@@ -180,6 +293,11 @@ async function maybeSkipScanForBacklog(input) {
180
293
  sourceInstanceId: input.config.sourceInstanceId,
181
294
  });
182
295
  }
296
+ const prunedSent = autoPruneSucceededOutbox({
297
+ outbox: input.outbox,
298
+ policy: input.autoPrunePolicy,
299
+ sourceInstanceId: input.config.sourceInstanceId,
300
+ });
183
301
  const summaryAfterGap = input.outbox.summary({ sourceInstanceId: input.config.sourceInstanceId });
184
302
  const recordsPendingAfterGap = pendingOutboxWorkCount(summaryAfterGap);
185
303
  await safeHeartbeat(input.client, {
@@ -192,11 +310,13 @@ async function maybeSkipScanForBacklog(input) {
192
310
  status: heartbeatStatusForSummary(summaryAfterGap, input.policy),
193
311
  });
194
312
  return {
313
+ completeness: null,
195
314
  done: null,
196
315
  enqueuedBatches: 0,
197
316
  flushedState: null,
198
317
  outboxSummary: summaryAfterGap,
199
318
  priorState: Object.freeze({}),
319
+ prunedSent,
200
320
  recordsQueued: 0,
201
321
  recoveredLeases: input.recoveredLeases,
202
322
  satisfiedBindings: input.satisfiedBindings,
@@ -218,6 +338,7 @@ async function readPriorStateOrBlock(input) {
218
338
  catch (error) {
219
339
  await safeHeartbeat(input.client, {
220
340
  connector_id: input.config.connector.connector_id,
341
+ last_error: { kind: "state_read_failed" },
221
342
  records_pending: input.recordsPending,
222
343
  source_instance_id: input.config.sourceInstanceId,
223
344
  status: "blocked",
@@ -225,6 +346,50 @@ async function readPriorStateOrBlock(input) {
225
346
  throw new CollectorStateReadError(`failed to read prior state for ${input.config.sourceInstanceId}: ${error instanceof Error ? error.message : String(error)}`, error);
226
347
  }
227
348
  }
349
+ const COVERAGE_DIAGNOSTICS_STREAM = "coverage_diagnostics";
350
+ function coverageEntryFromRecord(message) {
351
+ if (message.stream !== COVERAGE_DIAGNOSTICS_STREAM) {
352
+ return null;
353
+ }
354
+ const data = message.data;
355
+ const dataStore = isRecord(data) && typeof data.store === "string" && data.store ? data.store : null;
356
+ const keyStore = typeof message.key === "string" && message.key ? message.key : null;
357
+ const store = dataStore ?? keyStore;
358
+ if (!store) {
359
+ return null;
360
+ }
361
+ const rawStatus = isRecord(data) && typeof data.status === "string" ? data.status : null;
362
+ if (!rawStatus) {
363
+ return null;
364
+ }
365
+ const status = COLLECTOR_COVERAGE_STATUSES.includes(rawStatus)
366
+ ? rawStatus
367
+ : "unaccounted";
368
+ return { status, store };
369
+ }
370
+ export function summarizeCollectorCompleteness(coverageByStore) {
371
+ if (!coverageByStore || coverageByStore.size === 0) {
372
+ return null;
373
+ }
374
+ const countsByStatus = Object.fromEntries(COLLECTOR_COVERAGE_STATUSES.map((status) => [status, 0]));
375
+ const unaccountedStores = [];
376
+ const byStore = {};
377
+ for (const store of [...coverageByStore.keys()].sort()) {
378
+ const status = coverageByStore.get(store);
379
+ byStore[store] = status;
380
+ countsByStatus[status] += 1;
381
+ if (status === "unaccounted") {
382
+ unaccountedStores.push(store);
383
+ }
384
+ }
385
+ return {
386
+ byStore,
387
+ countsByStatus,
388
+ fullyAccounted: unaccountedStores.length === 0,
389
+ storeCount: coverageByStore.size,
390
+ unaccountedStores,
391
+ };
392
+ }
228
393
  async function streamConnectorIntoOutbox(input) {
229
394
  throwIfAborted(input.abortSignal);
230
395
  const child = spawnConnector(input.config.connector, {
@@ -242,6 +407,7 @@ async function streamConnectorIntoOutbox(input) {
242
407
  let enqueuedBatches = 0;
243
408
  let done = null;
244
409
  let scanBudgetExceeded = false;
410
+ let coverageByStore = null;
245
411
  const flushPendingBatch = () => {
246
412
  if (pendingRecords.length === 0) {
247
413
  return;
@@ -288,8 +454,17 @@ async function streamConnectorIntoOutbox(input) {
288
454
  scanBudgetExceeded,
289
455
  });
290
456
  };
457
+ const recordCoverageIfPresent = (message) => {
458
+ const entry = coverageEntryFromRecord(message);
459
+ if (!entry) {
460
+ return;
461
+ }
462
+ coverageByStore ??= new Map();
463
+ coverageByStore.set(entry.store, entry.status);
464
+ };
291
465
  const handleMessage = (message) => {
292
466
  if (message.type === "RECORD") {
467
+ recordCoverageIfPresent(message);
293
468
  pendingRecords.push(message);
294
469
  if (pendingRecords.length > bufferHighWaterMark) {
295
470
  bufferHighWaterMark = pendingRecords.length;
@@ -397,6 +572,7 @@ async function streamConnectorIntoOutbox(input) {
397
572
  return {
398
573
  bufferedState: Object.freeze(scanBudgetExceeded ? {} : { ...bufferedState }),
399
574
  bufferHighWaterMark,
575
+ coverageByStore,
400
576
  done: scanBudgetExceeded ? null : done,
401
577
  enqueuedBatches,
402
578
  recordsQueued,
@@ -567,7 +743,7 @@ async function recoverResolvedLocalCollectorGaps(input) {
567
743
  }
568
744
  async function safeHeartbeat(client, request) {
569
745
  try {
570
- await client.heartbeat(request);
746
+ await client.heartbeat({ agent_version: COLLECTOR_AGENT_VERSION, ...request });
571
747
  }
572
748
  catch {
573
749
  }
@@ -715,35 +891,53 @@ function hasCheckpointPredecessorBlockingWork(outbox, checkpoint) {
715
891
  async function drainClaimedOutboxItem(input, item, result, sentByKind) {
716
892
  throwIfAborted(input.abortSignal);
717
893
  try {
718
- await sendOutboxItem(input.client, item);
719
- input.outbox.acknowledge({ holder: input.holderId, id: item.id, leaseEpoch: item.lease_epoch });
894
+ const current = input.outbox.renewLease({
895
+ holder: input.holderId,
896
+ id: item.id,
897
+ leaseEpoch: item.lease_epoch,
898
+ leaseMs: input.policy.leaseMs,
899
+ });
900
+ await sendOutboxItem(input.client, current);
901
+ input.outbox.acknowledge({ holder: input.holderId, id: current.id, leaseEpoch: current.lease_epoch });
720
902
  result.sent++;
721
- sentByKind[item.kind] = (sentByKind[item.kind] ?? 0) + 1;
903
+ sentByKind[current.kind] = (sentByKind[current.kind] ?? 0) + 1;
722
904
  }
723
905
  catch (error) {
724
906
  failOutboxItem(input, item, error, result);
725
907
  }
726
908
  }
727
909
  function failOutboxItem(input, item, error, result) {
728
- const message = error instanceof Error ? error.message : String(error);
729
- if (error instanceof OutboxPayloadShapeError || item.attempt_count + 1 >= input.policy.maxAttempts) {
730
- input.outbox.deadLetter({
910
+ const message = sanitizeCollectorGapDetails(error instanceof Error ? error.message : String(error));
911
+ try {
912
+ if (error instanceof OutboxPayloadShapeError || item.attempt_count + 1 >= input.policy.maxAttempts) {
913
+ input.outbox.deadLetter({
914
+ error: message,
915
+ holder: input.holderId,
916
+ id: item.id,
917
+ leaseEpoch: item.lease_epoch,
918
+ });
919
+ result.deadLettered++;
920
+ return;
921
+ }
922
+ input.outbox.failRetryable({
731
923
  error: message,
732
924
  holder: input.holderId,
733
925
  id: item.id,
734
926
  leaseEpoch: item.lease_epoch,
927
+ retryBackoffMs: input.policy.retryBackoffMs * (item.attempt_count + 1),
735
928
  });
736
- result.deadLettered++;
737
- return;
929
+ result.failed++;
738
930
  }
739
- input.outbox.failRetryable({
740
- error: message,
741
- holder: input.holderId,
742
- id: item.id,
743
- leaseEpoch: item.lease_epoch,
744
- retryBackoffMs: input.policy.retryBackoffMs * (item.attempt_count + 1),
745
- });
746
- result.failed++;
931
+ catch (transitionError) {
932
+ if (isLeaseNotCurrentError(transitionError)) {
933
+ result.failed++;
934
+ return;
935
+ }
936
+ throw transitionError;
937
+ }
938
+ }
939
+ function isLeaseNotCurrentError(error) {
940
+ return error instanceof Error && error.message.startsWith("local outbox lease not current");
747
941
  }
748
942
  class OutboxPayloadShapeError extends Error {
749
943
  constructor(message) {
@@ -937,6 +1131,34 @@ function isUnresolvedScanBudgetGap(item, policy) {
937
1131
  }
938
1132
  return policy.maxEnqueuedBatchesPerRun <= Number(match[1]);
939
1133
  }
1134
+ export const LOCAL_COLLECTOR_LIFECYCLE_STATES = [
1135
+ "healthy_idle",
1136
+ "draining",
1137
+ "retryable_backlog",
1138
+ "dead_letter",
1139
+ "stale_lease",
1140
+ "coverage_missing",
1141
+ ];
1142
+ export function deriveLocalCollectorLifecycleState(input) {
1143
+ const { summary } = input;
1144
+ if (summary.deadLetter > 0) {
1145
+ return "dead_letter";
1146
+ }
1147
+ if (summary.staleLeases > 0) {
1148
+ return "stale_lease";
1149
+ }
1150
+ const claimableNow = Math.max(0, summary.ready - summary.retrying);
1151
+ if (summary.leased > 0 || claimableNow > 0) {
1152
+ return "draining";
1153
+ }
1154
+ if (summary.retrying > 0) {
1155
+ return "retryable_backlog";
1156
+ }
1157
+ if (input.coverageObserved === false && input.recordBatchCount > 0) {
1158
+ return "coverage_missing";
1159
+ }
1160
+ return "healthy_idle";
1161
+ }
940
1162
  function heartbeatStatusForSummary(summary, policy) {
941
1163
  if (summary.deadLetter > 0) {
942
1164
  return "blocked";
@@ -963,6 +1185,16 @@ export function buildHeartbeatOutboxDiagnostics(summary, options = {}) {
963
1185
  total: summary.total,
964
1186
  };
965
1187
  }
1188
+ function buildHeartbeatDeadLetterError(outbox, sourceInstanceId) {
1189
+ const summary = outbox.deadLetterErrorSummary({ sourceInstanceId });
1190
+ if (summary.dead_letter_count === 0) {
1191
+ return null;
1192
+ }
1193
+ return {
1194
+ kind: "dead_letter_backlog",
1195
+ top_dead_letter_classes: summary.top_classes,
1196
+ };
1197
+ }
966
1198
  function countOpenBacklogGaps(outbox, sourceInstanceId) {
967
1199
  return outbox.countOpenGaps({ sourceInstanceId });
968
1200
  }
@@ -0,0 +1,62 @@
1
+ const STDOUT_DRAIN_TIMEOUT_MS = 3000;
2
+ const RUNTIME_ACK_TIMEOUT_MS = 30 * 60 * 1000;
3
+ export function flushAndExitAfterRuntimeAck(code, options = {}) {
4
+ const stdin = options.stdin ?? process.stdin;
5
+ const stdout = options.stdout ?? process.stdout;
6
+ const exit = options.exit ?? process.exit;
7
+ const stdoutDrainTimeoutMs = options.stdoutDrainTimeoutMs ?? STDOUT_DRAIN_TIMEOUT_MS;
8
+ const runtimeAckTimeoutMs = options.runtimeAckTimeoutMs ?? RUNTIME_ACK_TIMEOUT_MS;
9
+ let finished = false;
10
+ let drainTimer = null;
11
+ let ackTimer = null;
12
+ const finish = () => {
13
+ if (finished) {
14
+ return;
15
+ }
16
+ finished = true;
17
+ if (drainTimer) {
18
+ clearTimeout(drainTimer);
19
+ }
20
+ if (ackTimer) {
21
+ clearTimeout(ackTimer);
22
+ }
23
+ exit(code);
24
+ };
25
+ const waitForRuntimeAck = () => {
26
+ if (drainTimer) {
27
+ clearTimeout(drainTimer);
28
+ drainTimer = null;
29
+ }
30
+ if (stdin.readableEnded || stdin.destroyed) {
31
+ finish();
32
+ return;
33
+ }
34
+ const cleanup = () => {
35
+ stdin.off("close", onRuntimeAck);
36
+ stdin.off("end", onRuntimeAck);
37
+ stdin.off("error", onRuntimeAck);
38
+ };
39
+ const onRuntimeAck = () => {
40
+ cleanup();
41
+ finish();
42
+ };
43
+ stdin.once("close", onRuntimeAck);
44
+ stdin.once("end", onRuntimeAck);
45
+ stdin.once("error", onRuntimeAck);
46
+ ackTimer = setTimeout(() => {
47
+ cleanup();
48
+ finish();
49
+ }, runtimeAckTimeoutMs);
50
+ ackTimer.unref();
51
+ };
52
+ if (stdout.writableLength > 0) {
53
+ stdout.once("drain", waitForRuntimeAck);
54
+ drainTimer = setTimeout(() => {
55
+ stdout.off("drain", waitForRuntimeAck);
56
+ waitForRuntimeAck();
57
+ }, stdoutDrainTimeoutMs);
58
+ drainTimer.unref();
59
+ return;
60
+ }
61
+ waitForRuntimeAck();
62
+ }
@@ -31,6 +31,19 @@ export interface DetailGapStartEntry {
31
31
  status: "pending";
32
32
  stream: string;
33
33
  }
34
+ export interface DetailGapsPageRequestMessage {
35
+ max_bytes?: number;
36
+ reference_only: true;
37
+ request_id: string;
38
+ streams?: readonly string[];
39
+ type: "DETAIL_GAPS_PAGE_REQUEST";
40
+ }
41
+ export interface DetailGapsPageResponse {
42
+ detail_gaps: readonly DetailGapStartEntry[];
43
+ reference_only: true;
44
+ request_id: string;
45
+ type: "DETAIL_GAPS_PAGE_RESPONSE";
46
+ }
34
47
  export interface InteractionResponse {
35
48
  data?: Record<string, string>;
36
49
  error?: {
@@ -70,20 +83,21 @@ export interface AssistanceCompletion {
70
83
  message?: string;
71
84
  status: AssistanceCompletionStatus;
72
85
  }
86
+ export interface DetailGapNetworkPressure {
87
+ attempt?: number;
88
+ endpoint_route: string;
89
+ error_class: string;
90
+ max_attempts?: number;
91
+ method: string;
92
+ retry_after_ms?: number;
93
+ safe_headers?: Record<string, string | number>;
94
+ status?: number;
95
+ }
73
96
  export interface DetailGapMessage {
74
97
  detail?: {
75
98
  class?: string;
76
99
  http_status?: number;
77
- network_pressure?: {
78
- attempt?: number;
79
- endpoint_route: string;
80
- error_class: string;
81
- max_attempts?: number;
82
- method: string;
83
- retry_after_ms?: number;
84
- safe_headers?: Record<string, string | number>;
85
- status?: number;
86
- };
100
+ network_pressure?: DetailGapNetworkPressure;
87
101
  };
88
102
  detail_locator: {
89
103
  kind: string;
@@ -93,16 +107,7 @@ export interface DetailGapMessage {
93
107
  class?: string;
94
108
  http_status?: number;
95
109
  message?: string;
96
- network_pressure?: {
97
- attempt?: number;
98
- endpoint_route: string;
99
- error_class: string;
100
- max_attempts?: number;
101
- method: string;
102
- retry_after_ms?: number;
103
- safe_headers?: Record<string, string | number>;
104
- status?: number;
105
- };
110
+ network_pressure?: DetailGapNetworkPressure;
106
111
  };
107
112
  list_cursor?: unknown;
108
113
  parent_stream?: string;
@@ -115,6 +120,8 @@ export interface DetailGapMessage {
115
120
  type: "DETAIL_GAP";
116
121
  }
117
122
  export interface DetailCoverageMessage {
123
+ considered?: number;
124
+ covered?: number;
118
125
  gap_keys?: Array<string | number>;
119
126
  hydrated_keys: Array<string | number>;
120
127
  optional_skip_keys?: Array<string | number>;
@@ -131,6 +138,18 @@ export interface DetailGapRecoveredMessage {
131
138
  stream: string;
132
139
  type: "DETAIL_GAP_RECOVERED";
133
140
  }
141
+ export interface ProviderBudgetProgress {
142
+ circuit: {
143
+ previous_state: "closed" | "half_open" | "open";
144
+ reason: "provider_failure" | "provider_throttle" | "reset_timeout" | "success";
145
+ state: "closed" | "half_open" | "open";
146
+ trigger: "before_request" | "provider_failure" | "provider_throttle" | "success";
147
+ };
148
+ elapsed_ms: number;
149
+ object: "provider_budget_circuit_transition";
150
+ request_count: number;
151
+ retry_tokens_remaining?: number | "unbounded";
152
+ }
134
153
  export type EmittedMessage = {
135
154
  type: "RECORD";
136
155
  stream: string;
@@ -146,6 +165,7 @@ export type EmittedMessage = {
146
165
  type: "PROGRESS";
147
166
  message: string;
148
167
  stream?: string;
168
+ provider_budget?: ProviderBudgetProgress;
149
169
  } | ({
150
170
  type: "ASSISTANCE";
151
171
  } & AssistanceRequest) | ({
@@ -156,7 +176,7 @@ export type EmittedMessage = {
156
176
  reason: string;
157
177
  message: string;
158
178
  diagnostics?: unknown;
159
- } | DetailGapMessage | DetailCoverageMessage | DetailGapRecoveredMessage | {
179
+ } | DetailGapMessage | DetailCoverageMessage | DetailGapRecoveredMessage | DetailGapsPageRequestMessage | {
160
180
  type: "DONE";
161
181
  status: "succeeded" | "failed";
162
182
  records_emitted: number;