@centerforagenticai/pi-multi-account 0.1.1

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 (124) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +29 -0
  3. package/README.md +999 -0
  4. package/config/models/pi-multi-account.v1.json +32 -0
  5. package/config/subscription-plans.v1.json +122 -0
  6. package/package.json +76 -0
  7. package/packages/pi-anthropic-oauth/LICENSE +21 -0
  8. package/packages/pi-anthropic-oauth/package.json +54 -0
  9. package/packages/pi-anthropic-oauth/src/auth.ts +396 -0
  10. package/packages/pi-anthropic-oauth/src/context.ts +116 -0
  11. package/packages/pi-anthropic-oauth/src/convert.ts +303 -0
  12. package/packages/pi-anthropic-oauth/src/index.ts +37 -0
  13. package/packages/pi-anthropic-oauth/src/prompt.ts +137 -0
  14. package/packages/pi-anthropic-oauth/src/stream.ts +476 -0
  15. package/packages/pi-antigravity/LICENSE +21 -0
  16. package/packages/pi-antigravity/package.json +77 -0
  17. package/packages/pi-antigravity/src/auth/index.ts +14 -0
  18. package/packages/pi-antigravity/src/auth/oauth.ts +442 -0
  19. package/packages/pi-antigravity/src/client/client.ts +561 -0
  20. package/packages/pi-antigravity/src/client/index.ts +1 -0
  21. package/packages/pi-antigravity/src/context.ts +110 -0
  22. package/packages/pi-antigravity/src/diagnostics/diagnostics.ts +96 -0
  23. package/packages/pi-antigravity/src/diagnostics/index.ts +1 -0
  24. package/packages/pi-antigravity/src/image/image.ts +336 -0
  25. package/packages/pi-antigravity/src/image/index.ts +1 -0
  26. package/packages/pi-antigravity/src/index.ts +280 -0
  27. package/packages/pi-antigravity/src/models/discovery.ts +154 -0
  28. package/packages/pi-antigravity/src/models/grouping.ts +424 -0
  29. package/packages/pi-antigravity/src/models/index.ts +3 -0
  30. package/packages/pi-antigravity/src/models/models.ts +500 -0
  31. package/packages/pi-antigravity/src/stream/index.ts +1 -0
  32. package/packages/pi-antigravity/src/stream/stream.ts +1478 -0
  33. package/packages/pi-antigravity/src/types/enums.ts +42 -0
  34. package/packages/pi-antigravity/src/types/index.ts +2 -0
  35. package/packages/pi-antigravity/src/types/types.ts +292 -0
  36. package/packages/pi-antigravity/src/usage/index.ts +1 -0
  37. package/packages/pi-antigravity/src/usage/usage.ts +416 -0
  38. package/packages/pi-antigravity/src/utils/http.ts +91 -0
  39. package/packages/pi-antigravity/src/utils/index.ts +3 -0
  40. package/packages/pi-antigravity/src/utils/security.ts +73 -0
  41. package/packages/pi-antigravity/src/utils/util.ts +132 -0
  42. package/scripts/multi-account.mjs +44 -0
  43. package/src/account-labels.ts +223 -0
  44. package/src/account-plan-assignment.ts +340 -0
  45. package/src/account-rate-history.ts +372 -0
  46. package/src/anthropic-adaptive-stream.ts +531 -0
  47. package/src/anthropic-alias-stream.ts +140 -0
  48. package/src/anthropic-context-compat.ts +80 -0
  49. package/src/api-pricing.ts +579 -0
  50. package/src/bounded-file-lines.ts +97 -0
  51. package/src/catalog-rebinding.ts +177 -0
  52. package/src/catalog-registration-probe.ts +111 -0
  53. package/src/codex-adapter.ts +345 -0
  54. package/src/codex-model-defaults.ts +785 -0
  55. package/src/command-completions.ts +404 -0
  56. package/src/commands.ts +2000 -0
  57. package/src/compaction.ts +14 -0
  58. package/src/config.ts +1317 -0
  59. package/src/continuation.ts +569 -0
  60. package/src/cooldowns.ts +110 -0
  61. package/src/cost-digest-store.ts +332 -0
  62. package/src/cost-digest.ts +1044 -0
  63. package/src/cost-history.ts +251 -0
  64. package/src/cost-period-closer.ts +160 -0
  65. package/src/cost-report-json.ts +318 -0
  66. package/src/cost-report-reader.ts +368 -0
  67. package/src/cost-report-render.ts +207 -0
  68. package/src/cost-report.ts +1104 -0
  69. package/src/coverage-attestation.ts +397 -0
  70. package/src/credential-lifecycle.ts +169 -0
  71. package/src/credential-refresh.ts +248 -0
  72. package/src/declaration-notice-marker.ts +238 -0
  73. package/src/diagnostic-store.ts +276 -0
  74. package/src/diagnostics.ts +309 -0
  75. package/src/discovery.ts +471 -0
  76. package/src/duration.ts +13 -0
  77. package/src/error-classification.ts +256 -0
  78. package/src/fuzzy.ts +15 -0
  79. package/src/group-policy.ts +81 -0
  80. package/src/history-store.ts +897 -0
  81. package/src/index.ts +5572 -0
  82. package/src/lifecycle.ts +378 -0
  83. package/src/logical-dispatch.ts +279 -0
  84. package/src/logical-model-selector.ts +254 -0
  85. package/src/logical-model-switcher.ts +430 -0
  86. package/src/logical-provider-attribution.ts +544 -0
  87. package/src/logical-provider.ts +1237 -0
  88. package/src/logical-route-indicator.ts +215 -0
  89. package/src/machine-lease.ts +445 -0
  90. package/src/model-support.ts +66 -0
  91. package/src/models-declaration.ts +1091 -0
  92. package/src/openai-adapter.ts +117 -0
  93. package/src/openrouter-budget.ts +304 -0
  94. package/src/openrouter-fallback.ts +146 -0
  95. package/src/period-boundaries.ts +376 -0
  96. package/src/pi-anthropic-oauth.d.ts +6 -0
  97. package/src/preflight.ts +253 -0
  98. package/src/pricing-cache.ts +235 -0
  99. package/src/project-identity.ts +100 -0
  100. package/src/provider-registration.ts +942 -0
  101. package/src/rate-formula.ts +163 -0
  102. package/src/recovery-engine.ts +853 -0
  103. package/src/recovery-output.ts +837 -0
  104. package/src/recovery-plan.ts +239 -0
  105. package/src/report-range.ts +203 -0
  106. package/src/route-resolver.ts +789 -0
  107. package/src/routing-config-transaction.ts +232 -0
  108. package/src/routing.ts +1163 -0
  109. package/src/runtime-state.ts +630 -0
  110. package/src/session-account-groups.ts +284 -0
  111. package/src/session-restore.ts +287 -0
  112. package/src/shared-usage.ts +1392 -0
  113. package/src/standalone-cli.ts +720 -0
  114. package/src/status-view.ts +578 -0
  115. package/src/subscription-plan-catalog.ts +346 -0
  116. package/src/tier-model-resolver.ts +46 -0
  117. package/src/upstream-anthropic.ts +315 -0
  118. package/src/upstream-antigravity.ts +327 -0
  119. package/src/usage-fetch.ts +1634 -0
  120. package/src/usage.ts +1026 -0
  121. package/src/vendor.ts +87 -0
  122. package/src/warmer.ts +231 -0
  123. package/src/watchdog.ts +219 -0
  124. package/src/window-history.ts +270 -0
@@ -0,0 +1,897 @@
1
+ import { constants as bufferConstants } from "node:buffer";
2
+ import { randomUUID } from "node:crypto";
3
+ import {
4
+ appendFileSync,
5
+ chmodSync,
6
+ closeSync,
7
+ fsyncSync,
8
+ lstatSync,
9
+ mkdirSync,
10
+ openSync,
11
+ readFileSync,
12
+ readSync,
13
+ realpathSync,
14
+ renameSync,
15
+ statSync,
16
+ unlinkSync,
17
+ writeFileSync,
18
+ writeSync,
19
+ } from "node:fs";
20
+ import { basename, dirname, join, relative } from "node:path";
21
+ import {
22
+ iterateBoundedFileLines,
23
+ normalizedFileReadChunkBytes,
24
+ } from "./bounded-file-lines.js";
25
+ import { acquireMachineLease, type MachineLeaseHandle } from "./machine-lease.js";
26
+
27
+ const SCHEMA_VERSION = 1;
28
+ const MAX_RECORD_BYTES = 1_024;
29
+ const RETENTION_DAYS = 90;
30
+ const DAY_MS = 86_400_000;
31
+ const RETENTION_MS = RETENTION_DAYS * DAY_MS;
32
+ const FATAL_UTF8_DECODER = new TextDecoder("utf-8", { fatal: true });
33
+ const WINDOW_HISTORY_MAX_BYTES = 128 * 1024 * 1024;
34
+ const COST_HISTORY_MAX_BYTES = Math.min(
35
+ 512 * 1024 * 1024,
36
+ bufferConstants.MAX_STRING_LENGTH,
37
+ );
38
+
39
+ export type HistoryRecordType = "window-sample" | "cost-delta";
40
+
41
+ export interface HistoryRecordEnvelope {
42
+ readonly schemaVersion: number;
43
+ readonly recordType: HistoryRecordType;
44
+ readonly stableId: string;
45
+ readonly observedAtMs: number;
46
+ readonly recordedAtMs: number;
47
+ readonly payload: unknown;
48
+ }
49
+
50
+ export interface HistoryGapRecord {
51
+ readonly schemaVersion: number;
52
+ readonly recordType: "gap";
53
+ readonly gapStartMs: number;
54
+ readonly gapEndMs: number;
55
+ readonly reason: string;
56
+ }
57
+
58
+ export type HistoryLogRecord = HistoryRecordEnvelope | HistoryGapRecord;
59
+
60
+ /**
61
+ * Lock-acquisition retry budget.
62
+ *
63
+ * Production defaults spend ~60s of real time exhausting 30 retries, which is
64
+ * correct for a contended machine-global lock but far too slow for a test that
65
+ * deliberately holds the lock to observe refusal. Tests inject a small budget;
66
+ * nothing else should override it.
67
+ */
68
+ export interface HistoryLockExhaustion {
69
+ readonly lockPath: string;
70
+ readonly elapsedMs: number;
71
+ }
72
+
73
+ interface HistoryRetryBudget {
74
+ readonly maxRetries?: number;
75
+ readonly initialDelayMs?: number;
76
+ readonly maxDelayMs?: number;
77
+ }
78
+
79
+ interface HistoryStoreOptions {
80
+ readonly windowHistoryPath: string;
81
+ readonly costHistoryPath: string;
82
+ readonly lockPath: string;
83
+ readonly now?: () => number;
84
+ /** Override lock-acquisition retry budget (for testing). */
85
+ readonly retryBudget?: HistoryRetryBudget;
86
+ /** Override window history capacity ceiling (for testing). */
87
+ readonly windowMaxBytes?: number;
88
+ /** Override cost history capacity ceiling (for testing). */
89
+ readonly costMaxBytes?: number;
90
+ /**
91
+ * Override expected base directory for path validation (for testing).
92
+ * Defaults to dirname of the default window/cost history paths.
93
+ * Tests with custom paths should set this to their test base directory.
94
+ */
95
+ readonly expectedBase?: string;
96
+ /** Test seam for pausing append after acquiring lock but before writing. */
97
+ readonly beforeAppend?: () => void;
98
+ /** Bounded diagnostic callback after cooperative lock retries exhaust. */
99
+ readonly onLockExhausted?: (details: HistoryLockExhaustion) => void;
100
+ /** Test seam for changing the target after validation but before compaction reads. */
101
+ readonly beforeCompactionRead?: () => void;
102
+ /** Test seam for pausing compaction after final read/stat but before atomic rename. */
103
+ readonly beforeCompactionRename?: () => void;
104
+ /** Override bounded sequential read size (for testing). */
105
+ readonly readChunkBytes?: number;
106
+ /** Observe each bounded sequential read without receiving retained contents (for testing). */
107
+ readonly onReadChunk?: (bytes: number) => void;
108
+ }
109
+
110
+ function defaultHistoryPaths(): Pick<HistoryStoreOptions, "windowHistoryPath" | "costHistoryPath" | "lockPath"> {
111
+ const agentDir =
112
+ process.env.PI_CODING_AGENT_DIR ??
113
+ join(process.env.HOME ?? ".", ".pi", "agent");
114
+ // Package storage identity stays decoupled from the logical provider ID.
115
+ const baseDir = join(agentDir, "pi-multi-account");
116
+ return {
117
+ windowHistoryPath: join(baseDir, "window-history.ndjson"),
118
+ costHistoryPath: join(baseDir, "cost-history.ndjson"),
119
+ lockPath: join(baseDir, "history.lock"),
120
+ };
121
+ }
122
+
123
+ /**
124
+ * Validate that a target path is safe for appending:
125
+ * - Not a symlink (reject symlinked files)
126
+ * - Within the store directory (reject traversal)
127
+ *
128
+ * REQ-STORE-PERMISSIONS: symlink write-through protection and traversal rejection.
129
+ * Uses path.relative() containment check: resolved target must be within resolved base.
130
+ * Store directory must NOT be a symlink (same write-through class as symlinked file).
131
+ */
132
+ function validateAppendPath(targetPath: string, storeDir: string): boolean {
133
+ try {
134
+ // REQ-STORE-PERMISSIONS: Reject symlinked store directory
135
+ // A symlinked store dir pointing at victim/ would make resolvedBase = victim/,
136
+ // passing containment and landing writes there. Reject it.
137
+ const storeDirStats = lstatSync(storeDir);
138
+ if (storeDirStats.isSymbolicLink()) {
139
+ // Store directory is a symlink - REJECT
140
+ return false;
141
+ }
142
+
143
+ const resolvedBase = realpathSync(storeDir);
144
+
145
+ // Check if target file exists
146
+ let resolvedTarget: string;
147
+ try {
148
+ const stats = lstatSync(targetPath);
149
+ if (stats.isSymbolicLink()) {
150
+ // File is a symlink - REJECT
151
+ return false;
152
+ }
153
+ // File exists and is not a symlink - resolve it
154
+ resolvedTarget = realpathSync(targetPath);
155
+ } catch {
156
+ // File doesn't exist - resolve parent and rejoin basename
157
+ const targetParent = dirname(targetPath);
158
+ const targetName = basename(targetPath);
159
+ const resolvedParent = realpathSync(targetParent);
160
+ resolvedTarget = join(resolvedParent, targetName);
161
+ }
162
+
163
+ // Check containment: resolved target must be within resolved base
164
+ const rel = relative(resolvedBase, resolvedTarget);
165
+
166
+ // Valid if:
167
+ // - Non-empty (target is not the base itself - history files are children)
168
+ // - Doesn't start with '..' (not outside base)
169
+ // - Not absolute (relative() returned an absolute path = different roots)
170
+ if (!rel || rel.startsWith('..') || relative(resolvedBase, resolvedTarget).startsWith('/')) {
171
+ return false;
172
+ }
173
+
174
+ return true;
175
+ } catch {
176
+ // Any resolution error is a validation failure
177
+ return false;
178
+ }
179
+ }
180
+
181
+ function isMissingPathError(error: unknown): boolean {
182
+ return (
183
+ typeof error === "object" &&
184
+ error !== null &&
185
+ "code" in error &&
186
+ (error as { readonly code?: unknown }).code === "ENOENT"
187
+ );
188
+ }
189
+
190
+ async function sleepRealClock(ms: number): Promise<void> {
191
+ if (ms <= 0) {
192
+ await new Promise<void>((resolve) => setTimeout(resolve, 0));
193
+ return;
194
+ }
195
+ await new Promise<void>((resolve) => setTimeout(resolve, ms));
196
+ }
197
+
198
+ function monotonicMilliseconds(): number {
199
+ return Number(process.hrtime.bigint()) / 1_000_000;
200
+ }
201
+
202
+ async function acquireHistoryLockAsync(
203
+ lockPath: string,
204
+ nowFn: () => number,
205
+ retryBudget: HistoryRetryBudget | undefined,
206
+ onLockExhausted: ((details: HistoryLockExhaustion) => void) | undefined,
207
+ ): Promise<MachineLeaseHandle | undefined> {
208
+ const startedAt = monotonicMilliseconds();
209
+ const maxRetries = retryBudget?.maxRetries ?? 30;
210
+ const initialDelayMs = retryBudget?.initialDelayMs ?? 50;
211
+ const maxDelayMs = retryBudget?.maxDelayMs ?? 2000;
212
+ let delayMs = initialDelayMs;
213
+ for (let retry = 0; retry < maxRetries; retry += 1) {
214
+ const lease = acquireMachineLease({
215
+ lockPath,
216
+ ttlMs: 30_000,
217
+ now: nowFn,
218
+ reclaimMalformed: true,
219
+ });
220
+ if (lease) return lease;
221
+ if (retry < maxRetries - 1) {
222
+ const jitter = Math.floor(Math.random() * (delayMs / 2));
223
+ await sleepRealClock(delayMs + jitter);
224
+ delayMs = Math.min(delayMs * 2, maxDelayMs);
225
+ }
226
+ }
227
+ try {
228
+ onLockExhausted?.({
229
+ lockPath,
230
+ elapsedMs: Math.max(0, Math.round(monotonicMilliseconds() - startedAt)),
231
+ });
232
+ } catch {
233
+ // Diagnostics are fail-soft and must not change storage behavior.
234
+ }
235
+ return undefined;
236
+ }
237
+
238
+ function validRecordEnvelope(value: unknown): value is HistoryRecordEnvelope {
239
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
240
+ return false;
241
+ }
242
+ const record = value as Record<string, unknown>;
243
+ return (
244
+ record.schemaVersion === SCHEMA_VERSION &&
245
+ (record.recordType === "window-sample" || record.recordType === "cost-delta") &&
246
+ typeof record.stableId === "string" &&
247
+ record.stableId.length > 0 &&
248
+ record.stableId.length <= 256 &&
249
+ typeof record.observedAtMs === "number" &&
250
+ Number.isFinite(record.observedAtMs) &&
251
+ record.observedAtMs >= 0 &&
252
+ typeof record.recordedAtMs === "number" &&
253
+ Number.isFinite(record.recordedAtMs) &&
254
+ record.recordedAtMs >= 0 &&
255
+ record.payload !== undefined
256
+ );
257
+ }
258
+
259
+ function validGapRecord(value: unknown): value is HistoryGapRecord {
260
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
261
+ return false;
262
+ }
263
+ const record = value as Record<string, unknown>;
264
+ return (
265
+ record.schemaVersion === SCHEMA_VERSION &&
266
+ record.recordType === "gap" &&
267
+ typeof record.gapStartMs === "number" &&
268
+ Number.isFinite(record.gapStartMs) &&
269
+ record.gapStartMs >= 0 &&
270
+ typeof record.gapEndMs === "number" &&
271
+ Number.isFinite(record.gapEndMs) &&
272
+ record.gapEndMs >= record.gapStartMs &&
273
+ typeof record.reason === "string" &&
274
+ record.reason.length > 0 &&
275
+ record.reason.length <= 256
276
+ );
277
+ }
278
+
279
+ function validHistoryRecord(value: unknown): value is HistoryLogRecord {
280
+ return validRecordEnvelope(value) || validGapRecord(value);
281
+ }
282
+
283
+ function parseHistoryLine(line: string): HistoryLogRecord | undefined {
284
+ if (line.trim().length === 0) return undefined;
285
+ try {
286
+ const parsed: unknown = JSON.parse(line);
287
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
288
+ return undefined;
289
+ }
290
+ const record = parsed as Record<string, unknown>;
291
+
292
+ // Readers expose unrecognized schema versions without rewriting the log.
293
+ if (
294
+ typeof record.schemaVersion === "number" &&
295
+ record.schemaVersion !== SCHEMA_VERSION
296
+ ) {
297
+ return parsed as HistoryLogRecord;
298
+ }
299
+ if (validHistoryRecord(parsed)) return parsed;
300
+
301
+ // Readers also expose unrecognized record types in the current schema.
302
+ return record.schemaVersion === SCHEMA_VERSION
303
+ ? (parsed as HistoryLogRecord)
304
+ : undefined;
305
+ } catch {
306
+ // One malformed line must not hide later records.
307
+ return undefined;
308
+ }
309
+ }
310
+
311
+ function isExpired(timestampMs: number, nowMs: number): boolean {
312
+ return nowMs - timestampMs > RETENTION_MS;
313
+ }
314
+
315
+ /** Preserve retained lines exactly; only recognized expired records are removed. */
316
+ function retainHistoryLine(line: Buffer, nowMs: number): boolean {
317
+ let decoded: string;
318
+ try {
319
+ decoded = FATAL_UTF8_DECODER.decode(line);
320
+ } catch {
321
+ return true;
322
+ }
323
+ if (decoded.trim().length === 0) return true;
324
+ try {
325
+ const parsed: unknown = JSON.parse(decoded);
326
+ if (validRecordEnvelope(parsed)) {
327
+ return !isExpired(parsed.recordedAtMs, nowMs);
328
+ }
329
+ if (validGapRecord(parsed)) {
330
+ return !isExpired(parsed.gapEndMs, nowMs);
331
+ }
332
+ } catch {
333
+ // Malformed or partial content is preserved rather than rewritten.
334
+ }
335
+ return true;
336
+ }
337
+
338
+ function writeBuffer(descriptor: number, bytes: Buffer): void {
339
+ let offset = 0;
340
+ while (offset < bytes.length) {
341
+ const written = writeSync(descriptor, bytes, offset, bytes.length - offset);
342
+ if (written < 1) throw new Error("History compaction write made no progress.");
343
+ offset += written;
344
+ }
345
+ }
346
+
347
+ function streamRetainedHistory(options: {
348
+ readonly sourceDescriptor: number;
349
+ readonly targetDescriptor?: number;
350
+ readonly nowMs: number;
351
+ readonly chunkBytes: number;
352
+ readonly onReadChunk: (bytes: number) => void;
353
+ readonly renewLease: () => boolean;
354
+ }): { readonly changed: boolean; readonly retainedBytes: number } {
355
+ const readBuffer = Buffer.allocUnsafe(options.chunkBytes);
356
+ const newline = Buffer.from("\n");
357
+ let fragments: Buffer[] = [];
358
+ let lineBytes = 0;
359
+ let oversized = false;
360
+ let changed = false;
361
+ let retainedBytes = 0;
362
+ let bytesSinceRenewal = 0;
363
+
364
+ const retainBytes = (bytes: Buffer): void => {
365
+ if (options.targetDescriptor !== undefined) {
366
+ writeBuffer(options.targetDescriptor, bytes);
367
+ }
368
+ retainedBytes += bytes.length;
369
+ };
370
+ const addSegment = (segment: Buffer): void => {
371
+ if (segment.length === 0) return;
372
+ if (oversized) {
373
+ retainBytes(segment);
374
+ return;
375
+ }
376
+ if (lineBytes + segment.length > MAX_RECORD_BYTES) {
377
+ for (const fragment of fragments) retainBytes(fragment);
378
+ retainBytes(segment);
379
+ fragments = [];
380
+ lineBytes = 0;
381
+ oversized = true;
382
+ return;
383
+ }
384
+ fragments.push(Buffer.from(segment));
385
+ lineBytes += segment.length;
386
+ };
387
+ const finishLine = (terminated: boolean): void => {
388
+ if (oversized) {
389
+ if (terminated) retainBytes(newline);
390
+ } else {
391
+ const line = Buffer.concat(fragments, lineBytes);
392
+ if (retainHistoryLine(line, options.nowMs)) {
393
+ retainBytes(line);
394
+ if (terminated) retainBytes(newline);
395
+ } else {
396
+ changed = true;
397
+ }
398
+ }
399
+ fragments = [];
400
+ lineBytes = 0;
401
+ oversized = false;
402
+ };
403
+
404
+ while (true) {
405
+ const bytesRead = readSync(
406
+ options.sourceDescriptor,
407
+ readBuffer,
408
+ 0,
409
+ readBuffer.length,
410
+ null,
411
+ );
412
+ if (bytesRead === 0) break;
413
+ options.onReadChunk(bytesRead);
414
+ bytesSinceRenewal += bytesRead;
415
+ if (bytesSinceRenewal >= 8 * 1024 * 1024) {
416
+ if (!options.renewLease()) {
417
+ throw new Error("History lease ownership changed during compaction.");
418
+ }
419
+ bytesSinceRenewal = 0;
420
+ }
421
+ let segmentStart = 0;
422
+ for (let index = 0; index < bytesRead; index += 1) {
423
+ if (readBuffer[index] !== 0x0a) continue;
424
+ addSegment(readBuffer.subarray(segmentStart, index));
425
+ finishLine(true);
426
+ segmentStart = index + 1;
427
+ }
428
+ addSegment(readBuffer.subarray(segmentStart, bytesRead));
429
+ }
430
+ if (fragments.length > 0 || oversized) finishLine(false);
431
+ return { changed, retainedBytes };
432
+ }
433
+
434
+ function appendEncodedUnderLease(options: {
435
+ readonly targetPath: string;
436
+ readonly maxBytes: number;
437
+ readonly encoded: string;
438
+ readonly recordBytes: number;
439
+ readonly expectedBase: string;
440
+ readonly nowMs: number;
441
+ readonly beforeAppend: () => void;
442
+ readonly beforeCompactionRead: () => void;
443
+ readonly beforeCompactionRename: () => void;
444
+ readonly readChunkBytes: number;
445
+ readonly onReadChunk: (bytes: number) => void;
446
+ readonly renewLease: () => boolean;
447
+ }): boolean {
448
+ const markerPath = retentionMarkerPath(options.targetPath);
449
+ if (!validateAppendPath(markerPath, options.expectedBase)) return false;
450
+ let compacted = false;
451
+ if (retentionCheckDue(markerPath, options.nowMs)) {
452
+ if (
453
+ !compactHistoryUnderLease({
454
+ targetPath: options.targetPath,
455
+ maxBytes: options.maxBytes,
456
+ nowMs: options.nowMs,
457
+ beforeRead: options.beforeCompactionRead,
458
+ beforeRename: options.beforeCompactionRename,
459
+ readChunkBytes: options.readChunkBytes,
460
+ onReadChunk: options.onReadChunk,
461
+ renewLease: options.renewLease,
462
+ })
463
+ ) {
464
+ return false;
465
+ }
466
+ recordRetentionCheck(markerPath, options.nowMs);
467
+ compacted = true;
468
+ }
469
+
470
+ options.beforeAppend();
471
+ let currentSize = 0;
472
+ try {
473
+ currentSize = statSync(options.targetPath).size;
474
+ } catch {
475
+ // File doesn't exist yet.
476
+ }
477
+ if (currentSize + options.recordBytes > options.maxBytes && !compacted) {
478
+ if (
479
+ !compactHistoryUnderLease({
480
+ targetPath: options.targetPath,
481
+ maxBytes: options.maxBytes,
482
+ nowMs: options.nowMs,
483
+ beforeRead: options.beforeCompactionRead,
484
+ beforeRename: options.beforeCompactionRename,
485
+ readChunkBytes: options.readChunkBytes,
486
+ onReadChunk: options.onReadChunk,
487
+ renewLease: options.renewLease,
488
+ })
489
+ ) {
490
+ return false;
491
+ }
492
+ try {
493
+ currentSize = statSync(options.targetPath).size;
494
+ } catch {
495
+ currentSize = 0;
496
+ }
497
+ }
498
+ if (currentSize + options.recordBytes > options.maxBytes) return false;
499
+
500
+ appendFileSync(options.targetPath, options.encoded, {
501
+ encoding: "utf8",
502
+ mode: 0o600,
503
+ });
504
+ chmodSync(options.targetPath, 0o600);
505
+ return true;
506
+ }
507
+
508
+ function retentionMarkerPath(targetPath: string): string {
509
+ return `${targetPath}.retention`;
510
+ }
511
+
512
+ function retentionDayStart(nowMs: number): number {
513
+ return Math.floor(Math.max(0, nowMs) / DAY_MS) * DAY_MS;
514
+ }
515
+
516
+ function retentionCheckDue(markerPath: string, nowMs: number): boolean {
517
+ try {
518
+ const value = Number(readFileSync(markerPath, "utf8").trim());
519
+ return value !== retentionDayStart(nowMs);
520
+ } catch {
521
+ return true;
522
+ }
523
+ }
524
+
525
+ function recordRetentionCheck(markerPath: string, nowMs: number): void {
526
+ writeFileSync(markerPath, `${retentionDayStart(nowMs)}\n`, {
527
+ encoding: "utf8",
528
+ mode: 0o600,
529
+ });
530
+ chmodSync(markerPath, 0o600);
531
+ }
532
+
533
+ /** Rewrite one store while the caller holds the dedicated history lock. */
534
+ function compactHistoryUnderLease(options: {
535
+ readonly targetPath: string;
536
+ readonly maxBytes: number;
537
+ readonly nowMs: number;
538
+ readonly beforeRead: () => void;
539
+ readonly beforeRename: () => void;
540
+ readonly renewLease: () => boolean;
541
+ readonly readChunkBytes: number;
542
+ readonly onReadChunk: (bytes: number) => void;
543
+ }): boolean {
544
+ let sourceDescriptor: number | undefined;
545
+ try {
546
+ options.beforeRead();
547
+ sourceDescriptor = openSync(options.targetPath, "r");
548
+ } catch (error) {
549
+ return isMissingPathError(error) && options.renewLease();
550
+ }
551
+
552
+ const temporaryPath = `${options.targetPath}.${process.pid}.${randomUUID()}.tmp`;
553
+ let targetDescriptor: number | undefined;
554
+ let published = false;
555
+ try {
556
+ const scan = streamRetainedHistory({
557
+ sourceDescriptor,
558
+ nowMs: options.nowMs,
559
+ chunkBytes: options.readChunkBytes,
560
+ onReadChunk: options.onReadChunk,
561
+ renewLease: options.renewLease,
562
+ });
563
+ closeSync(sourceDescriptor);
564
+ sourceDescriptor = undefined;
565
+ if (scan.retainedBytes > options.maxBytes) return false;
566
+ if (!scan.changed) return options.renewLease();
567
+
568
+ sourceDescriptor = openSync(options.targetPath, "r");
569
+ targetDescriptor = openSync(temporaryPath, "wx", 0o600);
570
+ const copy = streamRetainedHistory({
571
+ sourceDescriptor,
572
+ targetDescriptor,
573
+ nowMs: options.nowMs,
574
+ chunkBytes: options.readChunkBytes,
575
+ onReadChunk: options.onReadChunk,
576
+ renewLease: options.renewLease,
577
+ });
578
+ closeSync(sourceDescriptor);
579
+ sourceDescriptor = undefined;
580
+ if (!copy.changed || copy.retainedBytes !== scan.retainedBytes) return false;
581
+ fsyncSync(targetDescriptor);
582
+ closeSync(targetDescriptor);
583
+ targetDescriptor = undefined;
584
+ options.beforeRename();
585
+ if (!options.renewLease()) {
586
+ throw new Error("History lease ownership changed before publication.");
587
+ }
588
+ renameSync(temporaryPath, options.targetPath);
589
+ published = true;
590
+ chmodSync(options.targetPath, 0o600);
591
+ return true;
592
+ } catch {
593
+ return false;
594
+ } finally {
595
+ if (sourceDescriptor !== undefined) {
596
+ try {
597
+ closeSync(sourceDescriptor);
598
+ } catch {
599
+ // Continue with temporary-file cleanup.
600
+ }
601
+ }
602
+ if (targetDescriptor !== undefined) {
603
+ try {
604
+ closeSync(targetDescriptor);
605
+ } catch {
606
+ // Continue with temporary-file cleanup.
607
+ }
608
+ }
609
+ if (!published) {
610
+ try {
611
+ unlinkSync(temporaryPath);
612
+ } catch {
613
+ // Best-effort cleanup; the source file remains authoritative.
614
+ }
615
+ }
616
+ }
617
+ }
618
+
619
+ /**
620
+ * Append one history record under the cooperative machine-global history lease.
621
+ * The lease remains held through retention, compaction, and append publication.
622
+ */
623
+ export async function appendHistory(
624
+ record: HistoryRecordEnvelope,
625
+ options?: Partial<HistoryStoreOptions>,
626
+ ): Promise<boolean> {
627
+ const defaults = defaultHistoryPaths();
628
+ const normalized = {
629
+ windowHistoryPath: options?.windowHistoryPath ?? defaults.windowHistoryPath,
630
+ costHistoryPath: options?.costHistoryPath ?? defaults.costHistoryPath,
631
+ lockPath: options?.lockPath ?? defaults.lockPath,
632
+ now: options?.now ?? Date.now,
633
+ windowMaxBytes: options?.windowMaxBytes ?? WINDOW_HISTORY_MAX_BYTES,
634
+ costMaxBytes: options?.costMaxBytes ?? COST_HISTORY_MAX_BYTES,
635
+ beforeAppend: options?.beforeAppend ?? (() => {}),
636
+ beforeCompactionRead: options?.beforeCompactionRead ?? (() => {}),
637
+ beforeCompactionRename: options?.beforeCompactionRename ?? (() => {}),
638
+ readChunkBytes: normalizedFileReadChunkBytes(options?.readChunkBytes),
639
+ onReadChunk: options?.onReadChunk ?? (() => {}),
640
+ retryBudget: options?.retryBudget,
641
+ onLockExhausted: options?.onLockExhausted,
642
+ };
643
+ const targetPath = record.recordType === "window-sample"
644
+ ? normalized.windowHistoryPath
645
+ : normalized.costHistoryPath;
646
+ const maxBytes = record.recordType === "window-sample"
647
+ ? normalized.windowMaxBytes
648
+ : normalized.costMaxBytes;
649
+ const encoded = `${JSON.stringify(record)}\n`;
650
+ const recordBytes = Buffer.byteLength(encoded, "utf8");
651
+ if (recordBytes > MAX_RECORD_BYTES) return false;
652
+ const expectedBase = options?.expectedBase ?? dirname(
653
+ record.recordType === "window-sample" ? defaults.windowHistoryPath : defaults.costHistoryPath,
654
+ );
655
+ try {
656
+ mkdirSync(expectedBase, { recursive: true, mode: 0o700 });
657
+ if (!validateAppendPath(targetPath, expectedBase)) return false;
658
+ const directory = dirname(targetPath);
659
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
660
+ chmodSync(directory, 0o700);
661
+ const lease = await acquireHistoryLockAsync(
662
+ normalized.lockPath,
663
+ normalized.now,
664
+ normalized.retryBudget,
665
+ normalized.onLockExhausted,
666
+ );
667
+ if (!lease) return false;
668
+ try {
669
+ return appendEncodedUnderLease({
670
+ targetPath,
671
+ maxBytes,
672
+ encoded,
673
+ recordBytes,
674
+ expectedBase,
675
+ nowMs: normalized.now(),
676
+ beforeAppend: normalized.beforeAppend,
677
+ beforeCompactionRead: normalized.beforeCompactionRead,
678
+ beforeCompactionRename: normalized.beforeCompactionRename,
679
+ readChunkBytes: normalized.readChunkBytes,
680
+ onReadChunk: normalized.onReadChunk,
681
+ renewLease: () => lease.renew(),
682
+ });
683
+ } catch {
684
+ return false;
685
+ } finally {
686
+ lease.release();
687
+ }
688
+ } catch {
689
+ return false;
690
+ }
691
+ }
692
+
693
+ /**
694
+ * Append a gap record indicating an interval where observations could not be written.
695
+ *
696
+ * REQ-RETENTION-NUMERIC: at most one gap record per unwritable interval.
697
+ *
698
+ * One-gap-per-interval is CALLER DISCIPLINE, not enforced here. Enforcement would
699
+ * require cross-call state tracking ("has a gap been emitted for interval X?"),
700
+ * violating the stateless contract of this storage layer. The caller determines
701
+ * interval boundaries and is responsible for emitting at most one gap per interval.
702
+ */
703
+ export async function appendGap(
704
+ recordType: HistoryRecordType,
705
+ gapStartMs: number,
706
+ gapEndMs: number,
707
+ reason: string,
708
+ options?: Partial<HistoryStoreOptions>,
709
+ ): Promise<boolean> {
710
+ const defaults = defaultHistoryPaths();
711
+ const normalized = {
712
+ windowHistoryPath: options?.windowHistoryPath ?? defaults.windowHistoryPath,
713
+ costHistoryPath: options?.costHistoryPath ?? defaults.costHistoryPath,
714
+ lockPath: options?.lockPath ?? defaults.lockPath,
715
+ now: options?.now ?? Date.now,
716
+ windowMaxBytes: options?.windowMaxBytes ?? WINDOW_HISTORY_MAX_BYTES,
717
+ costMaxBytes: options?.costMaxBytes ?? COST_HISTORY_MAX_BYTES,
718
+ beforeAppend: options?.beforeAppend ?? (() => {}),
719
+ beforeCompactionRead: options?.beforeCompactionRead ?? (() => {}),
720
+ beforeCompactionRename: options?.beforeCompactionRename ?? (() => {}),
721
+ readChunkBytes: normalizedFileReadChunkBytes(options?.readChunkBytes),
722
+ onReadChunk: options?.onReadChunk ?? (() => {}),
723
+ retryBudget: options?.retryBudget,
724
+ onLockExhausted: options?.onLockExhausted,
725
+ };
726
+ const targetPath = recordType === "window-sample"
727
+ ? normalized.windowHistoryPath
728
+ : normalized.costHistoryPath;
729
+ const maxBytes = recordType === "window-sample"
730
+ ? normalized.windowMaxBytes
731
+ : normalized.costMaxBytes;
732
+ const gapRecord: HistoryGapRecord = {
733
+ schemaVersion: SCHEMA_VERSION,
734
+ recordType: "gap",
735
+ gapStartMs,
736
+ gapEndMs,
737
+ reason,
738
+ };
739
+ const encoded = `${JSON.stringify(gapRecord)}\n`;
740
+ const recordBytes = Buffer.byteLength(encoded, "utf8");
741
+ if (recordBytes > MAX_RECORD_BYTES) return false;
742
+ const expectedBase = options?.expectedBase ?? dirname(
743
+ recordType === "window-sample" ? defaults.windowHistoryPath : defaults.costHistoryPath,
744
+ );
745
+ try {
746
+ mkdirSync(expectedBase, { recursive: true, mode: 0o700 });
747
+ if (!validateAppendPath(targetPath, expectedBase)) return false;
748
+ const directory = dirname(targetPath);
749
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
750
+ chmodSync(directory, 0o700);
751
+ const lease = await acquireHistoryLockAsync(
752
+ normalized.lockPath,
753
+ normalized.now,
754
+ normalized.retryBudget,
755
+ normalized.onLockExhausted,
756
+ );
757
+ if (!lease) return false;
758
+ try {
759
+ return appendEncodedUnderLease({
760
+ targetPath, maxBytes, encoded, recordBytes, expectedBase,
761
+ nowMs: normalized.now(), beforeAppend: normalized.beforeAppend,
762
+ beforeCompactionRead: normalized.beforeCompactionRead,
763
+ beforeCompactionRename: normalized.beforeCompactionRename,
764
+ readChunkBytes: normalized.readChunkBytes,
765
+ onReadChunk: normalized.onReadChunk,
766
+ renewLease: () => lease.renew(),
767
+ });
768
+ } catch {
769
+ return false;
770
+ } finally {
771
+ lease.release();
772
+ }
773
+ } catch {
774
+ return false;
775
+ }
776
+ }
777
+
778
+ /** Iterate history records with bounded sequential reads and per-line parsing. */
779
+ export function* iterateHistory(
780
+ recordType: HistoryRecordType,
781
+ options?: Partial<HistoryStoreOptions>,
782
+ ): Generator<HistoryLogRecord> {
783
+ const defaults = defaultHistoryPaths();
784
+ const targetPath =
785
+ recordType === "window-sample"
786
+ ? (options?.windowHistoryPath ?? defaults.windowHistoryPath)
787
+ : (options?.costHistoryPath ?? defaults.costHistoryPath);
788
+
789
+ try {
790
+ for (const line of iterateBoundedFileLines(targetPath, {
791
+ maxLineBytes: MAX_RECORD_BYTES,
792
+ ...(options?.readChunkBytes === undefined
793
+ ? {}
794
+ : { chunkBytes: options.readChunkBytes }),
795
+ ...(options?.onReadChunk === undefined
796
+ ? {}
797
+ : { onReadChunk: options.onReadChunk }),
798
+ })) {
799
+ const record = parseHistoryLine(line);
800
+ if (record !== undefined) yield record;
801
+ }
802
+ } catch (error) {
803
+ if (isMissingPathError(error)) return;
804
+ throw error;
805
+ }
806
+ }
807
+
808
+ /** Read all parsed records for compatibility; hot consumers iterate directly. */
809
+ export function readHistory(
810
+ recordType: HistoryRecordType,
811
+ options?: Partial<HistoryStoreOptions>,
812
+ ): HistoryLogRecord[] {
813
+ return [...iterateHistory(recordType, options)];
814
+ }
815
+
816
+ /**
817
+ * Apply age retention immediately under the same machine-global lock used by
818
+ * appenders. Retained unknown and malformed lines remain byte-for-byte exact.
819
+ */
820
+ export async function compactHistory(
821
+ recordType: HistoryRecordType,
822
+ options?: Partial<HistoryStoreOptions>,
823
+ ): Promise<boolean> {
824
+ const defaults = defaultHistoryPaths();
825
+ const normalized = {
826
+ windowHistoryPath: options?.windowHistoryPath ?? defaults.windowHistoryPath,
827
+ costHistoryPath: options?.costHistoryPath ?? defaults.costHistoryPath,
828
+ lockPath: options?.lockPath ?? defaults.lockPath,
829
+ now: options?.now ?? Date.now,
830
+ windowMaxBytes: options?.windowMaxBytes ?? WINDOW_HISTORY_MAX_BYTES,
831
+ costMaxBytes: options?.costMaxBytes ?? COST_HISTORY_MAX_BYTES,
832
+ beforeCompactionRead: options?.beforeCompactionRead ?? (() => {}),
833
+ beforeCompactionRename: options?.beforeCompactionRename ?? (() => {}),
834
+ readChunkBytes: normalizedFileReadChunkBytes(options?.readChunkBytes),
835
+ onReadChunk: options?.onReadChunk ?? (() => {}),
836
+ retryBudget: options?.retryBudget,
837
+ onLockExhausted: options?.onLockExhausted,
838
+ };
839
+ const targetPath =
840
+ recordType === "window-sample"
841
+ ? normalized.windowHistoryPath
842
+ : normalized.costHistoryPath;
843
+ const maxBytes =
844
+ recordType === "window-sample"
845
+ ? normalized.windowMaxBytes
846
+ : normalized.costMaxBytes;
847
+ const expectedBase =
848
+ options?.expectedBase ??
849
+ dirname(
850
+ recordType === "window-sample"
851
+ ? defaults.windowHistoryPath
852
+ : defaults.costHistoryPath,
853
+ );
854
+ mkdirSync(expectedBase, { recursive: true, mode: 0o700 });
855
+ if (
856
+ !validateAppendPath(targetPath, expectedBase) ||
857
+ !validateAppendPath(retentionMarkerPath(targetPath), expectedBase)
858
+ ) {
859
+ return false;
860
+ }
861
+ const directory = dirname(targetPath);
862
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
863
+ chmodSync(directory, 0o700);
864
+ const lease = await acquireHistoryLockAsync(
865
+ normalized.lockPath,
866
+ normalized.now,
867
+ normalized.retryBudget,
868
+ normalized.onLockExhausted,
869
+ );
870
+ if (!lease) return false;
871
+ try {
872
+ const nowMs = normalized.now();
873
+ const compacted = compactHistoryUnderLease({
874
+ targetPath,
875
+ maxBytes,
876
+ nowMs,
877
+ beforeRead: normalized.beforeCompactionRead,
878
+ beforeRename: normalized.beforeCompactionRename,
879
+ readChunkBytes: normalized.readChunkBytes,
880
+ onReadChunk: normalized.onReadChunk,
881
+ renewLease: () => lease.renew(),
882
+ });
883
+ if (!compacted) return false;
884
+ recordRetentionCheck(retentionMarkerPath(targetPath), nowMs);
885
+ return true;
886
+ } catch {
887
+ return false;
888
+ } finally {
889
+ lease.release();
890
+ }
891
+ }
892
+
893
+ export const HISTORY_SCHEMA_VERSION = SCHEMA_VERSION;
894
+ export const HISTORY_MAX_RECORD_BYTES = MAX_RECORD_BYTES;
895
+ export const HISTORY_RETENTION_DAYS = RETENTION_DAYS;
896
+ export const HISTORY_WINDOW_MAX_BYTES = WINDOW_HISTORY_MAX_BYTES;
897
+ export const HISTORY_COST_MAX_BYTES = COST_HISTORY_MAX_BYTES;