@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.
- package/README.md +194 -11
- package/dist/bin/guardian-runner-darwin-amd64 +0 -0
- package/dist/bin/guardian-runner-darwin-arm64 +0 -0
- package/dist/bin/guardian-runner-linux-amd64 +0 -0
- package/dist/bin/guardian-runner-linux-arm64 +0 -0
- package/dist/bin/guardian-runner-windows-amd64.exe +0 -0
- package/dist/bin/guardian-runner-windows-arm64.exe +0 -0
- package/dist/src/abortable-delay.d.ts +10 -0
- package/dist/src/abortable-delay.d.ts.map +1 -0
- package/dist/src/abortable-delay.js +21 -0
- package/dist/src/abortable-delay.js.map +1 -0
- package/dist/src/activity/adapters/claude.d.ts.map +1 -1
- package/dist/src/activity/adapters/claude.js +9 -1
- package/dist/src/activity/adapters/claude.js.map +1 -1
- package/dist/src/activity/adapters/openai-compatible.d.ts.map +1 -1
- package/dist/src/activity/adapters/openai-compatible.js +9 -1
- package/dist/src/activity/adapters/openai-compatible.js.map +1 -1
- package/dist/src/activity/collector.d.ts +31 -1
- package/dist/src/activity/collector.d.ts.map +1 -1
- package/dist/src/activity/collector.js +337 -77
- package/dist/src/activity/collector.js.map +1 -1
- package/dist/src/activity/ingest.d.ts +9 -0
- package/dist/src/activity/ingest.d.ts.map +1 -1
- package/dist/src/activity/ingest.js +92 -3
- package/dist/src/activity/ingest.js.map +1 -1
- package/dist/src/activity/json-document-source.d.ts +15 -0
- package/dist/src/activity/json-document-source.d.ts.map +1 -1
- package/dist/src/activity/json-document-source.js +19 -2
- package/dist/src/activity/json-document-source.js.map +1 -1
- package/dist/src/activity/native-capabilities.d.ts.map +1 -1
- package/dist/src/activity/native-capabilities.js +8 -12
- package/dist/src/activity/native-capabilities.js.map +1 -1
- package/dist/src/activity/opencode-sqlite-source.js +1 -0
- package/dist/src/activity/opencode-sqlite-source.js.map +1 -1
- package/dist/src/activity/source-reader.d.ts +44 -3
- package/dist/src/activity/source-reader.d.ts.map +1 -1
- package/dist/src/activity/source-reader.js +325 -86
- package/dist/src/activity/source-reader.js.map +1 -1
- package/dist/src/agent-action-executor.d.ts +5 -0
- package/dist/src/agent-action-executor.d.ts.map +1 -1
- package/dist/src/agent-action-executor.js +9 -6
- package/dist/src/agent-action-executor.js.map +1 -1
- package/dist/src/atomic-replace.d.ts +7 -0
- package/dist/src/atomic-replace.d.ts.map +1 -1
- package/dist/src/atomic-replace.js +28 -3
- package/dist/src/atomic-replace.js.map +1 -1
- package/dist/src/bridge-journal.d.ts +32 -0
- package/dist/src/bridge-journal.d.ts.map +1 -0
- package/dist/src/bridge-journal.js +113 -0
- package/dist/src/bridge-journal.js.map +1 -0
- package/dist/src/cli.d.ts +7 -1
- package/dist/src/cli.d.ts.map +1 -1
- package/dist/src/cli.js +490 -43
- package/dist/src/cli.js.map +1 -1
- package/dist/src/collection-notice.d.ts +34 -0
- package/dist/src/collection-notice.d.ts.map +1 -0
- package/dist/src/collection-notice.js +73 -0
- package/dist/src/collection-notice.js.map +1 -0
- package/dist/src/config.d.ts +26 -0
- package/dist/src/config.d.ts.map +1 -1
- package/dist/src/config.js +88 -33
- package/dist/src/config.js.map +1 -1
- package/dist/src/content-redaction.d.ts +17 -1
- package/dist/src/content-redaction.d.ts.map +1 -1
- package/dist/src/content-redaction.js +44 -3
- package/dist/src/content-redaction.js.map +1 -1
- package/dist/src/credential-store.d.ts +20 -1
- package/dist/src/credential-store.d.ts.map +1 -1
- package/dist/src/credential-store.js +27 -3
- package/dist/src/credential-store.js.map +1 -1
- package/dist/src/discovery/engine.d.ts.map +1 -1
- package/dist/src/discovery/engine.js +1 -11
- package/dist/src/discovery/engine.js.map +1 -1
- package/dist/src/doctor-check.d.ts +51 -0
- package/dist/src/doctor-check.d.ts.map +1 -0
- package/dist/src/doctor-check.js +174 -0
- package/dist/src/doctor-check.js.map +1 -0
- package/dist/src/enroll.d.ts +86 -0
- package/dist/src/enroll.d.ts.map +1 -0
- package/dist/src/enroll.js +245 -0
- package/dist/src/enroll.js.map +1 -0
- package/dist/src/entry.d.ts +3 -0
- package/dist/src/entry.d.ts.map +1 -0
- package/dist/src/entry.js +14 -0
- package/dist/src/entry.js.map +1 -0
- package/dist/src/event-queue.d.ts +13 -1
- package/dist/src/event-queue.d.ts.map +1 -1
- package/dist/src/event-queue.js +50 -5
- package/dist/src/event-queue.js.map +1 -1
- package/dist/src/event-spool.d.ts +61 -3
- package/dist/src/event-spool.d.ts.map +1 -1
- package/dist/src/event-spool.js +290 -41
- package/dist/src/event-spool.js.map +1 -1
- package/dist/src/generated/phigon/v1/identity_pb.d.ts +7 -0
- package/dist/src/generated/phigon/v1/identity_pb.d.ts.map +1 -1
- package/dist/src/generated/phigon/v1/identity_pb.js +1 -1
- package/dist/src/generated/phigon/v1/identity_pb.js.map +1 -1
- package/dist/src/guardian-decision-journal.d.ts.map +1 -1
- package/dist/src/guardian-decision-journal.js +4 -20
- package/dist/src/guardian-decision-journal.js.map +1 -1
- package/dist/src/guardian-managed-exec.js +6 -19
- package/dist/src/guardian-managed-exec.js.map +1 -1
- package/dist/src/guardian-process-monitor.d.ts +1 -0
- package/dist/src/guardian-process-monitor.d.ts.map +1 -1
- package/dist/src/guardian-process-monitor.js +74 -26
- package/dist/src/guardian-process-monitor.js.map +1 -1
- package/dist/src/health.d.ts +19 -1
- package/dist/src/health.d.ts.map +1 -1
- package/dist/src/health.js +22 -1
- package/dist/src/health.js.map +1 -1
- package/dist/src/historical-identity-store.d.ts +8 -1
- package/dist/src/historical-identity-store.d.ts.map +1 -1
- package/dist/src/historical-identity-store.js +26 -9
- package/dist/src/historical-identity-store.js.map +1 -1
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +2 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/installation.d.ts +2 -0
- package/dist/src/installation.d.ts.map +1 -1
- package/dist/src/installation.js +3 -1
- package/dist/src/installation.js.map +1 -1
- package/dist/src/instance-lock.d.ts +45 -0
- package/dist/src/instance-lock.d.ts.map +1 -0
- package/dist/src/instance-lock.js +243 -0
- package/dist/src/instance-lock.js.map +1 -0
- package/dist/src/machine-command-executor.d.ts +2 -2
- package/dist/src/machine-command-executor.d.ts.map +1 -1
- package/dist/src/machine-command-executor.js +7 -20
- package/dist/src/machine-command-executor.js.map +1 -1
- package/dist/src/node-version.d.ts +8 -0
- package/dist/src/node-version.d.ts.map +1 -0
- package/dist/src/node-version.js +17 -0
- package/dist/src/node-version.js.map +1 -0
- package/dist/src/observer-log.d.ts +50 -0
- package/dist/src/observer-log.d.ts.map +1 -0
- package/dist/src/observer-log.js +141 -0
- package/dist/src/observer-log.js.map +1 -0
- package/dist/src/pair.d.ts +68 -1
- package/dist/src/pair.d.ts.map +1 -1
- package/dist/src/pair.js +204 -66
- package/dist/src/pair.js.map +1 -1
- package/dist/src/private-directory.d.ts +6 -0
- package/dist/src/private-directory.d.ts.map +1 -1
- package/dist/src/private-directory.js +33 -2
- package/dist/src/private-directory.js.map +1 -1
- package/dist/src/proxy.d.ts +68 -0
- package/dist/src/proxy.d.ts.map +1 -0
- package/dist/src/proxy.js +209 -0
- package/dist/src/proxy.js.map +1 -0
- package/dist/src/purge.d.ts +17 -0
- package/dist/src/purge.d.ts.map +1 -0
- package/dist/src/purge.js +84 -0
- package/dist/src/purge.js.map +1 -0
- package/dist/src/remove-machine.d.ts +5 -1
- package/dist/src/remove-machine.d.ts.map +1 -1
- package/dist/src/remove-machine.js +28 -14
- package/dist/src/remove-machine.js.map +1 -1
- package/dist/src/server-authority.d.ts +5 -2
- package/dist/src/server-authority.d.ts.map +1 -1
- package/dist/src/server-authority.js +10 -6
- package/dist/src/server-authority.js.map +1 -1
- package/dist/src/server-clock.d.ts +32 -0
- package/dist/src/server-clock.d.ts.map +1 -0
- package/dist/src/server-clock.js +72 -0
- package/dist/src/server-clock.js.map +1 -0
- package/dist/src/service-command.d.ts +20 -0
- package/dist/src/service-command.d.ts.map +1 -0
- package/dist/src/service-command.js +136 -0
- package/dist/src/service-command.js.map +1 -0
- package/dist/src/service.d.ts +98 -0
- package/dist/src/service.d.ts.map +1 -0
- package/dist/src/service.js +472 -0
- package/dist/src/service.js.map +1 -0
- package/dist/src/stream-client.d.ts +18 -0
- package/dist/src/stream-client.d.ts.map +1 -1
- package/dist/src/stream-client.js +91 -16
- package/dist/src/stream-client.js.map +1 -1
- package/dist/src/stream-state.d.ts +2 -0
- package/dist/src/stream-state.d.ts.map +1 -1
- package/dist/src/stream-state.js +3 -1
- package/dist/src/stream-state.js.map +1 -1
- package/dist/src/user-notices.d.ts +11 -0
- package/dist/src/user-notices.d.ts.map +1 -1
- package/dist/src/user-notices.js +24 -0
- package/dist/src/user-notices.js.map +1 -1
- 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
|
-
|
|
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":"
|
|
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"}
|
package/dist/src/event-spool.js
CHANGED
|
@@ -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,
|
|
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
|
|
11
|
-
|
|
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
|
-
|
|
24
|
-
//
|
|
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
|
-
|
|
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 >
|
|
57
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
|
71
|
-
//
|
|
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
|
|
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 (
|
|
137
|
-
|
|
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.#
|
|
333
|
+
if (this.#storedBytes() + bytes > this.#maxBytes) {
|
|
199
334
|
this.#compact();
|
|
200
335
|
}
|
|
201
|
-
if (this.#
|
|
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
|
-
|
|
229
|
-
|
|
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
|
-
|
|
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
|
|
268
|
-
|
|
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
|
-
|
|
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 (
|
|
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 {
|