@phigon/observer 8.2.0 → 8.3.0

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 (187) hide show
  1. package/README.md +194 -11
  2. package/dist/bin/guardian-runner-darwin-amd64 +0 -0
  3. package/dist/bin/guardian-runner-darwin-arm64 +0 -0
  4. package/dist/bin/guardian-runner-linux-amd64 +0 -0
  5. package/dist/bin/guardian-runner-linux-arm64 +0 -0
  6. package/dist/bin/guardian-runner-windows-amd64.exe +0 -0
  7. package/dist/bin/guardian-runner-windows-arm64.exe +0 -0
  8. package/dist/src/abortable-delay.d.ts +10 -0
  9. package/dist/src/abortable-delay.d.ts.map +1 -0
  10. package/dist/src/abortable-delay.js +21 -0
  11. package/dist/src/abortable-delay.js.map +1 -0
  12. package/dist/src/activity/adapters/claude.d.ts.map +1 -1
  13. package/dist/src/activity/adapters/claude.js +9 -1
  14. package/dist/src/activity/adapters/claude.js.map +1 -1
  15. package/dist/src/activity/adapters/openai-compatible.d.ts.map +1 -1
  16. package/dist/src/activity/adapters/openai-compatible.js +9 -1
  17. package/dist/src/activity/adapters/openai-compatible.js.map +1 -1
  18. package/dist/src/activity/collector.d.ts +31 -1
  19. package/dist/src/activity/collector.d.ts.map +1 -1
  20. package/dist/src/activity/collector.js +337 -77
  21. package/dist/src/activity/collector.js.map +1 -1
  22. package/dist/src/activity/ingest.d.ts +9 -0
  23. package/dist/src/activity/ingest.d.ts.map +1 -1
  24. package/dist/src/activity/ingest.js +92 -3
  25. package/dist/src/activity/ingest.js.map +1 -1
  26. package/dist/src/activity/json-document-source.d.ts +15 -0
  27. package/dist/src/activity/json-document-source.d.ts.map +1 -1
  28. package/dist/src/activity/json-document-source.js +19 -2
  29. package/dist/src/activity/json-document-source.js.map +1 -1
  30. package/dist/src/activity/native-capabilities.d.ts.map +1 -1
  31. package/dist/src/activity/native-capabilities.js +8 -12
  32. package/dist/src/activity/native-capabilities.js.map +1 -1
  33. package/dist/src/activity/opencode-sqlite-source.js +1 -0
  34. package/dist/src/activity/opencode-sqlite-source.js.map +1 -1
  35. package/dist/src/activity/source-reader.d.ts +44 -3
  36. package/dist/src/activity/source-reader.d.ts.map +1 -1
  37. package/dist/src/activity/source-reader.js +325 -86
  38. package/dist/src/activity/source-reader.js.map +1 -1
  39. package/dist/src/agent-action-executor.d.ts +5 -0
  40. package/dist/src/agent-action-executor.d.ts.map +1 -1
  41. package/dist/src/agent-action-executor.js +9 -6
  42. package/dist/src/agent-action-executor.js.map +1 -1
  43. package/dist/src/atomic-replace.d.ts +7 -0
  44. package/dist/src/atomic-replace.d.ts.map +1 -1
  45. package/dist/src/atomic-replace.js +28 -3
  46. package/dist/src/atomic-replace.js.map +1 -1
  47. package/dist/src/bridge-journal.d.ts +32 -0
  48. package/dist/src/bridge-journal.d.ts.map +1 -0
  49. package/dist/src/bridge-journal.js +113 -0
  50. package/dist/src/bridge-journal.js.map +1 -0
  51. package/dist/src/cli.d.ts +7 -1
  52. package/dist/src/cli.d.ts.map +1 -1
  53. package/dist/src/cli.js +490 -43
  54. package/dist/src/cli.js.map +1 -1
  55. package/dist/src/collection-notice.d.ts +34 -0
  56. package/dist/src/collection-notice.d.ts.map +1 -0
  57. package/dist/src/collection-notice.js +73 -0
  58. package/dist/src/collection-notice.js.map +1 -0
  59. package/dist/src/config.d.ts +26 -0
  60. package/dist/src/config.d.ts.map +1 -1
  61. package/dist/src/config.js +88 -33
  62. package/dist/src/config.js.map +1 -1
  63. package/dist/src/content-redaction.d.ts +17 -1
  64. package/dist/src/content-redaction.d.ts.map +1 -1
  65. package/dist/src/content-redaction.js +44 -3
  66. package/dist/src/content-redaction.js.map +1 -1
  67. package/dist/src/credential-store.d.ts +20 -1
  68. package/dist/src/credential-store.d.ts.map +1 -1
  69. package/dist/src/credential-store.js +27 -3
  70. package/dist/src/credential-store.js.map +1 -1
  71. package/dist/src/discovery/engine.d.ts.map +1 -1
  72. package/dist/src/discovery/engine.js +1 -11
  73. package/dist/src/discovery/engine.js.map +1 -1
  74. package/dist/src/doctor-check.d.ts +51 -0
  75. package/dist/src/doctor-check.d.ts.map +1 -0
  76. package/dist/src/doctor-check.js +174 -0
  77. package/dist/src/doctor-check.js.map +1 -0
  78. package/dist/src/enroll.d.ts +86 -0
  79. package/dist/src/enroll.d.ts.map +1 -0
  80. package/dist/src/enroll.js +245 -0
  81. package/dist/src/enroll.js.map +1 -0
  82. package/dist/src/entry.d.ts +3 -0
  83. package/dist/src/entry.d.ts.map +1 -0
  84. package/dist/src/entry.js +14 -0
  85. package/dist/src/entry.js.map +1 -0
  86. package/dist/src/event-queue.d.ts +13 -1
  87. package/dist/src/event-queue.d.ts.map +1 -1
  88. package/dist/src/event-queue.js +50 -5
  89. package/dist/src/event-queue.js.map +1 -1
  90. package/dist/src/event-spool.d.ts +61 -3
  91. package/dist/src/event-spool.d.ts.map +1 -1
  92. package/dist/src/event-spool.js +290 -41
  93. package/dist/src/event-spool.js.map +1 -1
  94. package/dist/src/generated/phigon/v1/identity_pb.d.ts +7 -0
  95. package/dist/src/generated/phigon/v1/identity_pb.d.ts.map +1 -1
  96. package/dist/src/generated/phigon/v1/identity_pb.js +1 -1
  97. package/dist/src/generated/phigon/v1/identity_pb.js.map +1 -1
  98. package/dist/src/guardian-decision-journal.d.ts.map +1 -1
  99. package/dist/src/guardian-decision-journal.js +4 -20
  100. package/dist/src/guardian-decision-journal.js.map +1 -1
  101. package/dist/src/guardian-managed-exec.js +6 -19
  102. package/dist/src/guardian-managed-exec.js.map +1 -1
  103. package/dist/src/guardian-process-monitor.d.ts +1 -0
  104. package/dist/src/guardian-process-monitor.d.ts.map +1 -1
  105. package/dist/src/guardian-process-monitor.js +74 -26
  106. package/dist/src/guardian-process-monitor.js.map +1 -1
  107. package/dist/src/health.d.ts +19 -1
  108. package/dist/src/health.d.ts.map +1 -1
  109. package/dist/src/health.js +22 -1
  110. package/dist/src/health.js.map +1 -1
  111. package/dist/src/historical-identity-store.d.ts +8 -1
  112. package/dist/src/historical-identity-store.d.ts.map +1 -1
  113. package/dist/src/historical-identity-store.js +26 -9
  114. package/dist/src/historical-identity-store.js.map +1 -1
  115. package/dist/src/index.d.ts +2 -0
  116. package/dist/src/index.d.ts.map +1 -1
  117. package/dist/src/index.js +2 -0
  118. package/dist/src/index.js.map +1 -1
  119. package/dist/src/installation.d.ts +2 -0
  120. package/dist/src/installation.d.ts.map +1 -1
  121. package/dist/src/installation.js +3 -1
  122. package/dist/src/installation.js.map +1 -1
  123. package/dist/src/instance-lock.d.ts +45 -0
  124. package/dist/src/instance-lock.d.ts.map +1 -0
  125. package/dist/src/instance-lock.js +243 -0
  126. package/dist/src/instance-lock.js.map +1 -0
  127. package/dist/src/machine-command-executor.d.ts +2 -2
  128. package/dist/src/machine-command-executor.d.ts.map +1 -1
  129. package/dist/src/machine-command-executor.js +7 -20
  130. package/dist/src/machine-command-executor.js.map +1 -1
  131. package/dist/src/node-version.d.ts +8 -0
  132. package/dist/src/node-version.d.ts.map +1 -0
  133. package/dist/src/node-version.js +17 -0
  134. package/dist/src/node-version.js.map +1 -0
  135. package/dist/src/observer-log.d.ts +50 -0
  136. package/dist/src/observer-log.d.ts.map +1 -0
  137. package/dist/src/observer-log.js +141 -0
  138. package/dist/src/observer-log.js.map +1 -0
  139. package/dist/src/pair.d.ts +68 -1
  140. package/dist/src/pair.d.ts.map +1 -1
  141. package/dist/src/pair.js +204 -66
  142. package/dist/src/pair.js.map +1 -1
  143. package/dist/src/private-directory.d.ts +6 -0
  144. package/dist/src/private-directory.d.ts.map +1 -1
  145. package/dist/src/private-directory.js +33 -2
  146. package/dist/src/private-directory.js.map +1 -1
  147. package/dist/src/proxy.d.ts +68 -0
  148. package/dist/src/proxy.d.ts.map +1 -0
  149. package/dist/src/proxy.js +209 -0
  150. package/dist/src/proxy.js.map +1 -0
  151. package/dist/src/purge.d.ts +17 -0
  152. package/dist/src/purge.d.ts.map +1 -0
  153. package/dist/src/purge.js +84 -0
  154. package/dist/src/purge.js.map +1 -0
  155. package/dist/src/remove-machine.d.ts +5 -1
  156. package/dist/src/remove-machine.d.ts.map +1 -1
  157. package/dist/src/remove-machine.js +28 -14
  158. package/dist/src/remove-machine.js.map +1 -1
  159. package/dist/src/server-authority.d.ts +5 -2
  160. package/dist/src/server-authority.d.ts.map +1 -1
  161. package/dist/src/server-authority.js +10 -6
  162. package/dist/src/server-authority.js.map +1 -1
  163. package/dist/src/server-clock.d.ts +32 -0
  164. package/dist/src/server-clock.d.ts.map +1 -0
  165. package/dist/src/server-clock.js +72 -0
  166. package/dist/src/server-clock.js.map +1 -0
  167. package/dist/src/service-command.d.ts +20 -0
  168. package/dist/src/service-command.d.ts.map +1 -0
  169. package/dist/src/service-command.js +136 -0
  170. package/dist/src/service-command.js.map +1 -0
  171. package/dist/src/service.d.ts +98 -0
  172. package/dist/src/service.d.ts.map +1 -0
  173. package/dist/src/service.js +472 -0
  174. package/dist/src/service.js.map +1 -0
  175. package/dist/src/stream-client.d.ts +18 -0
  176. package/dist/src/stream-client.d.ts.map +1 -1
  177. package/dist/src/stream-client.js +91 -16
  178. package/dist/src/stream-client.js.map +1 -1
  179. package/dist/src/stream-state.d.ts +2 -0
  180. package/dist/src/stream-state.d.ts.map +1 -1
  181. package/dist/src/stream-state.js +3 -1
  182. package/dist/src/stream-state.js.map +1 -1
  183. package/dist/src/user-notices.d.ts +11 -0
  184. package/dist/src/user-notices.d.ts.map +1 -1
  185. package/dist/src/user-notices.js +24 -0
  186. package/dist/src/user-notices.js.map +1 -1
  187. package/package.json +7 -7
@@ -1,7 +1,9 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { readdir, stat, unlink } from "node:fs/promises";
2
3
  import { join } from "node:path";
3
4
  import { create, toBinary } from "@bufbuild/protobuf";
4
5
  import { timestampFromDate } from "@bufbuild/protobuf/wkt";
6
+ import { abortableDelay } from "../abortable-delay.js";
5
7
  import { ActivityKind, AgentKind, AgentState, } from "../generated/phigon/v1/common_pb.js";
6
8
  import { AgentIdentitySchema, } from "../generated/phigon/v1/identity_pb.js";
7
9
  import { mergeInventoryWithHistoricalIdentities } from "../historical-identity-store.js";
@@ -27,10 +29,19 @@ import { genericActivityAdapter } from "./adapters/generic.js";
27
29
  import { LocalPolicyEngine } from "./policy.js";
28
30
  import { vendorDraft } from "./normalize.js";
29
31
  import { objectValue, recordObservedAt, stringField, } from "./record.js";
30
- import { JSONLSourceReader, } from "./source-reader.js";
32
+ import { JSONLSourceReader, fileReadThrough, } from "./source-reader.js";
33
+ import { bridgeWritingSuffix, ingestedBridgeFile } from "./ingest.js";
34
+ import { closedJournalSegments } from "../bridge-journal.js";
31
35
  import { processKeyCapability } from "../discovery/process-identity.js";
32
36
  const maxEnvelopeBytes = 1 << 20;
33
37
  const maxPendingCheckpoints = 256;
38
+ // Bridge files are cleaned up this often, and kept at most this long.
39
+ const bridgePruneIntervalMs = 5 * 60_000;
40
+ const bridgeFileRetentionMs = 7 * 86_400_000;
41
+ // A closed journal segment untouched this long has no writer left.
42
+ const closedSegmentQuietMs = 60_000;
43
+ // Saved live checkpoints remembered while their events await acknowledgement.
44
+ const retainedLiveCheckpoints = 8;
34
45
  export const historySyncQueueHeadroom = 1_024;
35
46
  const maxDraftsPerRecord = 32;
36
47
  const adapters = new Map([
@@ -46,6 +57,10 @@ const adapters = new Map([
46
57
  // Keep the flexible generic parser after the generated bridge adapters so
47
58
  // the generic bridge cannot accidentally replace it with a fixed label.
48
59
  ["generic", genericActivityAdapter],
60
+ // Deliberately replaces the generated OpenCode bridge parser: the native
61
+ // adapter reads both OpenCode's database events and the structured CLI
62
+ // records (step_start, tool_use, text, step_finish) that
63
+ // `ingest --agent opencode` receives, as docs/activity-collection.md says.
49
64
  ["opencode", openCodeActivityAdapter],
50
65
  // Native recordings use their own adapter IDs so the OpenAI-compatible
51
66
  // `ingest --agent` bridges for the same products keep their parser.
@@ -61,6 +76,18 @@ const adapters = new Map([
61
76
  ["roo-code-task", rooCodeActivityAdapter],
62
77
  ["kilo-code-task", kiloCodeActivityAdapter],
63
78
  ]);
79
+ /**
80
+ * `emit` refused an event. The reader was put back to where the scan
81
+ * started, so its records are read again once delivery has room.
82
+ */
83
+ class DeliveryRefusedError extends Error {
84
+ }
85
+ const sourceIdentityFailureKey = "agent identities";
86
+ /** The kind of an error, never its message, which may quote a record. */
87
+ function errorName(error) {
88
+ const name = error instanceof Error ? error.name : "";
89
+ return /^[A-Za-z][A-Za-z0-9]{0,63}$/u.test(name) ? name : "unknown error";
90
+ }
64
91
  export class ActivityCollector {
65
92
  #config;
66
93
  #credential;
@@ -74,6 +101,15 @@ export class ActivityCollector {
74
101
  #canCollect;
75
102
  #canSyncHistory;
76
103
  #onSourceIdentity;
104
+ #onDeliveryRefused;
105
+ #onSourceFailure;
106
+ #reportedFailures = new WeakSet();
107
+ // Failures in a row of record conversion, per adapter, and of saving
108
+ // source identities. The keys are a fixed, small set.
109
+ #collectionFailures = new Map();
110
+ #registeredSourcesFailing = false;
111
+ #deliveryRefused = false;
112
+ #refusedDeliveries = 0;
77
113
  #policy;
78
114
  #limiter;
79
115
  #agentsByKind = new Map();
@@ -87,6 +123,11 @@ export class ActivityCollector {
87
123
  #oversizedEvents = 0;
88
124
  #acknowledgedSequence;
89
125
  #durableDelivery;
126
+ // Saved live-reader checkpoints, oldest first, with the sequence that
127
+ // their events end at. Bridge files they read through can be deleted once
128
+ // the server acknowledges that sequence.
129
+ #committedLive = [];
130
+ #flushDelivery;
90
131
  #pendingCheckpoints = [];
91
132
  #checkpointSave = Promise.resolve();
92
133
  #sourceIdentitySaves = [];
@@ -106,6 +147,8 @@ export class ActivityCollector {
106
147
  this.#canCollect = options.canCollect ?? (() => true);
107
148
  this.#canSyncHistory = options.canSyncHistory ?? this.#canCollect;
108
149
  this.#onSourceIdentity = options.onSourceIdentity ?? (() => undefined);
150
+ this.#onDeliveryRefused = options.onDeliveryRefused ?? (() => undefined);
151
+ this.#onSourceFailure = options.onSourceFailure ?? (() => undefined);
109
152
  this.#policy = new LocalPolicyEngine(options.policy ?? options.config.policy, options.config.redactionPathPatterns);
110
153
  this.#limiter = new MinuteRateLimiter(options.config.activityRateLimitPerMinute);
111
154
  this.#lastSequence = options.initialSequence;
@@ -116,6 +159,7 @@ export class ActivityCollector {
116
159
  throw new Error("Initial activity acknowledgement is invalid.");
117
160
  }
118
161
  this.#durableDelivery = options.durableDelivery ?? false;
162
+ this.#flushDelivery = options.flushDelivery ?? (() => undefined);
119
163
  }
120
164
  updateInventory(inventory) {
121
165
  this.#agentsByKind.clear();
@@ -129,6 +173,9 @@ export class ActivityCollector {
129
173
  if (acknowledgedSequence < 0n || allocationSequence < 0n) {
130
174
  throw new Error("Activity sequence must not be negative.");
131
175
  }
176
+ // The server lost acknowledgements: no saved checkpoint is known to be
177
+ // delivered any more.
178
+ this.#committedLive = [];
132
179
  this.#acknowledgedSequence = acknowledgedSequence;
133
180
  this.#lastSequence =
134
181
  allocationSequence < acknowledgedSequence
@@ -162,26 +209,35 @@ export class ActivityCollector {
162
209
  throw new Error("History sync expired before all available records were uploaded.");
163
210
  }
164
211
  if (!this.#canSyncHistory()) {
165
- await abortableDelay(250, signal ?? new AbortController().signal);
212
+ await abortableDelay(250, signal);
166
213
  continue;
167
214
  }
168
215
  // Release the collection lock between bounded batches. A large archive
169
216
  // must not prevent the live reader from following current work.
170
217
  const started = performance.now();
171
- const pass = await this.#withScanLock(async () => {
172
- const before = this.#emittedEvents;
173
- const result = await this.#collectOnce(this.#historyReader, true);
174
- eventsUploaded += this.#emittedEvents - before;
175
- return result;
176
- });
218
+ let pass;
219
+ try {
220
+ pass = await this.#withScanLock(async () => {
221
+ const before = this.#emittedEvents;
222
+ const result = await this.#collectOnce(this.#historyReader, true);
223
+ eventsUploaded += this.#emittedEvents - before;
224
+ return result;
225
+ });
226
+ }
227
+ catch (error) {
228
+ if (!(error instanceof DeliveryRefusedError))
229
+ throw error;
230
+ await abortableDelay(250, signal);
231
+ continue;
232
+ }
177
233
  recordsScanned += pass.sourceRecords;
178
234
  filesDiscovered = Math.max(filesDiscovered, pass.discoveredFiles);
179
235
  complete = pass.complete;
180
236
  if (!complete && pass.sourceRecords === 0) {
181
- await abortableDelay(100, signal ?? new AbortController().signal);
237
+ await abortableDelay(100, signal);
182
238
  }
183
239
  else if (!complete) {
184
- await abortableDelay(historyRest(performance.now() - started), signal ?? new AbortController().signal);
240
+ await abortableDelay(historyRest(performance.now() - started), signal);
185
241
  }
186
242
  }
187
243
  return Object.freeze({
@@ -212,10 +268,12 @@ export class ActivityCollector {
212
268
  discoveredFiles: 0,
213
269
  };
214
270
  }
271
+ const start = reader.position();
215
272
  const scan = await reader.scan([
216
273
  ...this.#sources,
217
- ...await this.#additionalSources(),
274
+ ...await this.#registeredSources(),
218
275
  ], Math.max(1, Math.floor(allowance / maxDraftsPerRecord)));
276
+ this.#reportSourceFailures(reader);
219
277
  this.#sourceRecords += scan.metrics.records;
220
278
  this.#malformedRecords += scan.metrics.malformed;
221
279
  this.#oversizedRecords += scan.metrics.oversized;
@@ -224,61 +282,47 @@ export class ActivityCollector {
224
282
  const adapter = adapters.get(record.adapterId);
225
283
  if (adapter === undefined)
226
284
  continue;
227
- const sessionID = reportedSessionID(record.adapterId, record.value, record.sourceId);
228
- const drafts = adapter.parse(record.value, {
229
- sessionId: sessionID,
230
- observedAt: record.observedAt,
231
- policy: this.#policy,
232
- });
233
- const context = {
234
- sessionId: sessionID,
235
- observedAt: recordObservedAt(record.value, record.observedAt),
236
- policy: this.#policy,
237
- };
238
- const genericAgent = record.adapterId === "generic"
239
- ? reportedGenericAgentIdentity(record.value, record.sourceDisplayName)
240
- : undefined;
241
- const nativeDraft = vendorDraft(adapter.agentKind, context, genericAgent?.hint !== undefined
242
- ? genericAgent.hint
243
- : adapter.id, vendorType(record.value), record.value);
244
- const candidateDrafts = [nativeDraft, ...drafts.slice(0, 31)];
245
- const permittedDrafts = !historical && record.initialReplay === true &&
246
- this.#config.activityInitialReplayMode === "messages"
247
- ? candidateDrafts.filter((draft) => draft.payload.case === "activity" &&
248
- (draft.payload.value.kind === ActivityKind.PROMPT ||
249
- draft.payload.value.kind === ActivityKind.RESPONSE))
250
- : candidateDrafts;
285
+ let converted;
286
+ try {
287
+ converted = this.#convertRecord(record, adapter, historical, now);
288
+ }
289
+ catch (error) {
290
+ // A record this observer cannot convert is skipped like a malformed
291
+ // one: reading it again would fail the same way, and stopping
292
+ // would end collection from every source.
293
+ this.#malformedRecords++;
294
+ this.#reportCollectionFailure(record.adapterId, `one record could not be converted and was skipped (${errorName(error)})`);
295
+ continue;
296
+ }
297
+ this.#collectionFailures.delete(record.adapterId);
298
+ this.#oversizedEvents += converted.oversized;
251
299
  // A source record is atomic: never advance its cursor after dropping
252
300
  // part of its normalized activity. One record may use the final budget
253
301
  // plus a bounded burst; the next scan waits for replenishment.
254
302
  if (!historical)
255
- this.#limiter.consume(now, permittedDrafts.length);
256
- for (const [draftIndex, draft] of permittedDrafts.entries()) {
257
- const nextSequence = this.#lastSequence + 1n;
258
- const event = create(EventEnvelopeSchema, {
259
- tenantId: this.#credential.tenantId,
260
- machineId: this.#credential.machineId,
261
- agentId: this.#agentID(draft.agentKind, record.adapterId, record.sourceId, record.value, record.observedAt, genericAgent?.displayName),
262
- sessionId: draft.sessionId,
263
- sequence: nextSequence,
264
- observedAt: timestampFromDate(clampObservedAt(draft.observedAt, now)),
265
- schemaVersion: 1,
266
- privacyClass: draft.privacyClass,
267
- sourceEventId: createHash("sha256")
268
- .update(`${record.sourceRecordId}\u0000${draftIndex}\u0000${draft.payload.case}`)
269
- .digest("hex"),
270
- payload: draft.payload,
271
- });
272
- if (toBinary(EventEnvelopeSchema, event).byteLength >
273
- maxEnvelopeBytes) {
274
- this.#oversizedEvents++;
275
- continue;
303
+ this.#limiter.consume(now, converted.drafts);
304
+ for (const event of converted.events) {
305
+ try {
306
+ this.#emit(event);
276
307
  }
277
- this.#lastSequence = nextSequence;
278
- this.#emit(event);
308
+ catch (error) {
309
+ // The reader already moved past this scan's records. Put it back
310
+ // so none is skipped; the ones delivered before this event are
311
+ // sent again with the same source event IDs, which the server
312
+ // deduplicates. The sequence stays unused, leaving no gap.
313
+ reader.rewind(start);
314
+ this.#refusedDeliveries++;
315
+ if (!this.#deliveryRefused) {
316
+ this.#deliveryRefused = true;
317
+ this.#onDeliveryRefused(error);
318
+ }
319
+ throw new DeliveryRefusedError("Telemetry delivery refused an activity event.", { cause: error });
320
+ }
321
+ this.#lastSequence = event.sequence;
279
322
  this.#emittedEvents++;
280
323
  }
281
324
  }
325
+ this.#deliveryRefused = false;
282
326
  await this.#flushSourceIdentitySaves();
283
327
  const pending = {
284
328
  throughSequence: this.#lastSequence,
@@ -300,10 +344,99 @@ export class ActivityCollector {
300
344
  discoveredFiles: scan.metrics.discoveredFiles,
301
345
  };
302
346
  }
347
+ /**
348
+ * The events for one source record, numbered from the next sequence, and
349
+ * how many normalized drafts they came from. Throws when the record cannot
350
+ * be converted; nothing is emitted for it then.
351
+ */
352
+ #convertRecord(record, adapter, historical, now) {
353
+ const sessionID = reportedSessionID(record.adapterId, record.value, record.sourceId);
354
+ const drafts = adapter.parse(record.value, {
355
+ sessionId: sessionID,
356
+ observedAt: record.observedAt,
357
+ policy: this.#policy,
358
+ });
359
+ const context = {
360
+ sessionId: sessionID,
361
+ observedAt: recordObservedAt(record.value, record.observedAt),
362
+ policy: this.#policy,
363
+ };
364
+ const genericAgent = record.adapterId === "generic"
365
+ ? reportedGenericAgentIdentity(record.value, record.sourceDisplayName)
366
+ : undefined;
367
+ const nativeDraft = vendorDraft(adapter.agentKind, context, genericAgent?.hint !== undefined
368
+ ? genericAgent.hint
369
+ : adapter.id, vendorType(record.value), record.value);
370
+ const candidateDrafts = [nativeDraft, ...drafts.slice(0, 31)];
371
+ const permittedDrafts = !historical && record.initialReplay === true &&
372
+ this.#config.activityInitialReplayMode === "messages"
373
+ ? candidateDrafts.filter((draft) => draft.payload.case === "activity" &&
374
+ (draft.payload.value.kind === ActivityKind.PROMPT ||
375
+ draft.payload.value.kind === ActivityKind.RESPONSE))
376
+ : candidateDrafts;
377
+ const events = [];
378
+ let oversized = 0;
379
+ for (const [draftIndex, draft] of permittedDrafts.entries()) {
380
+ const event = create(EventEnvelopeSchema, {
381
+ tenantId: this.#credential.tenantId,
382
+ machineId: this.#credential.machineId,
383
+ agentId: this.#agentID(draft.agentKind, record.adapterId, record.sourceId, record.value, record.observedAt, genericAgent?.displayName),
384
+ sessionId: draft.sessionId,
385
+ sequence: this.#lastSequence + 1n + BigInt(events.length),
386
+ observedAt: timestampFromDate(clampObservedAt(draft.observedAt, now)),
387
+ schemaVersion: 1,
388
+ privacyClass: draft.privacyClass,
389
+ sourceEventId: createHash("sha256")
390
+ .update(`${record.sourceRecordId}\u0000${draftIndex}\u0000${draft.payload.case}`)
391
+ .digest("hex"),
392
+ payload: draft.payload,
393
+ });
394
+ if (toBinary(EventEnvelopeSchema, event).byteLength >
395
+ maxEnvelopeBytes) {
396
+ oversized++;
397
+ continue;
398
+ }
399
+ events.push(event);
400
+ }
401
+ return { events, drafts: permittedDrafts.length, oversized };
402
+ }
403
+ /**
404
+ * Says that collection work keeps failing, at the 1st, 2nd, 4th, 8th...
405
+ * failure in a row, so it stays visible without flooding the log. The
406
+ * message never includes activity content.
407
+ */
408
+ #reportCollectionFailure(key, message) {
409
+ const failures = (this.#collectionFailures.get(key) ?? 0) + 1;
410
+ this.#collectionFailures.set(key, failures);
411
+ if ((failures & (failures - 1)) !== 0)
412
+ return;
413
+ const now = this.#clock();
414
+ this.#onSourceFailure(Object.freeze({
415
+ adapterId: key,
416
+ failures,
417
+ message,
418
+ failedAt: now,
419
+ retryAt: now,
420
+ }));
421
+ }
303
422
  async run(signal) {
423
+ let prunedAt = 0;
304
424
  try {
305
425
  while (!signal.aborted) {
306
- await this.scanOnce();
426
+ try {
427
+ await this.scanOnce();
428
+ }
429
+ catch (error) {
430
+ // A full queue or spool waits for acknowledgements like any
431
+ // other backpressure; the refused records are read again.
432
+ if (!(error instanceof DeliveryRefusedError))
433
+ throw error;
434
+ }
435
+ if (this.#clock().getTime() - prunedAt >= bridgePruneIntervalMs) {
436
+ prunedAt = this.#clock().getTime();
437
+ // Cleanup only frees disk space; it retries on the next pass.
438
+ await this.pruneBridgeFiles().catch(() => undefined);
439
+ }
307
440
  await abortableDelay(this.#config.activityScanIntervalMs, signal);
308
441
  }
309
442
  }
@@ -311,6 +444,56 @@ export class ActivityCollector {
311
444
  this.close();
312
445
  }
313
446
  }
447
+ /**
448
+ * Deletes bridge files this observer is done with: an `ingest` file or a
449
+ * closed journal segment whose records were all read under a checkpoint
450
+ * the server has acknowledged through, or any of them after seven days.
451
+ * A file an `ingest` run is still writing, and each journal's newest
452
+ * segment, are never deleted while in use.
453
+ */
454
+ async pruneBridgeFiles() {
455
+ const now = this.#clock().getTime();
456
+ const acknowledged = this.#acknowledgedSequence;
457
+ const settled = this.#committedLive.findLast((checkpoint) => checkpoint.throughSequence <= acknowledged);
458
+ for (const agent of activityBridgeAgents) {
459
+ const root = join(this.#config.dataDirectory, "bridges", agent.id);
460
+ let names;
461
+ try {
462
+ names = await readdir(root);
463
+ }
464
+ catch {
465
+ continue;
466
+ }
467
+ const present = new Set(names);
468
+ const closedSegments = new Set(closedJournalSegments(names));
469
+ for (const name of names) {
470
+ const ingested = ingestedBridgeFile.test(name);
471
+ if (!ingested && !closedSegments.has(name))
472
+ continue;
473
+ const path = join(root, name);
474
+ try {
475
+ const modifiedMs = (await stat(path)).mtimeMs;
476
+ const expired = now - modifiedMs >= bridgeFileRetentionMs;
477
+ const inUse = ingested
478
+ ? present.has(`${name}${bridgeWritingSuffix}`)
479
+ // A segment closes when its journal starts the next one; a
480
+ // writer that chose it just before may still be appending.
481
+ : now - modifiedMs < closedSegmentQuietMs;
482
+ if (expired ||
483
+ (!inUse && settled !== undefined &&
484
+ await fileReadThrough(path, settled.cursors))) {
485
+ await unlink(path);
486
+ if (ingested) {
487
+ await unlink(`${path}${bridgeWritingSuffix}`).catch(() => undefined);
488
+ }
489
+ }
490
+ }
491
+ catch {
492
+ // Gone, or locked by another program: retried on the next pass.
493
+ }
494
+ }
495
+ }
496
+ }
314
497
  /**
315
498
  * Release the database connections the live reader keeps open. A later
316
499
  * scan opens them again.
@@ -329,8 +512,53 @@ export class ActivityCollector {
329
512
  lastSequence: this.#lastSequence,
330
513
  pendingCheckpoints: this.#pendingCheckpoints.length,
331
514
  backpressuredScans: this.#backpressuredScans,
515
+ refusedDeliveries: this.#refusedDeliveries,
516
+ failingSources: this.#reader.sourceFailures().length,
332
517
  });
333
518
  }
519
+ /**
520
+ * Sources of registered custom agents. When their list cannot be read,
521
+ * this scan goes on with the built-in sources.
522
+ */
523
+ async #registeredSources() {
524
+ try {
525
+ const sources = await this.#additionalSources();
526
+ this.#registeredSourcesFailing = false;
527
+ return sources;
528
+ }
529
+ catch (error) {
530
+ if (!this.#registeredSourcesFailing) {
531
+ this.#registeredSourcesFailing = true;
532
+ const now = this.#clock();
533
+ this.#onSourceFailure(Object.freeze({
534
+ adapterId: "registered agents",
535
+ failures: 1,
536
+ message: (error instanceof Error ? error.message : String(error))
537
+ .replace(/\s+/gu, " ")
538
+ .slice(0, 200),
539
+ failedAt: now,
540
+ // Read again on the next scan.
541
+ retryAt: now,
542
+ }));
543
+ }
544
+ return [];
545
+ }
546
+ }
547
+ #reportSourceFailures(reader) {
548
+ for (const failure of reader.sourceFailures()) {
549
+ if (failure.failures !== 1 || this.#reportedFailures.has(failure))
550
+ continue;
551
+ this.#reportedFailures.add(failure);
552
+ this.#onSourceFailure(failure);
553
+ }
554
+ }
555
+ /** Sources and files skipped after a failure, by the live and history readers. */
556
+ sourceFailures() {
557
+ return [
558
+ ...this.#reader.sourceFailures(),
559
+ ...this.#historyReader.sourceFailures(),
560
+ ];
561
+ }
334
562
  sourceIdentities() {
335
563
  return [...this.#sourceIdentities.values()].sort((left, right) => left.agentId.localeCompare(right.agentId));
336
564
  }
@@ -344,15 +572,34 @@ export class ActivityCollector {
344
572
  this.#checkpointSave = this.#checkpointSave
345
573
  .catch(() => undefined)
346
574
  .then(async () => {
347
- const currentDurableThrough = this.#durableDelivery
348
- ? this.#lastSequence
349
- : this.#acknowledgedSequence;
575
+ let currentDurableThrough = this.#acknowledgedSequence;
576
+ if (this.#durableDelivery) {
577
+ const emittedThrough = this.#lastSequence;
578
+ try {
579
+ await this.#flushDelivery();
580
+ currentDurableThrough = emittedThrough;
581
+ }
582
+ catch {
583
+ // Not written: only checkpoints the server acknowledged move.
584
+ // The rest stay pending for the next save, and delivery refuses
585
+ // new events until the spool can be written again.
586
+ }
587
+ }
350
588
  const committed = new Set();
351
589
  for (const pending of eligible) {
352
590
  if (pending.throughSequence > currentDurableThrough)
353
591
  continue;
354
592
  await pending.reader.commit(pending.checkpoint);
355
593
  committed.add(pending);
594
+ if (pending.reader === this.#reader) {
595
+ this.#committedLive = [
596
+ ...this.#committedLive.slice(-(retainedLiveCheckpoints - 1)),
597
+ {
598
+ cursors: pending.checkpoint.cursors,
599
+ throughSequence: pending.throughSequence,
600
+ },
601
+ ];
602
+ }
356
603
  }
357
604
  this.#pendingCheckpoints = this.#pendingCheckpoints.filter((candidate) => !committed.has(candidate));
358
605
  });
@@ -404,21 +651,45 @@ export class ActivityCollector {
404
651
  capabilities: ["activity", "source-isolated", "unattributed"],
405
652
  });
406
653
  this.#sourceIdentities.set(agentID, identity);
654
+ // An identity that could not be saved is forgotten, so the source's
655
+ // next record saves it again.
656
+ const forget = (error) => {
657
+ if (this.#sourceIdentities.get(agentID) === identity) {
658
+ this.#sourceIdentities.delete(agentID);
659
+ }
660
+ throw error;
661
+ };
662
+ let save;
407
663
  try {
408
- this.#sourceIdentitySaves.push(Promise.resolve(this.#onSourceIdentity(identity)));
664
+ save = Promise.resolve(this.#onSourceIdentity(identity)).catch(forget);
409
665
  }
410
666
  catch (error) {
411
- this.#sourceIdentitySaves.push(Promise.reject(error));
667
+ save = Promise.reject(error).catch(forget);
412
668
  }
669
+ // Handled here too: a scan that stops early leaves the save for the
670
+ // next flush, and an unhandled rejection would end the process.
671
+ save.catch(() => undefined);
672
+ this.#sourceIdentitySaves.push(save);
413
673
  }
674
+ /**
675
+ * Waits for the identity saves this scan started. A failed save is
676
+ * reported and retried with a later record; it never stops collection,
677
+ * because the events already carry the agent ID.
678
+ */
414
679
  async #flushSourceIdentitySaves() {
415
680
  const pending = this.#sourceIdentitySaves.splice(0);
416
681
  if (pending.length === 0)
417
682
  return;
418
683
  const results = await Promise.allSettled(pending);
419
684
  const failure = results.find((result) => result.status === "rejected");
420
- if (failure !== undefined)
421
- throw failure.reason;
685
+ if (failure === undefined) {
686
+ this.#collectionFailures.delete(sourceIdentityFailureKey);
687
+ return;
688
+ }
689
+ const reason = failure.reason;
690
+ this.#reportCollectionFailure(sourceIdentityFailureKey, (reason instanceof Error ? reason.message : String(reason))
691
+ .replace(/\s+/gu, " ")
692
+ .slice(0, 200));
422
693
  }
423
694
  }
424
695
  export function mergeInventoryWithSourceIdentities(inventory, sourceIdentities) {
@@ -845,15 +1116,4 @@ export function historyRest(spentMs) {
845
1116
  return 0;
846
1117
  return Math.min(1_000, Math.round(spentMs));
847
1118
  }
848
- async function abortableDelay(milliseconds, signal) {
849
- if (signal.aborted)
850
- return;
851
- await new Promise((resolve) => {
852
- const timeout = setTimeout(resolve, milliseconds);
853
- signal.addEventListener("abort", () => {
854
- clearTimeout(timeout);
855
- resolve();
856
- }, { once: true });
857
- });
858
- }
859
1119
  //# sourceMappingURL=collector.js.map