@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
@@ -4,7 +4,7 @@ import { chmod, mkdir, open, readFile, readdir, realpath, stat, unlink, writeFil
4
4
  import { homedir } from "node:os";
5
5
  import { basename, join, relative, resolve, sep } from "node:path";
6
6
  import { atomicReplace } from "../atomic-replace.js";
7
- import { DirectoryListingCache, scanJSONDocumentSource, } from "./json-document-source.js";
7
+ import { DirectoryListingCache, scanJSONDocumentSource, startedAfter, } from "./json-document-source.js";
8
8
  import { SQLiteConnections } from "./sqlite-connections.js";
9
9
  const maxSourceFiles = 256;
10
10
  const historicalFilesPerScan = 16;
@@ -17,6 +17,12 @@ const defaultInitialReplayBytes = 256 << 10;
17
17
  const readWindowBytes = 1 << 20;
18
18
  const maxLineBytes = 6 << 20;
19
19
  const maxCursorEntries = 16_384;
20
+ // A source or file that fails is skipped for a while, twice as long after
21
+ // each further failure, so one broken agent folder or database cannot stop
22
+ // the others or be retried on every scan.
23
+ const failureRetryBaseMs = 5_000;
24
+ const failureRetryMaxMs = 5 * 60_000;
25
+ const maxTrackedFailures = 256;
20
26
  /**
21
27
  * Source cursors in least-recently-used order, so a full store evicts the
22
28
  * cursors of sessions nobody reads any more instead of live ones (an
@@ -26,6 +32,8 @@ const maxCursorEntries = 16_384;
26
32
  */
27
33
  class RecentCursors extends Map {
28
34
  #used = new Set();
35
+ // Values from before `track`, for each cursor changed since.
36
+ #journal;
29
37
  get(key) {
30
38
  const value = super.get(key);
31
39
  if (value !== undefined)
@@ -33,9 +41,36 @@ class RecentCursors extends Map {
33
41
  return value;
34
42
  }
35
43
  set(key, value) {
44
+ this.#remember(key);
36
45
  super.delete(key);
37
46
  return super.set(key, value);
38
47
  }
48
+ delete(key) {
49
+ this.#remember(key);
50
+ return super.delete(key);
51
+ }
52
+ /** Start remembering the cursors a source changes. */
53
+ track() {
54
+ this.#journal = new Map();
55
+ }
56
+ /** Stop remembering; with `rollback`, undo every change since `track`. */
57
+ untrack(rollback) {
58
+ const journal = this.#journal;
59
+ this.#journal = undefined;
60
+ if (!rollback || journal === undefined)
61
+ return;
62
+ for (const [key, value] of journal) {
63
+ if (value === undefined)
64
+ super.delete(key);
65
+ else
66
+ super.set(key, value);
67
+ }
68
+ }
69
+ #remember(key) {
70
+ if (this.#journal !== undefined && !this.#journal.has(key)) {
71
+ this.#journal.set(key, super.get(key));
72
+ }
73
+ }
39
74
  /** Move cursors read since the last call to the recent end, then bound. */
40
75
  settle(limit) {
41
76
  for (const key of this.#used) {
@@ -67,7 +102,7 @@ export class ActivityCursorStore {
67
102
  }
68
103
  catch (error) {
69
104
  if (isNodeError(error) && error.code === "ENOENT")
70
- return new Map();
105
+ return { cursors: new Map() };
71
106
  throw error;
72
107
  }
73
108
  let value;
@@ -84,7 +119,7 @@ export class ActivityCursorStore {
84
119
  const recovered = new Map();
85
120
  await this.save(recovered);
86
121
  process.emitWarning("Saved activity checkpoints were unreadable. Available source history will be rescanned using stable record IDs.", { code: "PHIGON_CURSOR_RECOVERY" });
87
- return recovered;
122
+ return { cursors: recovered };
88
123
  }
89
124
  if (typeof value !== "object" ||
90
125
  value === null ||
@@ -106,15 +141,24 @@ export class ActivityCursorStore {
106
141
  result.set(key, offset);
107
142
  }
108
143
  }
109
- return result;
144
+ const scannedAt = "scanned_at" in value && typeof value.scanned_at === "string"
145
+ ? Date.parse(value.scanned_at)
146
+ : Number.NaN;
147
+ return Number.isFinite(scannedAt)
148
+ ? { cursors: result, scannedAt }
149
+ : { cursors: result };
110
150
  }
111
- async save(cursors) {
151
+ async save(cursors, scannedAt) {
112
152
  await mkdir(this.#dataDirectory, { recursive: true, mode: 0o700 });
113
153
  await makeDirectoryPrivate(this.#dataDirectory);
114
154
  // Keep the map's order (least recently used first) so a restarted
115
155
  // reader still knows which cursors are stale.
116
156
  const files = Object.fromEntries([...cursors.entries()].slice(-maxCursorEntries));
117
- const state = { version: 1, files };
157
+ const state = {
158
+ version: 1,
159
+ files,
160
+ ...(scannedAt === undefined ? {} : { scanned_at: new Date(scannedAt).toISOString() }),
161
+ };
118
162
  const temporary = `${this.#path}.${process.pid}.${randomUUID()}.tmp`;
119
163
  try {
120
164
  await writeFile(temporary, `${JSON.stringify(state)}\n`, {
@@ -148,6 +192,14 @@ export class JSONLSourceReader {
148
192
  // identical checkpoint every tick; rewriting and flushing it would cost a
149
193
  // synchronous disk write per scan interval without changing anything.
150
194
  #savedCursors;
195
+ #savedScannedAt;
196
+ // When the scan behind the saved cursors started, as loaded at startup.
197
+ // Undefined on the first start ever. A file without a cursor that began
198
+ // after it was created while the observer was stopped.
199
+ #lastRunAt;
200
+ // The cursors as the previous scan left them, which is where the next
201
+ // scan starts. Kept so a position costs no copy on every scan.
202
+ #scanStart;
151
203
  #initialScanComplete = false;
152
204
  #generation = 0;
153
205
  #committedGeneration = 0;
@@ -167,26 +219,40 @@ export class JSONLSourceReader {
167
219
  #documentListings = new DirectoryListingCache();
168
220
  #gooseConnections = new SQLiteConnections();
169
221
  #zedConnections = new SQLiteConnections();
170
- constructor(dataDirectory, replayOnFirstStart = false, initialReplayBytes = defaultInitialReplayBytes, initialReplaySessions = 1, mode = "live") {
222
+ #failures = new Map();
223
+ // Live sources that were skipped or failed while their first scan would
224
+ // have baselined them. Their first successful scan baselines instead, so
225
+ // an old archive is not read as new work once the source recovers.
226
+ #unbaselinedSources = new Set();
227
+ #clock;
228
+ constructor(dataDirectory, replayOnFirstStart = false, initialReplayBytes = defaultInitialReplayBytes, initialReplaySessions = 1, mode = "live", clock = Date.now) {
171
229
  this.#cursorStore = new ActivityCursorStore(dataDirectory);
172
230
  this.#replayOnFirstStart = replayOnFirstStart;
173
231
  this.#initialReplayBytes = Math.min(maxReadBytesPerFile, Math.max(1, initialReplayBytes));
174
232
  this.#initialReplaySessions = Math.min(maxSourceFiles, Math.max(1, initialReplaySessions));
175
233
  this.#mode = mode;
234
+ this.#clock = clock;
176
235
  }
177
236
  async scan(sources, maxRecords) {
178
237
  if (this.#cursors === undefined) {
179
238
  const loaded = await this.#cursorStore.load();
180
239
  this.#cursors = new RecentCursors();
181
- for (const [key, offset] of loaded)
240
+ for (const [key, offset] of loaded.cursors)
182
241
  this.#cursors.set(key, offset);
183
- this.#savedCursors = loaded;
242
+ this.#savedCursors = loaded.cursors;
243
+ this.#savedScannedAt = loaded.scannedAt;
244
+ this.#lastRunAt = loaded.scannedAt;
245
+ this.#scanStart = loaded.cursors;
184
246
  }
247
+ const scannedAt = this.#clock();
185
248
  const records = [];
186
249
  let fileCount = 0;
187
250
  let malformed = 0;
188
251
  let oversized = 0;
189
252
  let candidates = [];
253
+ const initialScan = !this.#initialScanComplete;
254
+ // Files of sources that missed their baseline, baselined on this scan.
255
+ const baselinePaths = new Set();
190
256
  const cacheKey = JSON.stringify(sources);
191
257
  if (this.#mode === "history" && this.#sourceCache?.key === cacheKey && Date.now() - this.#sourceCache.at < 5_000) {
192
258
  candidates = this.#sourceCache.files;
@@ -196,56 +262,19 @@ export class JSONLSourceReader {
196
262
  for (const source of sources) {
197
263
  if (source.format !== undefined && source.format !== "jsonl")
198
264
  continue;
199
- const pathPattern = source.pathPattern === undefined
200
- ? undefined
201
- : new RegExp(source.pathPattern, "u");
202
- for (const configuredRoot of source.roots) {
203
- const root = expandedActivityRoot(configuredRoot);
204
- const rootFiles = await listJSONLFiles(root);
205
- let rootReal;
206
- for (const file of rootFiles) {
207
- if (pathPattern !== undefined &&
208
- !pathPattern.test(relative(root, file.path).split(sep).join("/"))) {
209
- continue;
210
- }
211
- let canonicalPath;
212
- try {
213
- // The listing never follows links, so a listed file keeps its
214
- // canonical path while its root resolves to the same place and
215
- // the file keeps its identity. Resolve each file only once.
216
- rootReal ??= await realpath(root);
217
- const key = `${rootReal}\u0000${file.path}`;
218
- const identity = fileIdentityKey(file.identity);
219
- const known = this.#canonicalPaths.get(key);
220
- canonicalPath =
221
- known !== undefined && identity !== undefined &&
222
- known.identity === identity
223
- ? known.path
224
- : await realpath(file.path);
225
- if (identity !== undefined) {
226
- canonicalPaths.set(key, { path: canonicalPath, identity });
227
- }
228
- }
229
- catch (error) {
230
- if (isTransientSourceError(error))
231
- continue;
232
- throw error;
233
- }
234
- candidates.push({
235
- adapterId: source.adapterId,
236
- ...(source.sourceDisplayName === undefined
237
- ? {}
238
- : { sourceDisplayName: source.sourceDisplayName }),
239
- root,
240
- path: canonicalPath,
241
- modifiedMs: file.modifiedMs,
242
- identity: file.identity,
243
- });
244
- if (candidates.length > maxCursorEntries * 2) {
245
- candidates = reconciledSourceFiles(candidates);
246
- candidates.sort(recentFileFirst);
247
- candidates.length = maxCursorEntries;
248
- }
265
+ const key = sourceKey(source);
266
+ const sourceInitial = initialScan || this.#unbaselinedSources.has(key);
267
+ const listed = await this.#isolated(key, source.adapterId, sourceInitial, () => this.#listJSONLSource(source, canonicalPaths));
268
+ if (listed === undefined)
269
+ continue;
270
+ for (const file of listed) {
271
+ candidates.push(file);
272
+ if (sourceInitial && !initialScan)
273
+ baselinePaths.add(file.path);
274
+ if (candidates.length > maxCursorEntries * 2) {
275
+ candidates = reconciledSourceFiles(candidates);
276
+ candidates.sort(recentFileFirst);
277
+ candidates.length = maxCursorEntries;
249
278
  }
250
279
  }
251
280
  }
@@ -255,22 +284,24 @@ export class JSONLSourceReader {
255
284
  const recentCandidates = reconciledSourceFiles(candidates)
256
285
  .sort(recentFileFirst)
257
286
  .slice(0, maxCursorEntries);
258
- const initialScan = !this.#initialScanComplete;
259
287
  const selected = this.#mode === "history"
260
288
  ? await this.#pendingHistoryFiles(recentCandidates)
261
289
  : recentCandidates.slice(0, maxSourceFiles);
262
290
  const replayFiles = initialScan && this.#replayOnFirstStart
263
291
  ? newestFilesPerAdapter(selected, this.#initialReplaySessions)
264
292
  : new Set();
265
- if (initialScan && this.#mode === "live") {
293
+ if (this.#mode === "live" && (initialScan || baselinePaths.size > 0)) {
266
294
  for (const file of [...recentCandidates].reverse()) {
295
+ if (!initialScan && !baselinePaths.has(file.path))
296
+ continue;
267
297
  if (!replayFiles.has(file.path)) {
268
298
  try {
269
299
  await this.#baselineFile(file.root, file.path);
270
300
  }
271
301
  catch (error) {
272
- if (!isTransientSourceError(error))
273
- throw error;
302
+ if (!isTransientSourceError(error)) {
303
+ this.#recordFailure(fileKey(file.path), file.adapterId, error);
304
+ }
274
305
  }
275
306
  }
276
307
  }
@@ -290,6 +321,8 @@ export class JSONLSourceReader {
290
321
  fileCount++;
291
322
  continue;
292
323
  }
324
+ if (this.#skipping(fileKey(file.path)))
325
+ continue;
293
326
  let result;
294
327
  try {
295
328
  const rootReal = rootRealPaths.get(file.root) ??
@@ -298,10 +331,12 @@ export class JSONLSourceReader {
298
331
  result = await this.#readFile(file.adapterId, rootReal, file.path, replayFiles.has(file.path), file.sourceDisplayName, remainingRecords - reservedDatabaseRecords);
299
332
  }
300
333
  catch (error) {
301
- if (isTransientSourceError(error))
302
- continue;
303
- throw error;
334
+ if (!isTransientSourceError(error)) {
335
+ this.#recordFailure(fileKey(file.path), file.adapterId, error);
336
+ }
337
+ continue;
304
338
  }
339
+ this.#failures.delete(fileKey(file.path));
305
340
  fileCount++;
306
341
  records.push(...result.records);
307
342
  malformed += result.malformed;
@@ -315,25 +350,31 @@ export class JSONLSourceReader {
315
350
  }
316
351
  let documentsComplete = true;
317
352
  let documentFiles = 0;
353
+ const cursors = this.#cursors;
318
354
  for (const source of sources) {
319
355
  if (source.format !== "json-messages")
320
356
  continue;
321
- const documents = await scanJSONDocumentSource({
357
+ const key = sourceKey(source);
358
+ const sourceInitial = initialScan || this.#unbaselinedSources.has(key);
359
+ const documents = await this.#isolated(key, source.adapterId, sourceInitial, () => scanJSONDocumentSource({
322
360
  adapterId: source.adapterId,
323
361
  roots: source.roots.map(expandedActivityRoot),
324
362
  pathPattern: source.pathPattern ?? "\\.json$",
325
363
  messagesField: source.messagesField ?? "messages",
326
364
  ...source.document,
327
365
  }, {
328
- cursors: this.#cursors,
366
+ cursors,
329
367
  listings: this.#documentListings,
330
- initialScan,
368
+ initialScan: sourceInitial,
331
369
  replayOnFirstStart: this.#replayOnFirstStart,
332
370
  replaySessions: this.#initialReplaySessions,
333
371
  maxReplayRecords: Math.max(32, Math.floor(this.#initialReplayBytes / 1_024)),
334
372
  maxRecords: Math.max(0, remainingRecords - reservedDatabaseRecords),
335
373
  historical: this.#mode === "history",
336
- });
374
+ ...(this.#lastRunAt === undefined ? {} : { newSince: this.#lastRunAt }),
375
+ }));
376
+ if (documents === undefined)
377
+ continue;
337
378
  records.push(...documents.records);
338
379
  fileCount += documents.files;
339
380
  malformed += documents.malformed;
@@ -351,17 +392,21 @@ export class JSONLSourceReader {
351
392
  }
352
393
  // Loaded on demand for the same reason as the OpenCode source below.
353
394
  const { scanGooseSQLiteSources } = await import("./goose-sqlite-source.js");
354
- const goose = await scanGooseSQLiteSources({
395
+ const key = sourceKey(source);
396
+ const sourceInitial = initialScan || this.#unbaselinedSources.has(key);
397
+ const goose = await this.#isolated(key, source.adapterId, sourceInitial, () => scanGooseSQLiteSources({
355
398
  adapterId: source.adapterId,
356
399
  roots: source.roots.map(expandedActivityRoot),
357
- cursors: this.#cursors,
358
- initialScan,
400
+ cursors,
401
+ initialScan: sourceInitial,
359
402
  replayOnFirstStart: this.#replayOnFirstStart,
360
403
  maxReplayRecords: Math.max(32, Math.floor(this.#initialReplayBytes / 1_024)),
361
404
  maxRecords: remainingRecords - reservedDatabaseRecords,
362
405
  historical: this.#mode === "history",
363
406
  ...(this.#mode === "live" ? { connections: this.#gooseConnections } : { completedHistory: this.#completedHistoryDatabases }),
364
- });
407
+ }));
408
+ if (goose === undefined)
409
+ continue;
365
410
  records.push(...goose.records);
366
411
  fileCount += goose.files;
367
412
  malformed += goose.malformed;
@@ -378,18 +423,22 @@ export class JSONLSourceReader {
378
423
  break;
379
424
  }
380
425
  const { scanZedSQLiteSources } = await import("./zed-sqlite-source.js");
381
- const zed = await scanZedSQLiteSources({
426
+ const key = sourceKey(source);
427
+ const sourceInitial = initialScan || this.#unbaselinedSources.has(key);
428
+ const zed = await this.#isolated(key, source.adapterId, sourceInitial, () => scanZedSQLiteSources({
382
429
  adapterId: source.adapterId,
383
430
  roots: source.roots.map(expandedActivityRoot),
384
- cursors: this.#cursors,
385
- initialScan,
431
+ cursors,
432
+ initialScan: sourceInitial,
386
433
  replayOnFirstStart: this.#replayOnFirstStart,
387
434
  replaySessions: this.#initialReplaySessions,
388
435
  maxReplayRecords: Math.max(32, Math.floor(this.#initialReplayBytes / 1_024)),
389
436
  maxRecords: remainingRecords - reservedDatabaseRecords,
390
437
  historical: this.#mode === "history",
391
438
  ...(this.#mode === "live" ? { connections: this.#zedConnections } : { completedHistory: this.#completedHistoryDatabases }),
392
- });
439
+ }));
440
+ if (zed === undefined)
441
+ continue;
393
442
  records.push(...zed.records);
394
443
  fileCount += zed.files;
395
444
  malformed += zed.malformed;
@@ -411,16 +460,20 @@ export class JSONLSourceReader {
411
460
  // when an OpenCode data source is actually scanned so commands such as
412
461
  // pair, doctor, and help stay warning-free.
413
462
  const { scanOpenCodeSQLiteSources } = await import("./opencode-sqlite-source.js");
414
- const sqlite = await scanOpenCodeSQLiteSources({
463
+ const key = sourceKey(source);
464
+ const sourceInitial = initialScan || this.#unbaselinedSources.has(key);
465
+ const sqlite = await this.#isolated(key, source.adapterId, sourceInitial, () => scanOpenCodeSQLiteSources({
415
466
  adapterId: source.adapterId,
416
467
  roots: source.roots.map(expandedActivityRoot),
417
- cursors: this.#cursors,
418
- initialScan,
468
+ cursors,
469
+ initialScan: sourceInitial,
419
470
  replayOnFirstStart: this.#replayOnFirstStart,
420
471
  maxReplayRecords: Math.min(remainingRecords, Math.max(this.#mode === "history" ? 1 : 32, Math.floor(this.#initialReplayBytes / 1_024))),
421
472
  historical: this.#mode === "history",
422
473
  ...(this.#mode === "live" ? { connections: this.#openCodeConnections } : { completedHistory: this.#completedHistoryDatabases }),
423
- });
474
+ }));
475
+ if (sqlite === undefined)
476
+ continue;
424
477
  records.push(...sqlite.records);
425
478
  fileCount += sqlite.files;
426
479
  malformed += sqlite.malformed;
@@ -437,7 +490,9 @@ export class JSONLSourceReader {
437
490
  const checkpoint = Object.freeze({
438
491
  generation: ++this.#generation,
439
492
  cursors: new Map(this.#cursors),
493
+ scannedAt,
440
494
  });
495
+ this.#scanStart = checkpoint.cursors;
441
496
  const jsonlComplete = this.#mode !== "history" || selected.length === 0;
442
497
  return {
443
498
  records,
@@ -458,13 +513,113 @@ export class JSONLSourceReader {
458
513
  if (checkpoint.generation > this.#generation) {
459
514
  throw new Error("Activity source checkpoint is invalid.");
460
515
  }
516
+ // Saved once more when the time is missing, so the next start knows
517
+ // when this one ran. Later idle scans keep the time of the last change:
518
+ // a file read since then would have changed the cursors.
461
519
  if (this.#savedCursors === undefined ||
520
+ this.#savedScannedAt === undefined ||
462
521
  !sameCursors(checkpoint.cursors, this.#savedCursors)) {
463
- await this.#cursorStore.save(checkpoint.cursors);
522
+ await this.#cursorStore.save(checkpoint.cursors, checkpoint.scannedAt);
464
523
  this.#savedCursors = checkpoint.cursors;
524
+ this.#savedScannedAt = checkpoint.scannedAt;
465
525
  }
466
526
  this.#committedGeneration = checkpoint.generation;
467
527
  }
528
+ /**
529
+ * Where the next scan starts. A scan moves the cursors past every record
530
+ * it returns, before the caller delivers them; `rewind` puts the reader
531
+ * back here when they could not be delivered, so the next scan returns
532
+ * the same records instead of skipping them.
533
+ */
534
+ position() {
535
+ return {
536
+ cursors: this.#scanStart,
537
+ oversizedContinuations: new Set(this.#oversizedContinuations),
538
+ completedHistoryFiles: new Map(this.#completedHistoryFiles),
539
+ completedHistoryDatabases: new Map(this.#completedHistoryDatabases),
540
+ initialScanComplete: this.#initialScanComplete,
541
+ unbaselinedSources: new Set(this.#unbaselinedSources),
542
+ };
543
+ }
544
+ rewind(position) {
545
+ if (position.cursors === undefined) {
546
+ // Taken before the first scan: reload what is durable.
547
+ this.#cursors = undefined;
548
+ }
549
+ else {
550
+ this.#cursors = new RecentCursors();
551
+ for (const [key, offset] of position.cursors) {
552
+ this.#cursors.set(key, offset);
553
+ }
554
+ }
555
+ this.#scanStart = position.cursors;
556
+ this.#oversizedContinuations = new Set(position.oversizedContinuations);
557
+ this.#completedHistoryFiles = new Map(position.completedHistoryFiles);
558
+ this.#completedHistoryDatabases = new Map(position.completedHistoryDatabases);
559
+ this.#initialScanComplete = position.initialScanComplete;
560
+ this.#unbaselinedSources = new Set(position.unbaselinedSources);
561
+ }
562
+ /** Sources and files that failed and are skipped until `retryAt`. */
563
+ sourceFailures() {
564
+ return [...this.#failures.values()];
565
+ }
566
+ /**
567
+ * Run one source's scan so that its failure cannot fail the others. A
568
+ * failed source is recorded, skipped until its retry time, and its cursor
569
+ * changes are undone so none of its records is skipped. Returns undefined
570
+ * when the source was skipped or failed.
571
+ */
572
+ async #isolated(key, adapterId, baselines, scan) {
573
+ const remember = (missed) => {
574
+ if (this.#mode !== "live")
575
+ return;
576
+ if (missed && baselines)
577
+ this.#unbaselinedSources.add(key);
578
+ else if (!missed)
579
+ this.#unbaselinedSources.delete(key);
580
+ };
581
+ if (this.#skipping(key)) {
582
+ remember(true);
583
+ return undefined;
584
+ }
585
+ this.#cursors?.track();
586
+ try {
587
+ const result = await scan();
588
+ this.#cursors?.untrack(false);
589
+ this.#failures.delete(key);
590
+ remember(false);
591
+ return result;
592
+ }
593
+ catch (error) {
594
+ this.#cursors?.untrack(true);
595
+ this.#recordFailure(key, adapterId, error);
596
+ remember(true);
597
+ return undefined;
598
+ }
599
+ }
600
+ #skipping(key) {
601
+ const failure = this.#failures.get(key);
602
+ return failure !== undefined && this.#clock() < failure.retryAt.getTime();
603
+ }
604
+ #recordFailure(key, adapterId, error) {
605
+ const failures = (this.#failures.get(key)?.failures ?? 0) + 1;
606
+ const now = this.#clock();
607
+ this.#failures.delete(key);
608
+ this.#failures.set(key, Object.freeze({
609
+ adapterId,
610
+ failures,
611
+ message: (error instanceof Error ? error.message : String(error))
612
+ .replace(/\s+/gu, " ")
613
+ .slice(0, 200),
614
+ failedAt: new Date(now),
615
+ retryAt: new Date(now + Math.min(failureRetryMaxMs, failureRetryBaseMs * 2 ** Math.min(failures - 1, 16))),
616
+ }));
617
+ for (const oldest of this.#failures.keys()) {
618
+ if (this.#failures.size <= maxTrackedFailures)
619
+ break;
620
+ this.#failures.delete(oldest);
621
+ }
622
+ }
468
623
  /**
469
624
  * Close the database connections kept between scans. A later scan opens
470
625
  * them again, so this only releases files, for example before shutdown.
@@ -606,11 +761,71 @@ export class JSONLSourceReader {
606
761
  this.#setCursor(cursorId, legacyCursorId, offset + processedThrough);
607
762
  return { records, malformed, oversized, processedRecords };
608
763
  }
764
+ /** The files of one JSONL source, with their canonical paths. */
765
+ async #listJSONLSource(source, canonicalPaths) {
766
+ let files = [];
767
+ const pathPattern = source.pathPattern === undefined
768
+ ? undefined
769
+ : new RegExp(source.pathPattern, "u");
770
+ for (const configuredRoot of source.roots) {
771
+ const root = expandedActivityRoot(configuredRoot);
772
+ const rootFiles = await listJSONLFiles(root);
773
+ let rootReal;
774
+ for (const file of rootFiles) {
775
+ if (pathPattern !== undefined &&
776
+ !pathPattern.test(relative(root, file.path).split(sep).join("/"))) {
777
+ continue;
778
+ }
779
+ let canonicalPath;
780
+ try {
781
+ // The listing never follows links, so a listed file keeps its
782
+ // canonical path while its root resolves to the same place and
783
+ // the file keeps its identity. Resolve each file only once.
784
+ rootReal ??= await realpath(root);
785
+ const key = `${rootReal}\u0000${file.path}`;
786
+ const identity = fileIdentityKey(file.identity);
787
+ const known = this.#canonicalPaths.get(key);
788
+ canonicalPath =
789
+ known !== undefined && identity !== undefined &&
790
+ known.identity === identity
791
+ ? known.path
792
+ : await realpath(file.path);
793
+ if (identity !== undefined) {
794
+ canonicalPaths.set(key, { path: canonicalPath, identity });
795
+ }
796
+ }
797
+ catch (error) {
798
+ if (isTransientSourceError(error))
799
+ continue;
800
+ throw error;
801
+ }
802
+ files.push({
803
+ adapterId: source.adapterId,
804
+ ...(source.sourceDisplayName === undefined
805
+ ? {}
806
+ : { sourceDisplayName: source.sourceDisplayName }),
807
+ root,
808
+ path: canonicalPath,
809
+ modifiedMs: file.modifiedMs,
810
+ identity: file.identity,
811
+ });
812
+ if (files.length > maxCursorEntries * 2) {
813
+ files = reconciledSourceFiles(files);
814
+ files.sort(recentFileFirst);
815
+ files.length = maxCursorEntries;
816
+ }
817
+ }
818
+ }
819
+ return files;
820
+ }
609
821
  async #pendingHistoryFiles(files) {
610
822
  const pending = [];
611
823
  for (const file of files) {
612
824
  if (this.#completedHistoryFiles.get(file.path) === file.modifiedMs)
613
825
  continue;
826
+ // A failing file is retried later without holding the import open.
827
+ if (this.#skipping(fileKey(file.path)))
828
+ continue;
614
829
  try {
615
830
  const metadata = await stat(file.path);
616
831
  const offset = this.#cursors?.get(fileCursorId(file.path, metadata)) ??
@@ -625,8 +840,9 @@ export class JSONLSourceReader {
625
840
  }
626
841
  }
627
842
  catch (error) {
628
- if (!isTransientSourceError(error))
629
- throw error;
843
+ if (!isTransientSourceError(error)) {
844
+ this.#recordFailure(fileKey(file.path), file.adapterId, error);
845
+ }
630
846
  }
631
847
  }
632
848
  return pending;
@@ -655,7 +871,11 @@ export class JSONLSourceReader {
655
871
  return;
656
872
  if (this.#cursors?.has(fileCursorId(fileReal, metadata)) || this.#cursors?.has(digest(fileReal)))
657
873
  return;
658
- this.#setCursor(fileCursorId(fileReal, metadata), digest(fileReal), metadata.size);
874
+ // A session that began while the observer was stopped is read from its
875
+ // start; any other file without a cursor starts at its end, as before.
876
+ this.#setCursor(fileCursorId(fileReal, metadata), digest(fileReal), this.#lastRunAt !== undefined && startedAfter(metadata, this.#lastRunAt)
877
+ ? 0
878
+ : metadata.size);
659
879
  }
660
880
  }
661
881
  export function expandedActivityRoot(root) {
@@ -820,6 +1040,18 @@ function fileIdentityKey(identity) {
820
1040
  }
821
1041
  return `${identity.dev}:${identity.ino}:${identity.birthtimeMs}`;
822
1042
  }
1043
+ /**
1044
+ * Whether saved `cursors` are at the end of the file at `path`, so every
1045
+ * record in it was read. A file that grew or was replaced since does not
1046
+ * count.
1047
+ */
1048
+ export async function fileReadThrough(path, cursors) {
1049
+ const fileReal = await realpath(path);
1050
+ const metadata = await stat(fileReal);
1051
+ const offset = cursors.get(fileCursorId(fileReal, metadata)) ??
1052
+ cursors.get(digest(fileReal));
1053
+ return offset === metadata.size;
1054
+ }
823
1055
  function fileCursorId(path, metadata) {
824
1056
  const identity = metadata.ino > 0 || metadata.dev > 0
825
1057
  ? `inode:${metadata.dev}:${metadata.ino}:${metadata.birthtimeMs}`
@@ -843,6 +1075,12 @@ function newestFilesPerAdapter(files, limit) {
843
1075
  function configuredRoots(sources, format) {
844
1076
  return new Set(sources.flatMap((source) => source.format === format ? source.roots.map(expandedActivityRoot) : []));
845
1077
  }
1078
+ function sourceKey(source) {
1079
+ return JSON.stringify([source.format ?? "jsonl", source.adapterId, source.roots, source.pathPattern ?? ""]);
1080
+ }
1081
+ function fileKey(path) {
1082
+ return `file\u0000${path}`;
1083
+ }
846
1084
  function sameCursors(left, right) {
847
1085
  if (left.size !== right.size)
848
1086
  return false;
@@ -868,6 +1106,7 @@ function isTransientSourceError(error) {
868
1106
  error.code === "EACCES" ||
869
1107
  error.code === "EPERM" ||
870
1108
  error.code === "ENOTDIR" ||
1109
+ error.code === "EBUSY" ||
871
1110
  error.code === "ESTALE"));
872
1111
  }
873
1112
  //# sourceMappingURL=source-reader.js.map