@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,14 +1,62 @@
1
1
  import { type EventEnvelope } from "./generated/phigon/v1/telemetry_pb.js";
2
- import { BoundedEventQueue } from "./event-queue.js";
2
+ import { BoundedEventQueue, type QueueMetrics } from "./event-queue.js";
3
+ /**
4
+ * What `restore` did instead of loading the spool as it was:
5
+ * - `other_machine`: an earlier pairing's spool, moved to `directory`.
6
+ * - `damaged`: a spool that could not be read, moved to `directory`.
7
+ * - `over_capacity`: more events than the queue holds; the newest were kept
8
+ * and `dropped` of the oldest were left out.
9
+ */
10
+ export type SpoolRestoreNotice = Readonly<{
11
+ reason: "other_machine" | "damaged";
12
+ directory: string;
13
+ }> | Readonly<{
14
+ reason: "over_capacity";
15
+ dropped: number;
16
+ }>;
3
17
  /**
4
18
  * An encrypted append-only delivery spool. The in-memory queue remains the hot
5
19
  * path, while this log makes unacknowledged events recoverable after an
6
20
  * observer restart without writing prompt or tool content as plaintext.
21
+ *
22
+ * Records are group-committed: they are buffered and written with one
23
+ * flushed append at most 100 ms later, or at once by `flush`. An event is
24
+ * durable only after a flush, so a source checkpoint may move past an event
25
+ * only after `flush` returns (the collector's `flushDelivery`). Events the
26
+ * server already acknowledged are safe either way, and a lost
27
+ * acknowledgement or discard record only sends an event again.
7
28
  */
8
29
  export declare class FileBackedEventQueue extends BoundedEventQueue<EventEnvelope> {
9
30
  #private;
10
- constructor(dataDirectory: string, capacity?: number);
11
- restore(tenantID: string, machineID: string, credential: string): void;
31
+ constructor(dataDirectory: string, capacity?: number, maxBytes?: number);
32
+ /**
33
+ * Writes every buffered record with one flushed append. Throws when the
34
+ * spool cannot be written; the records stay buffered and the next write
35
+ * retries them.
36
+ */
37
+ flush(): void;
38
+ /**
39
+ * Adds the spool's size by bytes: the stored size of the unacknowledged
40
+ * events, which is what a compaction keeps, and the budget it must fit.
41
+ */
42
+ metrics(): QueueMetrics;
43
+ /**
44
+ * Loads the spool for this machine. None of these stops the observer, and
45
+ * the returned notice says which one happened:
46
+ * - A spool that an earlier pairing of this data directory left for
47
+ * another machine cannot be delivered as this one: it is moved aside
48
+ * with its stream cursor.
49
+ * - A spool that is too large to read safely, or holds a record that does
50
+ * not verify before its last line, is moved aside on its own and the
51
+ * queue starts empty. Its events were written before their sources'
52
+ * checkpoints moved, so they are not read again; the moved copy keeps
53
+ * them for an administrator.
54
+ * - A spool with more events than the queue holds, as after lowering
55
+ * PHIGON_ACTIVITY_QUEUE_CAPACITY, keeps the newest events, as a full
56
+ * queue does while running. The control plane records the skipped
57
+ * sequence numbers as a gap.
58
+ */
59
+ restore(tenantID: string, machineID: string, credential: string): SpoolRestoreNotice | undefined;
12
60
  enqueue(value: EventEnvelope, sequence: bigint, coalesceKey?: string): void;
13
61
  acknowledge(throughSequence: bigint): void;
14
62
  discard(sequence: bigint): boolean;
@@ -16,4 +64,14 @@ export declare class FileBackedEventQueue extends BoundedEventQueue<EventEnvelop
16
64
  replacePending(replace: (value: EventEnvelope) => EventEnvelope): void;
17
65
  }
18
66
  export declare function clearEventSpool(dataDirectory: string): void;
67
+ /**
68
+ * Moves what an earlier pairing of this data directory left behind out of
69
+ * the way, so a computer paired again as a new machine starts with an empty
70
+ * queue instead of refusing to read the old one. The encrypted spool, the
71
+ * stream cursor and `extraFiles` (such as the old credential, the only key
72
+ * that can still decrypt that spool) go to `retired-pairings/<time>-<machine>/`.
73
+ * Only the newest few pairings are kept. Returns that directory, or
74
+ * undefined when there was nothing to move.
75
+ */
76
+ export declare function retirePairingState(dataDirectory: string, machineID: string | undefined, extraFiles?: readonly string[]): string | undefined;
19
77
  //# sourceMappingURL=event-spool.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"event-spool.d.ts","sourceRoot":"","sources":["../../src/event-spool.ts"],"names":[],"mappings":"AAqBA,OAAO,EAEL,KAAK,aAAa,EACnB,MAAM,uCAAuC,CAAC;AAC/C,OAAO,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AAoCrD;;;;GAIG;AACH,qBAAa,oBAAqB,SAAQ,iBAAiB,CAAC,aAAa,CAAC;;gBAa5D,aAAa,EAAE,MAAM,EAAE,QAAQ,SAAO;IAMlD,OAAO,CAAC,QAAQ,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,IAAI;IAgD7D,OAAO,CACd,KAAK,EAAE,aAAa,EACpB,QAAQ,EAAE,MAAM,EAChB,WAAW,CAAC,EAAE,MAAM,GACnB,IAAI;IAeE,WAAW,CAAC,eAAe,EAAE,MAAM,GAAG,IAAI;IAO1C,OAAO,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO;IAUlC,qBAAqB,CAAC,eAAe,EAAE,MAAM,GAAG,IAAI;IAMpD,cAAc,CACrB,OAAO,EAAE,CAAC,KAAK,EAAE,aAAa,KAAK,aAAa,GAC/C,IAAI;CAoNR;AAED,wBAAgB,eAAe,CAAC,aAAa,EAAE,MAAM,GAAG,IAAI,CAkB3D"}
1
+ {"version":3,"file":"event-spool.d.ts","sourceRoot":"","sources":["../../src/event-spool.ts"],"names":[],"mappings":"AAuBA,OAAO,EAEL,KAAK,aAAa,EACnB,MAAM,uCAAuC,CAAC;AAC/C,OAAO,EAAE,iBAAiB,EAAE,KAAK,YAAY,EAAE,MAAM,kBAAkB,CAAC;AAoBxE;;;;;;GAMG;AACH,MAAM,MAAM,kBAAkB,GAC1B,QAAQ,CAAC;IAAE,MAAM,EAAE,eAAe,GAAG,SAAS,CAAC;IAAC,SAAS,EAAE,MAAM,CAAA;CAAE,CAAC,GACpE,QAAQ,CAAC;IAAE,MAAM,EAAE,eAAe,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAAC,CAAC;AA8B3D;;;;;;;;;;;GAWG;AACH,qBAAa,oBAAqB,SAAQ,iBAAiB,CAAC,aAAa,CAAC;;gBAqBtE,aAAa,EAAE,MAAM,EACrB,QAAQ,SAAO,EACf,QAAQ,SAAuB;IAejC;;;;OAIG;IACH,KAAK,IAAI,IAAI;IAuBb;;;OAGG;IACM,OAAO,IAAI,YAAY;IAQhC;;;;;;;;;;;;;;;OAeG;IACH,OAAO,CACL,QAAQ,EAAE,MAAM,EAChB,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,MAAM,GACjB,kBAAkB,GAAG,SAAS;IAwExB,OAAO,CACd,KAAK,EAAE,aAAa,EACpB,QAAQ,EAAE,MAAM,EAChB,WAAW,CAAC,EAAE,MAAM,GACnB,IAAI;IAuBE,WAAW,CAAC,eAAe,EAAE,MAAM,GAAG,IAAI;IAO1C,OAAO,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO;IAUlC,qBAAqB,CAAC,eAAe,EAAE,MAAM,GAAG,IAAI;IAMpD,cAAc,CACrB,OAAO,EAAE,CAAC,KAAK,EAAE,aAAa,KAAK,aAAa,GAC/C,IAAI;CA6RR;AAED,wBAAgB,eAAe,CAAC,aAAa,EAAE,MAAM,GAAG,IAAI,CAE3D;AAED;;;;;;;;GAQG;AACH,wBAAgB,kBAAkB,CAChC,aAAa,EAAE,MAAM,EACrB,SAAS,EAAE,MAAM,GAAG,SAAS,EAC7B,UAAU,GAAE,SAAS,MAAM,EAAO,GACjC,MAAM,GAAG,SAAS,CAQpB"}
@@ -1,37 +1,125 @@
1
1
  import { makeDirectoryPrivateSync } from "./private-directory.js";
2
2
  import { createCipheriv, createDecipheriv, createHash, randomBytes, } from "node:crypto";
3
- import { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
3
+ import { appendFileSync, chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
4
4
  import { join } from "node:path";
5
5
  import { fromBinary, toBinary } from "@bufbuild/protobuf";
6
+ import { atomicReplaceSync } from "./atomic-replace.js";
6
7
  import { EventEnvelopeSchema, } from "./generated/phigon/v1/telemetry_pb.js";
7
8
  import { BoundedEventQueue } from "./event-queue.js";
9
+ import { streamStateFileName } from "./stream-state.js";
8
10
  const spoolVersion = 1;
9
11
  const spoolFileName = "telemetry-spool.jsonl";
10
- const maxSpoolBytes = 64 << 20;
11
- const compactAfterOperations = 1024;
12
+ const defaultMaxSpoolBytes = 64 << 20;
13
+ // Buffered records are written together, at most this long after the first.
14
+ const flushDelayMs = 100;
15
+ // A burst this large is written at once rather than held in memory.
16
+ const flushAfterBytes = 1 << 20;
17
+ // The spool is rewritten once records that no longer matter (acknowledged,
18
+ // discarded or replaced events, and acknowledgements) outweigh the queued
19
+ // events and pass this size, or a quarter of the budget if that is smaller.
20
+ const compactAfterDeadBytes = 1 << 20;
12
21
  const scopedIDPattern = /^[A-Za-z0-9._:-]{1,128}$/u;
22
+ const retiredPairingsDirectory = "retired-pairings";
23
+ const damagedSpoolsDirectory = "damaged-spools";
24
+ // Each retired or damaged spool can be as large as the spool budget.
25
+ const retainedRetiredPairings = 3;
13
26
  /**
14
27
  * An encrypted append-only delivery spool. The in-memory queue remains the hot
15
28
  * path, while this log makes unacknowledged events recoverable after an
16
29
  * observer restart without writing prompt or tool content as plaintext.
30
+ *
31
+ * Records are group-committed: they are buffered and written with one
32
+ * flushed append at most 100 ms later, or at once by `flush`. An event is
33
+ * durable only after a flush, so a source checkpoint may move past an event
34
+ * only after `flush` returns (the collector's `flushDelivery`). Events the
35
+ * server already acknowledged are safe either way, and a lost
36
+ * acknowledgement or discard record only sends an event again.
17
37
  */
18
38
  export class FileBackedEventQueue extends BoundedEventQueue {
19
39
  #dataDirectory;
20
40
  #path;
41
+ #maxBytes;
42
+ #compactAfterDeadBytes;
21
43
  #scope;
22
44
  #key;
23
- #operations = 0;
24
- // Appends run synchronously on the event loop for every event and every
25
- // acknowledgement. Prepare the private directory and secure the file once,
26
- // and track the spool size here instead of stat-ing it before each append.
45
+ // Prepare the private directory and secure the file once, and track the
46
+ // spool size here instead of stat-ing it before each write.
27
47
  #directoryPrepared = false;
28
48
  #fileSecured = false;
29
49
  #size;
30
- constructor(dataDirectory, capacity = 2048) {
50
+ // Records accepted in memory but not yet written, oldest first.
51
+ #buffer = [];
52
+ #bufferBytes = 0;
53
+ #flushTimer;
54
+ // Why the last write failed. A failed append can leave a partial record,
55
+ // so the next write rewrites the whole spool instead of appending.
56
+ #writeFailure;
57
+ constructor(dataDirectory, capacity = 2048, maxBytes = defaultMaxSpoolBytes) {
31
58
  super(capacity);
59
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) {
60
+ throw new Error("Telemetry spool byte budget must be a positive integer.");
61
+ }
32
62
  this.#dataDirectory = dataDirectory;
33
63
  this.#path = join(dataDirectory, spoolFileName);
64
+ this.#maxBytes = maxBytes;
65
+ this.#compactAfterDeadBytes = Math.min(compactAfterDeadBytes, Math.max(1, Math.floor(maxBytes / 4)));
66
+ }
67
+ /**
68
+ * Writes every buffered record with one flushed append. Throws when the
69
+ * spool cannot be written; the records stay buffered and the next write
70
+ * retries them.
71
+ */
72
+ flush() {
73
+ if (this.#flushTimer !== undefined) {
74
+ clearTimeout(this.#flushTimer);
75
+ this.#flushTimer = undefined;
76
+ }
77
+ if (this.#writeFailure !== undefined) {
78
+ this.#compact();
79
+ return;
80
+ }
81
+ if (this.#buffer.length === 0)
82
+ return;
83
+ const encoded = this.#buffer.join("");
84
+ try {
85
+ this.#appendToFile(encoded);
86
+ }
87
+ catch (error) {
88
+ this.#writeFailure = error;
89
+ this.#size = undefined;
90
+ throw error;
91
+ }
92
+ this.#size = this.#currentSize() + this.#bufferBytes;
93
+ this.#buffer = [];
94
+ this.#bufferBytes = 0;
95
+ }
96
+ /**
97
+ * Adds the spool's size by bytes: the stored size of the unacknowledged
98
+ * events, which is what a compaction keeps, and the budget it must fit.
99
+ */
100
+ metrics() {
101
+ return Object.freeze({
102
+ ...super.metrics(),
103
+ bytes: this.queuedBytes(),
104
+ byteCapacity: this.#maxBytes,
105
+ });
34
106
  }
107
+ /**
108
+ * Loads the spool for this machine. None of these stops the observer, and
109
+ * the returned notice says which one happened:
110
+ * - A spool that an earlier pairing of this data directory left for
111
+ * another machine cannot be delivered as this one: it is moved aside
112
+ * with its stream cursor.
113
+ * - A spool that is too large to read safely, or holds a record that does
114
+ * not verify before its last line, is moved aside on its own and the
115
+ * queue starts empty. Its events were written before their sources'
116
+ * checkpoints moved, so they are not read again; the moved copy keeps
117
+ * them for an administrator.
118
+ * - A spool with more events than the queue holds, as after lowering
119
+ * PHIGON_ACTIVITY_QUEUE_CAPACITY, keeps the newest events, as a full
120
+ * queue does while running. The control plane records the skipped
121
+ * sequence numbers as a gap.
122
+ */
35
123
  restore(tenantID, machineID, credential) {
36
124
  validateScope(tenantID, machineID);
37
125
  if (!credential.startsWith("phg_m_") || credential.length > 256) {
@@ -45,34 +133,57 @@ export class FileBackedEventQueue extends BoundedEventQueue {
45
133
  !this.#key?.equals(key)) {
46
134
  throw new Error("Telemetry spool scope changed while the observer was running.");
47
135
  }
48
- return;
136
+ return undefined;
49
137
  }
50
138
  this.#scope = scope;
51
139
  this.#key = key;
52
140
  try {
53
141
  if (!existsSync(this.#path))
54
- return;
142
+ return undefined;
55
143
  const metadata = statSync(this.#path);
56
- if (!metadata.isFile() || metadata.size > maxSpoolBytes) {
57
- throw new Error("Telemetry spool exceeds its safe storage bound.");
144
+ if (!metadata.isFile() || metadata.size > this.#maxBytes) {
145
+ return this.#setAsideDamaged();
58
146
  }
59
147
  const restored = new BoundedEventQueue(this.metrics().capacity);
60
148
  const raw = readFileSync(this.#path, "utf8");
149
+ // Every record names its machine in plain text, so another machine's
150
+ // spool is told apart from a damaged one.
151
+ const owner = spoolOwner(raw);
152
+ if (owner !== undefined &&
153
+ (owner.tenantID !== tenantID || owner.machineID !== machineID)) {
154
+ this.#size = undefined;
155
+ const directory = retirePairingState(this.#dataDirectory, owner.machineID);
156
+ return directory === undefined
157
+ ? undefined
158
+ : { reason: "other_machine", directory };
159
+ }
61
160
  const lines = raw.split("\n");
62
161
  const completeLines = lines.slice(0, -1);
63
- for (const line of completeLines) {
64
- if (line.length === 0)
65
- continue;
66
- const record = parseRecord(line, scope);
67
- this.#restoreRecord(record, restored);
162
+ // Events left out because the queue was full, until a later record
163
+ // acknowledges or discards them.
164
+ const dropped = new Set();
165
+ try {
166
+ for (const line of completeLines) {
167
+ if (line.length === 0)
168
+ continue;
169
+ const record = parseRecord(line, scope);
170
+ this.#restoreRecord(record, restored, Buffer.byteLength(line) + 1, dropped);
171
+ }
172
+ }
173
+ catch {
174
+ return this.#setAsideDamaged();
68
175
  }
69
176
  if (!raw.endsWith("\n") && (lines.at(-1)?.length ?? 0) > 0) {
70
- // A crash can leave only the final append incomplete. It was never a
71
- // durable event, so ignore that tail and compact the verified prefix.
177
+ // A crash can leave only the final write incomplete. Its records
178
+ // were never flushed, so no source checkpoint moved past them:
179
+ // ignore that tail and compact the verified prefix.
72
180
  }
73
- this.#writeCompactedQueue(restored);
181
+ const sizes = this.#writeCompactedQueue(restored);
74
182
  this.replaceState(restored);
75
- this.#operations = 0;
183
+ this.reweigh(sizes);
184
+ const acknowledged = restored.metrics().acknowledged;
185
+ const lost = [...dropped].filter((sequence) => sequence > acknowledged).length;
186
+ return lost === 0 ? undefined : { reason: "over_capacity", dropped: lost };
76
187
  }
77
188
  catch (error) {
78
189
  this.#scope = undefined;
@@ -87,24 +198,33 @@ export class FileBackedEventQueue extends BoundedEventQueue {
87
198
  "before an unacknowledged event could be discarded.");
88
199
  }
89
200
  validateEventScope(value, scope, sequence);
201
+ // While the disk refuses writes, refuse new events before they reach
202
+ // memory, as an unbuffered append did; the collector reads them again.
203
+ if (this.#writeFailure !== undefined)
204
+ this.flush();
90
205
  const record = this.#encryptedRecord(value, sequence, coalesceKey);
91
- this.#append(record);
92
- super.enqueue(value, sequence, coalesceKey);
206
+ const bytes = this.#append(record);
207
+ super.enqueue(value, sequence, coalesceKey, bytes);
93
208
  this.#compactIfNeeded();
94
209
  }
210
+ // Removals apply in memory before they are buffered. On a full spool the
211
+ // compaction that makes room for the record then already leaves the
212
+ // removed events out, so an acknowledgement can always drain the spool.
213
+ // A removal lost in a crash only sends the event again, so removals are
214
+ // written lazily with the next group of records.
95
215
  acknowledge(throughSequence) {
96
216
  if (throughSequence <= this.metrics().acknowledged)
97
217
  return;
98
- this.#append(this.#sequenceRecord("acknowledge", throughSequence));
99
218
  super.acknowledge(throughSequence);
219
+ this.#append(this.#sequenceRecord("acknowledge", throughSequence));
100
220
  this.#compactIfNeeded();
101
221
  }
102
222
  discard(sequence) {
103
223
  if (!this.pending().some((event) => event.sequence === sequence)) {
104
224
  return false;
105
225
  }
106
- this.#append(this.#sequenceRecord("discard", sequence));
107
226
  const discarded = super.discard(sequence);
227
+ this.#append(this.#sequenceRecord("discard", sequence));
108
228
  this.#compactIfNeeded();
109
229
  return discarded;
110
230
  }
@@ -122,7 +242,7 @@ export class FileBackedEventQueue extends BoundedEventQueue {
122
242
  });
123
243
  this.#compact();
124
244
  }
125
- #restoreRecord(record, target) {
245
+ #restoreRecord(record, target, bytes, dropped) {
126
246
  const sequence = parseSequence(record.sequence);
127
247
  if (record.operation === "acknowledge") {
128
248
  target.acknowledge(sequence);
@@ -130,13 +250,28 @@ export class FileBackedEventQueue extends BoundedEventQueue {
130
250
  }
131
251
  if (record.operation === "discard") {
132
252
  target.discard(sequence);
253
+ dropped.delete(sequence);
133
254
  return;
134
255
  }
135
256
  const event = this.#decrypt(record);
136
- if (!target.canEnqueue(record.coalesce_key)) {
137
- throw new Error("Telemetry spool contains more events than the configured queue capacity.");
257
+ if (sequence > target.metrics().acknowledged &&
258
+ !target.canEnqueue(record.coalesce_key)) {
259
+ // A full queue makes room by leaving out its oldest event.
260
+ const oldest = target.metrics().oldestSequence;
261
+ if (oldest !== undefined)
262
+ dropped.add(oldest);
138
263
  }
139
- target.enqueue(event, sequence, record.coalesce_key);
264
+ target.enqueue(event, sequence, record.coalesce_key, bytes);
265
+ }
266
+ /** Moves an unreadable spool aside so the queue can start empty. */
267
+ #setAsideDamaged() {
268
+ this.#size = undefined;
269
+ removeSpoolFiles(this.#dataDirectory, false);
270
+ const directory = moveAside(this.#dataDirectory, damagedSpoolsDirectory, "damaged", [spoolFileName]);
271
+ if (directory === undefined) {
272
+ throw new Error("Telemetry spool disappeared while it was being moved aside.");
273
+ }
274
+ return { reason: "damaged", directory };
140
275
  }
141
276
  #encryptedRecord(event, sequence, coalesceKey) {
142
277
  const scope = this.#requireScope();
@@ -191,16 +326,45 @@ export class FileBackedEventQueue extends BoundedEventQueue {
191
326
  sequence: sequence.toString(),
192
327
  };
193
328
  }
329
+ /** Buffer one record for the next group write and return its stored size. */
194
330
  #append(record) {
195
- this.#prepareDirectory();
196
331
  const encoded = `${JSON.stringify(record)}\n`;
197
332
  const bytes = Buffer.byteLength(encoded);
198
- if (this.#currentSize() + bytes > maxSpoolBytes) {
333
+ if (this.#storedBytes() + bytes > this.#maxBytes) {
199
334
  this.#compact();
200
335
  }
201
- if (this.#currentSize() + bytes > maxSpoolBytes) {
336
+ if (this.#storedBytes() + bytes > this.#maxBytes) {
202
337
  throw new Error("Telemetry spool is full; collection stopped before data loss.");
203
338
  }
339
+ this.#buffer.push(encoded);
340
+ this.#bufferBytes += bytes;
341
+ if (this.#bufferBytes >= flushAfterBytes) {
342
+ this.#flushQuietly();
343
+ }
344
+ else {
345
+ this.#flushTimer ??= setTimeout(() => {
346
+ this.#flushTimer = undefined;
347
+ this.#flushQuietly();
348
+ }, flushDelayMs).unref();
349
+ }
350
+ return bytes;
351
+ }
352
+ /**
353
+ * A write that runs outside a caller who could handle its failure. The
354
+ * failure is kept: the next event is refused until a write succeeds, and
355
+ * no source checkpoint moves past an unwritten event.
356
+ */
357
+ #flushQuietly() {
358
+ try {
359
+ this.flush();
360
+ }
361
+ catch {
362
+ // Kept in #writeFailure; the next write retries.
363
+ }
364
+ }
365
+ /** One flushed append of `encoded`. */
366
+ #appendToFile(encoded) {
367
+ this.#prepareDirectory();
204
368
  if (this.#currentSize() === 0)
205
369
  this.#fileSecured = false;
206
370
  const options = {
@@ -225,8 +389,10 @@ export class FileBackedEventQueue extends BoundedEventQueue {
225
389
  chmodSync(this.#path, 0o600);
226
390
  this.#fileSecured = true;
227
391
  }
228
- this.#size = this.#currentSize() + bytes;
229
- this.#operations++;
392
+ }
393
+ /** The spool's size once the buffered records are written. */
394
+ #storedBytes() {
395
+ return this.#currentSize() + this.#bufferBytes;
230
396
  }
231
397
  #prepareDirectory() {
232
398
  if (this.#directoryPrepared)
@@ -249,13 +415,19 @@ export class FileBackedEventQueue extends BoundedEventQueue {
249
415
  return this.#size;
250
416
  }
251
417
  #compactIfNeeded() {
252
- if (this.#operations >= compactAfterOperations)
418
+ // A compaction rewrites the queued events, so rewriting only once the
419
+ // records it drops outweigh them keeps its cost proportional to the
420
+ // records appended since the last one.
421
+ const live = this.queuedBytes();
422
+ const dead = this.#storedBytes() - live;
423
+ if (dead >= this.#compactAfterDeadBytes && dead > live)
253
424
  this.#compact();
254
425
  }
426
+ /** Rewrite the spool from memory, which also writes every buffered record. */
255
427
  #compact() {
256
- this.#writeCompactedQueue(this);
257
- this.#operations = 0;
428
+ this.reweigh(this.#writeCompactedQueue(this));
258
429
  }
430
+ /** Rewrite the spool as `queue` and return each queued event's stored size. */
259
431
  #writeCompactedQueue(queue) {
260
432
  this.#requireScope();
261
433
  mkdirSync(this.#dataDirectory, { recursive: true, mode: 0o700 });
@@ -264,8 +436,9 @@ export class FileBackedEventQueue extends BoundedEventQueue {
264
436
  this.#sequenceRecord("acknowledge", queue.metrics().acknowledged),
265
437
  ...queue.entries().map((entry) => this.#encryptedRecord(entry.value, entry.sequence, entry.coalesceKey)),
266
438
  ];
267
- const encoded = `${records.map((record) => JSON.stringify(record)).join("\n")}\n`;
268
- if (Buffer.byteLength(encoded) > maxSpoolBytes) {
439
+ const lines = records.map((record) => JSON.stringify(record));
440
+ const encoded = `${lines.join("\n")}\n`;
441
+ if (Buffer.byteLength(encoded) > this.#maxBytes) {
269
442
  throw new Error("Telemetry spool is full; collection stopped before data loss.");
270
443
  }
271
444
  const temporaryPath = `${this.#path}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`;
@@ -277,11 +450,19 @@ export class FileBackedEventQueue extends BoundedEventQueue {
277
450
  flush: true,
278
451
  });
279
452
  this.#size = undefined;
280
- renameSync(temporaryPath, this.#path);
453
+ atomicReplaceSync(temporaryPath, this.#path);
281
454
  chmodSync(this.#path, 0o600);
282
455
  this.#directoryPrepared = true;
283
456
  this.#fileSecured = true;
284
457
  this.#size = Buffer.byteLength(encoded);
458
+ // Memory already holds every buffered record's effect.
459
+ this.#buffer = [];
460
+ this.#bufferBytes = 0;
461
+ this.#writeFailure = undefined;
462
+ if (this.#flushTimer !== undefined) {
463
+ clearTimeout(this.#flushTimer);
464
+ this.#flushTimer = undefined;
465
+ }
285
466
  }
286
467
  catch (error) {
287
468
  try {
@@ -292,6 +473,8 @@ export class FileBackedEventQueue extends BoundedEventQueue {
292
473
  }
293
474
  throw error;
294
475
  }
476
+ // The first line is the acknowledgement; each event is one line.
477
+ return lines.slice(1).map((line) => Buffer.byteLength(line) + 1);
295
478
  }
296
479
  #requireScope() {
297
480
  if (this.#scope === undefined) {
@@ -307,6 +490,50 @@ export class FileBackedEventQueue extends BoundedEventQueue {
307
490
  }
308
491
  }
309
492
  export function clearEventSpool(dataDirectory) {
493
+ removeSpoolFiles(dataDirectory, true);
494
+ }
495
+ /**
496
+ * Moves what an earlier pairing of this data directory left behind out of
497
+ * the way, so a computer paired again as a new machine starts with an empty
498
+ * queue instead of refusing to read the old one. The encrypted spool, the
499
+ * stream cursor and `extraFiles` (such as the old credential, the only key
500
+ * that can still decrypt that spool) go to `retired-pairings/<time>-<machine>/`.
501
+ * Only the newest few pairings are kept. Returns that directory, or
502
+ * undefined when there was nothing to move.
503
+ */
504
+ export function retirePairingState(dataDirectory, machineID, extraFiles = []) {
505
+ removeSpoolFiles(dataDirectory, false);
506
+ return moveAside(dataDirectory, retiredPairingsDirectory, machineID ?? "unknown", [spoolFileName, streamStateFileName, ...extraFiles]);
507
+ }
508
+ /**
509
+ * Moves the `names` that exist into a new `<rootName>/<time>-<label>-*`
510
+ * directory and keeps only the newest few such directories. Returns the new
511
+ * directory, or undefined when there was nothing to move.
512
+ */
513
+ function moveAside(dataDirectory, rootName, label, names) {
514
+ const present = names.filter((name) => existsSync(join(dataDirectory, name)));
515
+ if (present.length === 0)
516
+ return undefined;
517
+ const root = join(dataDirectory, rootName);
518
+ mkdirSync(root, { recursive: true, mode: 0o700 });
519
+ chmodSync(root, 0o700);
520
+ // The time first, so names sort oldest first; Windows refuses ":" in names.
521
+ const stamp = new Date().toISOString().replace(/[:.]/gu, "-");
522
+ const safeLabel = label.replace(/[^A-Za-z0-9._-]/gu, "_");
523
+ const destination = mkdtempSync(join(root, `${stamp}-${safeLabel}-`));
524
+ for (const name of present) {
525
+ atomicReplaceSync(join(dataDirectory, name), join(destination, name));
526
+ }
527
+ const kept = readdirSync(root, { withFileTypes: true })
528
+ .filter((entry) => entry.isDirectory())
529
+ .map((entry) => entry.name)
530
+ .sort();
531
+ for (const name of kept.slice(0, -retainedRetiredPairings)) {
532
+ rmSync(join(root, name), { recursive: true, force: true });
533
+ }
534
+ return destination;
535
+ }
536
+ function removeSpoolFiles(dataDirectory, includeSpool) {
310
537
  let names;
311
538
  try {
312
539
  names = readdirSync(dataDirectory);
@@ -318,7 +545,7 @@ export function clearEventSpool(dataDirectory) {
318
545
  }
319
546
  for (const name of names) {
320
547
  const temporary = name.startsWith(`${spoolFileName}.`) && name.endsWith(".tmp");
321
- if (name !== spoolFileName && !temporary)
548
+ if (!temporary && !(includeSpool && name === spoolFileName))
322
549
  continue;
323
550
  try {
324
551
  unlinkSync(join(dataDirectory, name));
@@ -329,6 +556,28 @@ export function clearEventSpool(dataDirectory) {
329
556
  }
330
557
  }
331
558
  }
559
+ /** The machine that wrote a spool, from its first record. */
560
+ function spoolOwner(raw) {
561
+ const end = raw.indexOf("\n");
562
+ if (end < 0)
563
+ return undefined;
564
+ let value;
565
+ try {
566
+ value = JSON.parse(raw.slice(0, end));
567
+ }
568
+ catch {
569
+ return undefined;
570
+ }
571
+ if (typeof value !== "object" || value === null ||
572
+ !("version" in value) || value.version !== spoolVersion ||
573
+ !("tenant_id" in value) || typeof value.tenant_id !== "string" ||
574
+ !("machine_id" in value) || typeof value.machine_id !== "string" ||
575
+ !scopedIDPattern.test(value.tenant_id) ||
576
+ !scopedIDPattern.test(value.machine_id)) {
577
+ return undefined;
578
+ }
579
+ return { tenantID: value.tenant_id, machineID: value.machine_id };
580
+ }
332
581
  function parseRecord(line, scope) {
333
582
  let value;
334
583
  try {