@narumitw/pi-analytics 0.46.0 → 0.48.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.
- package/README.md +43 -68
- package/package.json +3 -4
- package/src/analytics.ts +102 -109
- package/src/collector.ts +4 -0
- package/src/menu.ts +28 -13
- package/src/skills.ts +6 -7
- package/src/storage/files.ts +434 -0
- package/src/storage/format.ts +281 -0
- package/src/storage/queries.ts +99 -151
- package/src/storage/store.ts +32 -227
- package/src/storage/database.ts +0 -126
- package/src/storage/migrations.ts +0 -257
package/src/menu.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { stripVTControlCharacters } from "node:util";
|
|
1
2
|
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
3
|
import { defineMenu, runMenu, runTask } from "@narumitw/pi-tui-kit";
|
|
4
|
+
import type { ClearAnalyticsResult } from "./storage/files.js";
|
|
3
5
|
import type {
|
|
4
6
|
AnalyticsSnapshot,
|
|
5
7
|
SkillStats,
|
|
@@ -16,7 +18,7 @@ export type AnalyticsLoadResult =
|
|
|
16
18
|
export interface AnalyticsMenuDataSource {
|
|
17
19
|
path: string;
|
|
18
20
|
load(range: TimeRange, signal: AbortSignal): Promise<AnalyticsLoadResult>;
|
|
19
|
-
clearAll(signal: AbortSignal): Promise<
|
|
21
|
+
clearAll(signal: AbortSignal): Promise<ClearAnalyticsResult>;
|
|
20
22
|
}
|
|
21
23
|
|
|
22
24
|
export interface AnalyticsMenuState {
|
|
@@ -120,16 +122,22 @@ export function createAnalyticsMenu(source: AnalyticsMenuDataSource, now: () =>
|
|
|
120
122
|
const count = state.result.snapshot.overview.responseCycles;
|
|
121
123
|
const confirmed = await ctx.ui.confirm(
|
|
122
124
|
"Delete analytics data?",
|
|
123
|
-
`This will
|
|
125
|
+
`This will clear all local analytics history from:\n\n${safeDisplayText(state.path)}\n\nThe selected range currently shows ${count} response cycles. Other running Pi processes may add new records afterward.`,
|
|
124
126
|
{ signal },
|
|
125
127
|
);
|
|
126
128
|
if (!confirmed || signal.aborted) return { kind: "stay" };
|
|
127
|
-
const
|
|
129
|
+
const result = await source.clearAll(signal);
|
|
128
130
|
cachedState = undefined;
|
|
129
131
|
try {
|
|
130
|
-
ctx.ui.notify(
|
|
132
|
+
ctx.ui.notify("Cleared local analytics data.", "info");
|
|
133
|
+
if (result.cleanupIncomplete) {
|
|
134
|
+
ctx.ui.notify(
|
|
135
|
+
"Some obsolete analytics files are still in use. Stop other Pi processes and clear again to remove them.",
|
|
136
|
+
"warning",
|
|
137
|
+
);
|
|
138
|
+
}
|
|
131
139
|
} catch {
|
|
132
|
-
// Session replacement can invalidate the UI after the
|
|
140
|
+
// Session replacement can invalidate the UI after the generation switch.
|
|
133
141
|
}
|
|
134
142
|
return signal.aborted ? { kind: "close" } : { kind: "to", screen: "main" };
|
|
135
143
|
},
|
|
@@ -233,7 +241,7 @@ function skillsScreen(result: AnalyticsLoadResult) {
|
|
|
233
241
|
function skillItem(skill: SkillStats) {
|
|
234
242
|
return {
|
|
235
243
|
id: skill.name,
|
|
236
|
-
label: skill.name,
|
|
244
|
+
label: safeDisplayText(skill.name),
|
|
237
245
|
statusText: `${skill.count} · ${skill.modelInitiated} model / ${skill.userInitiated} user`,
|
|
238
246
|
searchText: skill.models.map(modelLabel).join(" "),
|
|
239
247
|
details: [
|
|
@@ -275,7 +283,7 @@ function toolsScreen(result: AnalyticsLoadResult) {
|
|
|
275
283
|
function toolItem(tool: ToolStats) {
|
|
276
284
|
return {
|
|
277
285
|
id: tool.name,
|
|
278
|
-
label: tool.name,
|
|
286
|
+
label: safeDisplayText(tool.name),
|
|
279
287
|
statusText: `${tool.count} · ${tool.errors} errors`,
|
|
280
288
|
searchText: tool.models.map(modelLabel).join(" "),
|
|
281
289
|
details: [
|
|
@@ -333,14 +341,14 @@ function responseLines(result: AnalyticsLoadResult): string[] {
|
|
|
333
341
|
|
|
334
342
|
function privacyLines(state: AnalyticsMenuState): string[] {
|
|
335
343
|
return [
|
|
336
|
-
"Local
|
|
337
|
-
state.path,
|
|
344
|
+
"Local analytics files:",
|
|
345
|
+
safeDisplayText(state.path),
|
|
338
346
|
"",
|
|
339
|
-
"Stored: timestamps, model/provider IDs, thinking level, tool and skill names, durations, counts, HTTP statuses, and classified errors.",
|
|
347
|
+
"Stored: timestamps, extension-generated record IDs, model/provider IDs, thinking level, tool and skill names, durations, counts, HTTP statuses, and classified errors.",
|
|
340
348
|
"Not stored: prompts, responses, thinking, tool arguments/results, raw errors, headers, cwd/file paths, session identity, or credentials.",
|
|
341
349
|
"",
|
|
342
|
-
"No
|
|
343
|
-
"
|
|
350
|
+
"No database server, cloud connection, or other remote telemetry is used.",
|
|
351
|
+
"Analytics are non-critical derived metadata; a failed local write may be dropped.",
|
|
344
352
|
];
|
|
345
353
|
}
|
|
346
354
|
|
|
@@ -360,7 +368,14 @@ function formatTimestamp(value: number): string {
|
|
|
360
368
|
|
|
361
369
|
function modelLabel(model: { provider?: string; model?: string }): string {
|
|
362
370
|
if (!model.provider && !model.model) return "unknown";
|
|
363
|
-
return `${model.provider ?? "unknown"}/${model.model ?? "unknown"}
|
|
371
|
+
return safeDisplayText(`${model.provider ?? "unknown"}/${model.model ?? "unknown"}`);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function safeDisplayText(value: unknown): string {
|
|
375
|
+
return Array.from(stripVTControlCharacters(String(value)), (character) => {
|
|
376
|
+
const codePoint = character.codePointAt(0) ?? 0;
|
|
377
|
+
return codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f) ? " " : character;
|
|
378
|
+
}).join("");
|
|
364
379
|
}
|
|
365
380
|
|
|
366
381
|
function isRangeId(value: string): value is TimeRangeId {
|
package/src/skills.ts
CHANGED
|
@@ -22,7 +22,10 @@ export class SkillTracker {
|
|
|
22
22
|
private readonly skillByPath = new Map<string, string>();
|
|
23
23
|
private readonly availableNames = new Set<string>();
|
|
24
24
|
|
|
25
|
-
constructor(
|
|
25
|
+
constructor(
|
|
26
|
+
private readonly cwd: string,
|
|
27
|
+
private readonly canonicalize: (filePath: string) => Promise<string> = realpath,
|
|
28
|
+
) {}
|
|
26
29
|
|
|
27
30
|
observeInput(text: string, source: InputSource, now: number): void {
|
|
28
31
|
if (source === "extension") return;
|
|
@@ -52,7 +55,7 @@ export class SkillTracker {
|
|
|
52
55
|
if (seenNames.has(skill.name)) continue;
|
|
53
56
|
seenNames.add(skill.name);
|
|
54
57
|
this.availableNames.add(skill.name);
|
|
55
|
-
const canonical = await
|
|
58
|
+
const canonical = await this.canonicalize(skill.filePath).catch(() =>
|
|
56
59
|
path.resolve(this.cwd, skill.filePath),
|
|
57
60
|
);
|
|
58
61
|
this.skillByPath.set(canonical, skill.name);
|
|
@@ -69,15 +72,11 @@ export class SkillTracker {
|
|
|
69
72
|
if (typeof rawPath !== "string" || rawPath.length === 0) return undefined;
|
|
70
73
|
const normalized = rawPath.startsWith("@") ? rawPath.slice(1) : rawPath;
|
|
71
74
|
const absolute = path.resolve(this.cwd, normalized);
|
|
72
|
-
const canonical = await
|
|
75
|
+
const canonical = await this.canonicalize(absolute).catch(() => absolute);
|
|
73
76
|
return this.skillByPath.get(canonical);
|
|
74
77
|
}
|
|
75
78
|
}
|
|
76
79
|
|
|
77
|
-
function canonicalPath(filePath: string): Promise<string> {
|
|
78
|
-
return realpath(filePath);
|
|
79
|
-
}
|
|
80
|
-
|
|
81
80
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
82
81
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
83
82
|
}
|
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { createReadStream, type Dirent } from "node:fs";
|
|
3
|
+
import {
|
|
4
|
+
chmod,
|
|
5
|
+
lstat,
|
|
6
|
+
mkdir,
|
|
7
|
+
open,
|
|
8
|
+
readdir,
|
|
9
|
+
readFile,
|
|
10
|
+
rename,
|
|
11
|
+
rm,
|
|
12
|
+
rmdir,
|
|
13
|
+
unlink,
|
|
14
|
+
writeFile,
|
|
15
|
+
} from "node:fs/promises";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import type { SettledRun } from "../types.js";
|
|
18
|
+
import {
|
|
19
|
+
AnalyticsStorageFormatError,
|
|
20
|
+
decodeStoredRun,
|
|
21
|
+
encodeStoredRun,
|
|
22
|
+
MAX_STORED_RUN_BYTES,
|
|
23
|
+
} from "./format.js";
|
|
24
|
+
|
|
25
|
+
const GENERATION_PATTERN =
|
|
26
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u;
|
|
27
|
+
const DEFAULT_WRITE_TIMEOUT_MS = 500;
|
|
28
|
+
const YIELD_EVERY_RECORDS = 100;
|
|
29
|
+
|
|
30
|
+
export interface ClearAnalyticsResult {
|
|
31
|
+
cleanupIncomplete: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export class AnalyticsGenerationChangedError extends Error {
|
|
35
|
+
constructor() {
|
|
36
|
+
super("The active analytics generation changed during the read.");
|
|
37
|
+
this.name = "AnalyticsGenerationChangedError";
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export class AnalyticsRunFiles {
|
|
42
|
+
private readonly createId: () => string;
|
|
43
|
+
private readonly writeTimeoutMs: number;
|
|
44
|
+
private readonly beforeAppend?: (generation: string, signal: AbortSignal) => Promise<void>;
|
|
45
|
+
private readonly beforeCleanupEntry?: (signal?: AbortSignal) => Promise<void>;
|
|
46
|
+
private readonly beforeReadFile?: (signal?: AbortSignal) => Promise<void>;
|
|
47
|
+
private writerId: string;
|
|
48
|
+
private mutationTail: Promise<void> = Promise.resolve();
|
|
49
|
+
private readonly lifecycle = new AbortController();
|
|
50
|
+
private closed = false;
|
|
51
|
+
|
|
52
|
+
constructor(
|
|
53
|
+
readonly path: string,
|
|
54
|
+
options: {
|
|
55
|
+
createId?: () => string;
|
|
56
|
+
writeTimeoutMs?: number;
|
|
57
|
+
beforeAppend?: (generation: string, signal: AbortSignal) => Promise<void>;
|
|
58
|
+
beforeCleanupEntry?: (signal?: AbortSignal) => Promise<void>;
|
|
59
|
+
beforeReadFile?: (signal?: AbortSignal) => Promise<void>;
|
|
60
|
+
} = {},
|
|
61
|
+
) {
|
|
62
|
+
this.createId = options.createId ?? randomUUID;
|
|
63
|
+
this.writeTimeoutMs = options.writeTimeoutMs ?? DEFAULT_WRITE_TIMEOUT_MS;
|
|
64
|
+
this.beforeAppend = options.beforeAppend;
|
|
65
|
+
this.beforeCleanupEntry = options.beforeCleanupEntry;
|
|
66
|
+
this.beforeReadFile = options.beforeReadFile;
|
|
67
|
+
this.writerId = validCreatedId(this.createId());
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
append(run: SettledRun, signal?: AbortSignal): Promise<void> {
|
|
71
|
+
if (this.closed) return Promise.reject(new Error("Analytics storage is closed."));
|
|
72
|
+
const frame = encodeStoredRun(run);
|
|
73
|
+
return this.enqueueMutation(() =>
|
|
74
|
+
withDeadline(signal, this.lifecycle.signal, this.writeTimeoutMs, (operationSignal) =>
|
|
75
|
+
this.appendFrame(frame, operationSignal),
|
|
76
|
+
),
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async *read(signal?: AbortSignal): AsyncIterable<SettledRun> {
|
|
81
|
+
throwIfAborted(signal);
|
|
82
|
+
const generation = await this.readOrCreateGeneration(signal);
|
|
83
|
+
const directory = this.generationPath(generation);
|
|
84
|
+
let entries: Dirent[];
|
|
85
|
+
try {
|
|
86
|
+
entries = await readdir(directory, { withFileTypes: true });
|
|
87
|
+
} catch (error) {
|
|
88
|
+
if (isNodeError(error) && error.code === "ENOENT") {
|
|
89
|
+
await this.assertGenerationUnchanged(generation, signal);
|
|
90
|
+
throw new AnalyticsGenerationChangedError();
|
|
91
|
+
}
|
|
92
|
+
throw error;
|
|
93
|
+
}
|
|
94
|
+
let count = 0;
|
|
95
|
+
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
|
|
96
|
+
throwIfAborted(signal);
|
|
97
|
+
if (!entry.name.endsWith(".jsonl")) continue;
|
|
98
|
+
const filePath = path.join(directory, entry.name);
|
|
99
|
+
try {
|
|
100
|
+
await this.beforeReadFile?.(signal);
|
|
101
|
+
throwIfAborted(signal);
|
|
102
|
+
await assertPrivateRegularFile(filePath);
|
|
103
|
+
for await (const run of readFrames(filePath, signal)) {
|
|
104
|
+
yield run;
|
|
105
|
+
count += 1;
|
|
106
|
+
if (count % YIELD_EVERY_RECORDS === 0) await yieldToEventLoop(signal);
|
|
107
|
+
}
|
|
108
|
+
} catch (error) {
|
|
109
|
+
if (isNodeError(error) && error.code === "ENOENT") {
|
|
110
|
+
await this.assertGenerationUnchanged(generation, signal);
|
|
111
|
+
throw new AnalyticsGenerationChangedError();
|
|
112
|
+
}
|
|
113
|
+
throw error;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
await this.assertGenerationUnchanged(generation, signal);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
clear(signal?: AbortSignal): Promise<ClearAnalyticsResult> {
|
|
120
|
+
if (this.closed) return Promise.reject(new Error("Analytics storage is closed."));
|
|
121
|
+
let result: ClearAnalyticsResult = { cleanupIncomplete: false };
|
|
122
|
+
return this.enqueueMutation(() =>
|
|
123
|
+
withLinkedSignals(signal, this.lifecycle.signal, async (operationSignal) => {
|
|
124
|
+
throwIfAborted(operationSignal);
|
|
125
|
+
await this.readOrCreateGeneration(operationSignal);
|
|
126
|
+
const next = validCreatedId(this.createId());
|
|
127
|
+
await this.publishGeneration(next);
|
|
128
|
+
this.writerId = validCreatedId(this.createId());
|
|
129
|
+
const current = await readGenerationMarker(path.join(this.path, "current"));
|
|
130
|
+
await ensurePrivateDirectory(this.generationPath(current));
|
|
131
|
+
if (!(await this.cleanupObsoleteGenerations(operationSignal))) {
|
|
132
|
+
result = { cleanupIncomplete: true };
|
|
133
|
+
}
|
|
134
|
+
}),
|
|
135
|
+
).then(() => result);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async close(): Promise<void> {
|
|
139
|
+
if (this.closed) return;
|
|
140
|
+
this.closed = true;
|
|
141
|
+
this.lifecycle.abort(new DOMException("Analytics storage closed", "AbortError"));
|
|
142
|
+
await this.mutationTail.catch(() => undefined);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
private enqueueMutation(operation: () => Promise<void>): Promise<void> {
|
|
146
|
+
const result = this.mutationTail.then(operation);
|
|
147
|
+
this.mutationTail = result.catch(() => undefined);
|
|
148
|
+
return result;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
private async appendFrame(frame: string, signal: AbortSignal): Promise<void> {
|
|
152
|
+
throwIfAborted(signal);
|
|
153
|
+
let obsoleteDirectory: string | undefined;
|
|
154
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
155
|
+
const generation = await this.readOrCreateGeneration(signal);
|
|
156
|
+
const directory = this.generationPath(generation);
|
|
157
|
+
await ensurePrivateDirectory(directory);
|
|
158
|
+
const filePath = path.join(directory, `${this.writerId}.jsonl`);
|
|
159
|
+
await assertOptionalPrivateRegularFile(filePath);
|
|
160
|
+
throwIfAborted(signal);
|
|
161
|
+
await this.beforeAppend?.(generation, signal);
|
|
162
|
+
throwIfAborted(signal);
|
|
163
|
+
try {
|
|
164
|
+
await writeFile(filePath, frame, {
|
|
165
|
+
encoding: "utf8",
|
|
166
|
+
flag: "a",
|
|
167
|
+
mode: 0o600,
|
|
168
|
+
signal,
|
|
169
|
+
});
|
|
170
|
+
if (process.platform !== "win32") await chmod(filePath, 0o600);
|
|
171
|
+
const current = await readGenerationMarker(path.join(this.path, "current"), signal);
|
|
172
|
+
if (current === generation) {
|
|
173
|
+
if (obsoleteDirectory) {
|
|
174
|
+
await cleanupGeneration(obsoleteDirectory, signal).catch(() => undefined);
|
|
175
|
+
}
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
obsoleteDirectory = directory;
|
|
179
|
+
} catch (error) {
|
|
180
|
+
this.writerId = validCreatedId(this.createId());
|
|
181
|
+
throwIfAborted(signal);
|
|
182
|
+
if (!isNodeError(error) || error.code !== "ENOENT") throw error;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
throw new AnalyticsGenerationChangedError();
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
private async readOrCreateGeneration(signal?: AbortSignal): Promise<string> {
|
|
189
|
+
throwIfAborted(signal);
|
|
190
|
+
await ensurePrivateDirectory(this.path);
|
|
191
|
+
await ensurePrivateDirectory(path.join(this.path, "generations"));
|
|
192
|
+
const markerPath = path.join(this.path, "current");
|
|
193
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
194
|
+
let generation: string;
|
|
195
|
+
try {
|
|
196
|
+
generation = await readGenerationMarker(markerPath, signal);
|
|
197
|
+
} catch (error) {
|
|
198
|
+
if (!isNodeError(error) || error.code !== "ENOENT") throw error;
|
|
199
|
+
generation = validCreatedId(this.createId());
|
|
200
|
+
throwIfAborted(signal);
|
|
201
|
+
try {
|
|
202
|
+
await createPrivateFile(markerPath, `${generation}\n`);
|
|
203
|
+
} catch (createError) {
|
|
204
|
+
if (!isNodeError(createError) || createError.code !== "EEXIST") throw createError;
|
|
205
|
+
generation = await readGenerationMarker(markerPath, signal);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
await ensurePrivateDirectory(this.generationPath(generation));
|
|
209
|
+
const current = await readGenerationMarker(markerPath, signal);
|
|
210
|
+
if (current === generation) return generation;
|
|
211
|
+
}
|
|
212
|
+
throw new AnalyticsGenerationChangedError();
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
private async publishGeneration(generation: string): Promise<void> {
|
|
216
|
+
const markerPath = path.join(this.path, "current");
|
|
217
|
+
const temporaryPath = path.join(this.path, `.current.${validCreatedId(this.createId())}.tmp`);
|
|
218
|
+
await createPrivateFile(temporaryPath, `${generation}\n`);
|
|
219
|
+
try {
|
|
220
|
+
await rename(temporaryPath, markerPath);
|
|
221
|
+
if (process.platform !== "win32") await chmod(markerPath, 0o600);
|
|
222
|
+
} catch (error) {
|
|
223
|
+
await rm(temporaryPath, { force: true }).catch(() => undefined);
|
|
224
|
+
throw error;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
private async cleanupObsoleteGenerations(signal?: AbortSignal): Promise<boolean> {
|
|
229
|
+
let complete = true;
|
|
230
|
+
const root = path.join(this.path, "generations");
|
|
231
|
+
for (const entry of await readdir(root, { withFileTypes: true })) {
|
|
232
|
+
const active = await readGenerationMarker(path.join(this.path, "current"));
|
|
233
|
+
if (entry.name === active) continue;
|
|
234
|
+
if (!entry.isDirectory() || !GENERATION_PATTERN.test(entry.name)) {
|
|
235
|
+
complete = false;
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
try {
|
|
239
|
+
await cleanupGeneration(path.join(root, entry.name), signal, this.beforeCleanupEntry);
|
|
240
|
+
} catch {
|
|
241
|
+
complete = false;
|
|
242
|
+
if (signal?.aborted) break;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
const active = await readGenerationMarker(path.join(this.path, "current"));
|
|
246
|
+
const remaining = await readdir(root, { withFileTypes: true });
|
|
247
|
+
return complete && remaining.every((entry) => entry.isDirectory() && entry.name === active);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
private async assertGenerationUnchanged(generation: string, signal?: AbortSignal): Promise<void> {
|
|
251
|
+
const current = await readGenerationMarker(path.join(this.path, "current"), signal);
|
|
252
|
+
if (current !== generation) throw new AnalyticsGenerationChangedError();
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
private generationPath(generation: string): string {
|
|
256
|
+
return path.join(this.path, "generations", generation);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async function* readFrames(filePath: string, signal?: AbortSignal): AsyncIterable<SettledRun> {
|
|
261
|
+
let pending = "";
|
|
262
|
+
const stream = createReadStream(filePath, { encoding: "utf8", signal });
|
|
263
|
+
for await (const chunk of stream) {
|
|
264
|
+
throwIfAborted(signal);
|
|
265
|
+
pending += String(chunk);
|
|
266
|
+
while (true) {
|
|
267
|
+
const newline = pending.indexOf("\n");
|
|
268
|
+
if (newline < 0) break;
|
|
269
|
+
const line = pending.slice(0, newline);
|
|
270
|
+
pending = pending.slice(newline + 1);
|
|
271
|
+
if (!line) continue;
|
|
272
|
+
yield decodeStoredRun(line);
|
|
273
|
+
}
|
|
274
|
+
if (Buffer.byteLength(pending) > MAX_STORED_RUN_BYTES) {
|
|
275
|
+
throw new AnalyticsStorageFormatError("Analytics record is too large to read safely.");
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
// A writer always terminates complete frames with a newline. An unterminated tail is a
|
|
279
|
+
// crash residue and is ignored; this writer file will not be reused after its process exits.
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async function readGenerationMarker(markerPath: string, signal?: AbortSignal): Promise<string> {
|
|
283
|
+
await assertPrivateRegularFile(markerPath);
|
|
284
|
+
throwIfAborted(signal);
|
|
285
|
+
const value = (await readFile(markerPath, { encoding: "utf8", signal })).trim();
|
|
286
|
+
if (!GENERATION_PATTERN.test(value)) {
|
|
287
|
+
throw new AnalyticsStorageFormatError("Analytics generation marker is invalid.");
|
|
288
|
+
}
|
|
289
|
+
return value;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async function ensurePrivateDirectory(directoryPath: string): Promise<void> {
|
|
293
|
+
try {
|
|
294
|
+
const metadata = await lstat(directoryPath);
|
|
295
|
+
if (!metadata.isDirectory() || metadata.isSymbolicLink()) {
|
|
296
|
+
throw new Error("Analytics storage paths must be regular directories, not links.");
|
|
297
|
+
}
|
|
298
|
+
} catch (error) {
|
|
299
|
+
if (!isNodeError(error) || error.code !== "ENOENT") throw error;
|
|
300
|
+
await mkdir(directoryPath, { recursive: true, mode: 0o700 });
|
|
301
|
+
const metadata = await lstat(directoryPath);
|
|
302
|
+
if (!metadata.isDirectory() || metadata.isSymbolicLink()) {
|
|
303
|
+
throw new Error("Analytics storage paths must be regular directories, not links.");
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
if (process.platform !== "win32") await chmod(directoryPath, 0o700);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async function assertOptionalPrivateRegularFile(filePath: string): Promise<void> {
|
|
310
|
+
try {
|
|
311
|
+
await assertPrivateRegularFile(filePath);
|
|
312
|
+
} catch (error) {
|
|
313
|
+
if (isNodeError(error) && error.code === "ENOENT") return;
|
|
314
|
+
throw error;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
async function assertPrivateRegularFile(filePath: string): Promise<void> {
|
|
319
|
+
const metadata = await lstat(filePath);
|
|
320
|
+
if (!metadata.isFile() || metadata.isSymbolicLink()) {
|
|
321
|
+
throw new Error("Analytics storage files must be regular files, not links.");
|
|
322
|
+
}
|
|
323
|
+
if (process.platform !== "win32") await chmod(filePath, 0o600);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async function createPrivateFile(filePath: string, content: string): Promise<void> {
|
|
327
|
+
const handle = await open(filePath, "wx", 0o600);
|
|
328
|
+
try {
|
|
329
|
+
await handle.writeFile(content, "utf8");
|
|
330
|
+
await handle.sync();
|
|
331
|
+
} finally {
|
|
332
|
+
await handle.close();
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
async function cleanupGeneration(
|
|
337
|
+
directoryPath: string,
|
|
338
|
+
signal?: AbortSignal,
|
|
339
|
+
beforeEntry?: (signal?: AbortSignal) => Promise<void>,
|
|
340
|
+
): Promise<void> {
|
|
341
|
+
throwIfAborted(signal);
|
|
342
|
+
let entries: Dirent[];
|
|
343
|
+
try {
|
|
344
|
+
entries = await readdir(directoryPath, { withFileTypes: true });
|
|
345
|
+
} catch (error) {
|
|
346
|
+
if (isNodeError(error) && error.code === "ENOENT") return;
|
|
347
|
+
throw error;
|
|
348
|
+
}
|
|
349
|
+
for (const entry of entries) {
|
|
350
|
+
throwIfAborted(signal);
|
|
351
|
+
await beforeEntry?.(signal);
|
|
352
|
+
throwIfAborted(signal);
|
|
353
|
+
if (!entry.isFile() || !entry.name.endsWith(".jsonl")) {
|
|
354
|
+
throw new Error("Analytics generation contains an unexpected storage entry.");
|
|
355
|
+
}
|
|
356
|
+
await unlink(path.join(directoryPath, entry.name));
|
|
357
|
+
}
|
|
358
|
+
throwIfAborted(signal);
|
|
359
|
+
await rmdir(directoryPath);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
async function withLinkedSignals<T>(
|
|
363
|
+
callerSignal: AbortSignal | undefined,
|
|
364
|
+
lifecycleSignal: AbortSignal,
|
|
365
|
+
operation: (signal: AbortSignal) => Promise<T>,
|
|
366
|
+
): Promise<T> {
|
|
367
|
+
throwIfAborted(callerSignal);
|
|
368
|
+
throwIfAborted(lifecycleSignal);
|
|
369
|
+
const controller = new AbortController();
|
|
370
|
+
const abort = (signal: AbortSignal) =>
|
|
371
|
+
controller.abort(
|
|
372
|
+
signal.reason ?? new DOMException("Analytics operation aborted", "AbortError"),
|
|
373
|
+
);
|
|
374
|
+
const callerAbort = () => callerSignal && abort(callerSignal);
|
|
375
|
+
const lifecycleAbort = () => abort(lifecycleSignal);
|
|
376
|
+
callerSignal?.addEventListener("abort", callerAbort, { once: true });
|
|
377
|
+
lifecycleSignal.addEventListener("abort", lifecycleAbort, { once: true });
|
|
378
|
+
try {
|
|
379
|
+
return await operation(controller.signal);
|
|
380
|
+
} finally {
|
|
381
|
+
callerSignal?.removeEventListener("abort", callerAbort);
|
|
382
|
+
lifecycleSignal.removeEventListener("abort", lifecycleAbort);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
async function withDeadline<T>(
|
|
387
|
+
callerSignal: AbortSignal | undefined,
|
|
388
|
+
lifecycleSignal: AbortSignal,
|
|
389
|
+
timeoutMs: number,
|
|
390
|
+
operation: (signal: AbortSignal) => Promise<T>,
|
|
391
|
+
): Promise<T> {
|
|
392
|
+
throwIfAborted(callerSignal);
|
|
393
|
+
throwIfAborted(lifecycleSignal);
|
|
394
|
+
const controller = new AbortController();
|
|
395
|
+
const abort = (signal: AbortSignal) =>
|
|
396
|
+
controller.abort(
|
|
397
|
+
signal.reason ?? new DOMException("Analytics operation aborted", "AbortError"),
|
|
398
|
+
);
|
|
399
|
+
const callerAbort = () => callerSignal && abort(callerSignal);
|
|
400
|
+
const lifecycleAbort = () => abort(lifecycleSignal);
|
|
401
|
+
callerSignal?.addEventListener("abort", callerAbort, { once: true });
|
|
402
|
+
lifecycleSignal.addEventListener("abort", lifecycleAbort, { once: true });
|
|
403
|
+
const timer = setTimeout(
|
|
404
|
+
() => controller.abort(new DOMException("Analytics write timed out", "TimeoutError")),
|
|
405
|
+
Math.max(1, timeoutMs),
|
|
406
|
+
);
|
|
407
|
+
try {
|
|
408
|
+
return await operation(controller.signal);
|
|
409
|
+
} finally {
|
|
410
|
+
clearTimeout(timer);
|
|
411
|
+
callerSignal?.removeEventListener("abort", callerAbort);
|
|
412
|
+
lifecycleSignal.removeEventListener("abort", lifecycleAbort);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
async function yieldToEventLoop(signal?: AbortSignal): Promise<void> {
|
|
417
|
+
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
418
|
+
throwIfAborted(signal);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function validCreatedId(value: string): string {
|
|
422
|
+
if (!GENERATION_PATTERN.test(value)) throw new Error("Analytics storage received an invalid ID.");
|
|
423
|
+
return value;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function throwIfAborted(signal?: AbortSignal): void {
|
|
427
|
+
if (signal?.aborted) {
|
|
428
|
+
throw signal.reason ?? new DOMException("Analytics operation aborted", "AbortError");
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function isNodeError(error: unknown): error is NodeJS.ErrnoException {
|
|
433
|
+
return error instanceof Error && "code" in error;
|
|
434
|
+
}
|