@raquezha/notrace 0.1.0 → 0.1.2
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/CHANGELOG.md +16 -0
- package/dist/notrace/index.d.ts +34 -0
- package/dist/notrace/index.js +142 -117
- package/extensions/notrace/__tests__/ghost-session.test.ts +103 -0
- package/extensions/notrace/__tests__/helpers.ts +11 -0
- package/extensions/notrace/__tests__/lock-race.test.ts +176 -0
- package/extensions/notrace/__tests__/usage-normalization.test.ts +80 -0
- package/extensions/notrace/index.ts +158 -123
- package/package.json +4 -2
- package/templates/dashboard.sample.json +1 -1
- package/templates/sessions/019ed2ee-1002-76ee-b353-000000000003/notrace.html +1 -1
- package/templates/sessions/019ed2ee-1002-76ee-b353-000000000003/notrace.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# @raquezha/notrace
|
|
2
2
|
|
|
3
|
+
## 0.1.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- a0f076e: Fix notrace index lock behavior under contention and add regression coverage for lock handling and usage normalization.
|
|
8
|
+
- 894da0a: Add Vitest scaffolding and extract `handleSessionShutdown` for testability with no intended runtime behavior change.
|
|
9
|
+
- 9d6c8b2: Skip writing per-session notrace artifacts for ghost sessions and add regression coverage for ghost vs non-ghost shutdown behavior.
|
|
10
|
+
|
|
11
|
+
## 0.1.1
|
|
12
|
+
|
|
13
|
+
### Patch Changes
|
|
14
|
+
|
|
15
|
+
- 13be706: refactor: split antigravity monolith and implement dynamic model routing, validated toolConfig, interleaved thinking headers, and empty stream retries
|
|
16
|
+
|
|
17
|
+
docs: replace stale public model IDs in notrace sample templates
|
|
18
|
+
|
|
3
19
|
## 0.1.0
|
|
4
20
|
|
|
5
21
|
### Minor Changes
|
package/dist/notrace/index.d.ts
CHANGED
|
@@ -1,2 +1,36 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { NotraceCaptureMode, NotraceEvent, NotraceExtensionTelemetry } from "./types.js";
|
|
3
|
+
import { type WorkflowAdapter } from "./adapters.js";
|
|
4
|
+
type UsageLike = {
|
|
5
|
+
input?: number;
|
|
6
|
+
output?: number;
|
|
7
|
+
inputTokens?: number;
|
|
8
|
+
outputTokens?: number;
|
|
9
|
+
cacheRead?: number;
|
|
10
|
+
cacheWrite?: number;
|
|
11
|
+
cacheReadTokens?: number;
|
|
12
|
+
cacheWriteTokens?: number;
|
|
13
|
+
totalTokens?: number;
|
|
14
|
+
cost?: {
|
|
15
|
+
input?: number;
|
|
16
|
+
output?: number;
|
|
17
|
+
cacheRead?: number;
|
|
18
|
+
cacheWrite?: number;
|
|
19
|
+
total?: number;
|
|
20
|
+
};
|
|
21
|
+
};
|
|
22
|
+
export declare function normalizeUsage(raw: unknown): Required<Pick<UsageLike, "inputTokens" | "outputTokens" | "cacheReadTokens" | "cacheWriteTokens" | "totalTokens">> & {
|
|
23
|
+
totalCostUsd: number;
|
|
24
|
+
};
|
|
25
|
+
export type SessionShutdownDeps = {
|
|
26
|
+
events: NotraceEvent[];
|
|
27
|
+
startTime: number;
|
|
28
|
+
traceId: string;
|
|
29
|
+
extensionTelemetry: Map<string, NotraceExtensionTelemetry>;
|
|
30
|
+
captureMode: NotraceCaptureMode;
|
|
31
|
+
notraceDir: string;
|
|
32
|
+
adapter: WorkflowAdapter;
|
|
33
|
+
};
|
|
34
|
+
export declare function handleSessionShutdown(e: any, ctx: any, deps: SessionShutdownDeps): Promise<void>;
|
|
2
35
|
export default function (pi: ExtensionAPI): void;
|
|
36
|
+
export {};
|
package/dist/notrace/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readFileSync, writeFileSync, mkdirSync, existsSync, renameSync, chmodSync } from "node:fs";
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, renameSync, chmodSync, rmSync } from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import * as os from "node:os";
|
|
4
4
|
import { execSync } from "node:child_process";
|
|
@@ -40,14 +40,18 @@ function sanitizeTraceValue(value) {
|
|
|
40
40
|
function asNumber(value) {
|
|
41
41
|
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
42
42
|
}
|
|
43
|
-
function normalizeUsage(raw) {
|
|
43
|
+
export function normalizeUsage(raw) {
|
|
44
44
|
const usage = (raw && typeof raw === "object" ? raw : {});
|
|
45
|
+
const inputTokens = asNumber(usage.inputTokens ?? usage.input);
|
|
46
|
+
const outputTokens = asNumber(usage.outputTokens ?? usage.output);
|
|
47
|
+
const cacheReadTokens = asNumber(usage.cacheReadTokens ?? usage.cacheRead);
|
|
48
|
+
const cacheWriteTokens = asNumber(usage.cacheWriteTokens ?? usage.cacheWrite);
|
|
45
49
|
return {
|
|
46
|
-
inputTokens
|
|
47
|
-
outputTokens
|
|
48
|
-
cacheReadTokens
|
|
49
|
-
cacheWriteTokens
|
|
50
|
-
totalTokens: asNumber(usage.totalTokens),
|
|
50
|
+
inputTokens,
|
|
51
|
+
outputTokens,
|
|
52
|
+
cacheReadTokens,
|
|
53
|
+
cacheWriteTokens,
|
|
54
|
+
totalTokens: usage.totalTokens == null ? inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens : asNumber(usage.totalTokens),
|
|
51
55
|
totalCostUsd: asNumber(usage.cost?.total),
|
|
52
56
|
};
|
|
53
57
|
}
|
|
@@ -164,6 +168,129 @@ function createIndexEntry(record, htmlPath, recordPath) {
|
|
|
164
168
|
},
|
|
165
169
|
};
|
|
166
170
|
}
|
|
171
|
+
export async function handleSessionShutdown(e, ctx, deps) {
|
|
172
|
+
const shutdownReason = typeof e?.reason === "string" ? e.reason : null;
|
|
173
|
+
const endedAt = Date.now();
|
|
174
|
+
const context = deps.adapter.getContext(ctx.cwd);
|
|
175
|
+
const finalTraceId = ctx.sessionManager?.getSessionId?.() || deps.traceId;
|
|
176
|
+
const outputDir = path.join(deps.notraceDir, "sessions", finalTraceId.replace(/[^a-z0-9]/gi, "-"));
|
|
177
|
+
const repositoryName = path.basename(ctx.cwd);
|
|
178
|
+
let branchName = null;
|
|
179
|
+
try {
|
|
180
|
+
branchName = execSync("git branch --show-current", { cwd: ctx.cwd, stdio: ["ignore", "pipe", "ignore"], encoding: "utf8", timeout: 1000 }).trim() || null;
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
// not a git repo or no commits yet
|
|
184
|
+
}
|
|
185
|
+
const recordPath = path.join(outputDir, "notrace.json");
|
|
186
|
+
let mergedEvents = deps.events;
|
|
187
|
+
let originalStartedAt = deps.startTime;
|
|
188
|
+
let originalTask = null;
|
|
189
|
+
if (existsSync(recordPath)) {
|
|
190
|
+
try {
|
|
191
|
+
const oldRecord = readJsonFile(recordPath, null);
|
|
192
|
+
if (Array.isArray(oldRecord.events)) {
|
|
193
|
+
mergedEvents = [...oldRecord.events, ...deps.events];
|
|
194
|
+
}
|
|
195
|
+
if (oldRecord.session?.startedAt) {
|
|
196
|
+
originalStartedAt = new Date(oldRecord.session.startedAt).getTime();
|
|
197
|
+
}
|
|
198
|
+
if (oldRecord.task) {
|
|
199
|
+
originalTask = oldRecord.task;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
catch (err) {
|
|
203
|
+
// ignore parse errors
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
const activity = collectActivity(mergedEvents, originalStartedAt, endedAt);
|
|
207
|
+
// Do not index purely empty ghost sessions
|
|
208
|
+
const isGhostSession = activity.llmCallCount === 0 && activity.toolCallCount === 0 && activity.totals.totalTokens === 0;
|
|
209
|
+
const telemetry = Object.fromEntries([...deps.extensionTelemetry.entries()].sort(([a], [b]) => a.localeCompare(b)));
|
|
210
|
+
const record = {
|
|
211
|
+
kind: "notrace-run",
|
|
212
|
+
schemaVersion: SCHEMA_VERSION,
|
|
213
|
+
traceId: finalTraceId,
|
|
214
|
+
repository: {
|
|
215
|
+
name: repositoryName,
|
|
216
|
+
cwd: ctx.cwd,
|
|
217
|
+
branch: branchName,
|
|
218
|
+
},
|
|
219
|
+
session: {
|
|
220
|
+
id: finalTraceId,
|
|
221
|
+
startedAt: new Date(originalStartedAt).toISOString(),
|
|
222
|
+
endedAt: new Date(endedAt).toISOString(),
|
|
223
|
+
durationMs: activity.durationMs,
|
|
224
|
+
shutdownReason,
|
|
225
|
+
},
|
|
226
|
+
task: toTaskInfo(context) || originalTask,
|
|
227
|
+
captureMode: deps.captureMode,
|
|
228
|
+
conditions: buildConditions(mergedEvents, telemetry),
|
|
229
|
+
activity,
|
|
230
|
+
telemetry: { extensions: telemetry },
|
|
231
|
+
events: mergedEvents,
|
|
232
|
+
};
|
|
233
|
+
validateRunRecord(record);
|
|
234
|
+
const htmlPath = path.join(outputDir, "notrace.html");
|
|
235
|
+
if (!isGhostSession) {
|
|
236
|
+
const html = generateHtmlReport(record);
|
|
237
|
+
mkdirSync(outputDir, { recursive: true });
|
|
238
|
+
writePrivateFileAtomic(htmlPath, html);
|
|
239
|
+
writePrivateFileAtomic(recordPath, `${JSON.stringify(record, null, 2)}\n`);
|
|
240
|
+
}
|
|
241
|
+
const indexPath = path.join(deps.notraceDir, "index.json");
|
|
242
|
+
const lockPath = `${indexPath}.lock`;
|
|
243
|
+
let lockAcquired = false;
|
|
244
|
+
for (let i = 0; i < 20; i++) {
|
|
245
|
+
try {
|
|
246
|
+
writeFileSync(lockPath, `${process.pid}`, { flag: "wx" });
|
|
247
|
+
lockAcquired = true;
|
|
248
|
+
break;
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
const t = Date.now();
|
|
252
|
+
while (Date.now() - t < 50) { } // busy wait 50ms
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
if (!lockAcquired) {
|
|
256
|
+
// Could not get exclusive access to the index after retrying. Skip the
|
|
257
|
+
// index/dashboard update rather than racing another process's
|
|
258
|
+
// read-modify-write on index.json. The per-session record and HTML
|
|
259
|
+
// report were already written above and are not affected.
|
|
260
|
+
console.warn(`[notrace] Could not acquire index lock, skipping index update for ${finalTraceId}`);
|
|
261
|
+
}
|
|
262
|
+
else {
|
|
263
|
+
try {
|
|
264
|
+
const existing = readJsonFile(indexPath, { sessions: [] });
|
|
265
|
+
let sessions = Array.isArray(existing.sessions) ? existing.sessions.filter((s) => s.sessionId !== finalTraceId) : [];
|
|
266
|
+
if (!isGhostSession) {
|
|
267
|
+
sessions.push(createIndexEntry(record, htmlPath, recordPath));
|
|
268
|
+
}
|
|
269
|
+
writePrivateFileAtomic(indexPath, `${JSON.stringify({ sessions }, null, 2)}\n`);
|
|
270
|
+
writePrivateFileAtomic(path.join(deps.notraceDir, "index.html"), generateDashboardHtml(sessions, {}));
|
|
271
|
+
}
|
|
272
|
+
finally {
|
|
273
|
+
if (existsSync(lockPath)) {
|
|
274
|
+
try {
|
|
275
|
+
rmSync(lockPath);
|
|
276
|
+
}
|
|
277
|
+
catch { }
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
if (context && !isGhostSession) {
|
|
282
|
+
const displayPath = htmlPath.startsWith(os.homedir())
|
|
283
|
+
? `~${htmlPath.slice(os.homedir().length)}`
|
|
284
|
+
: htmlPath;
|
|
285
|
+
deps.adapter.attach(context, {
|
|
286
|
+
html: displayPath,
|
|
287
|
+
record: recordPath
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
if (!isGhostSession) {
|
|
291
|
+
console.log(`\n\x1b[1m\x1b[38;5;208m[notrace] Session Retrospective: file://${htmlPath}\x1b[0m\n`);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
167
294
|
function normalizeTelemetryPayload(raw) {
|
|
168
295
|
if (!raw || typeof raw !== "object")
|
|
169
296
|
return null;
|
|
@@ -194,7 +321,6 @@ export default function (pi) {
|
|
|
194
321
|
const startTime = Date.now();
|
|
195
322
|
let traceId = "";
|
|
196
323
|
let activeLlmPayload = null;
|
|
197
|
-
let shutdownReason = null;
|
|
198
324
|
const extensionTelemetry = new Map();
|
|
199
325
|
currentMode = getInitialMode();
|
|
200
326
|
if (typeof pi.events?.on === "function") {
|
|
@@ -249,115 +375,14 @@ export default function (pi) {
|
|
|
249
375
|
}
|
|
250
376
|
});
|
|
251
377
|
pi.on("session_shutdown", async (e, ctx) => {
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
const finalTraceId = ctx.sessionManager?.getSessionId?.() || traceId;
|
|
258
|
-
const outputDir = path.join(notraceDir, "sessions", finalTraceId.replace(/[^a-z0-9]/gi, "-"));
|
|
259
|
-
const repositoryName = path.basename(ctx.cwd);
|
|
260
|
-
let branchName = null;
|
|
261
|
-
try {
|
|
262
|
-
branchName = execSync("git branch --show-current", { cwd: ctx.cwd, stdio: ["ignore", "pipe", "ignore"], encoding: "utf8", timeout: 1000 }).trim() || null;
|
|
263
|
-
}
|
|
264
|
-
catch {
|
|
265
|
-
// not a git repo or no commits yet
|
|
266
|
-
}
|
|
267
|
-
const recordPath = path.join(outputDir, "notrace.json");
|
|
268
|
-
let mergedEvents = events;
|
|
269
|
-
let originalStartedAt = startTime;
|
|
270
|
-
let originalTask = null;
|
|
271
|
-
if (existsSync(recordPath)) {
|
|
272
|
-
try {
|
|
273
|
-
const oldRecord = readJsonFile(recordPath, null);
|
|
274
|
-
if (Array.isArray(oldRecord.events)) {
|
|
275
|
-
mergedEvents = [...oldRecord.events, ...events];
|
|
276
|
-
}
|
|
277
|
-
if (oldRecord.session?.startedAt) {
|
|
278
|
-
originalStartedAt = new Date(oldRecord.session.startedAt).getTime();
|
|
279
|
-
}
|
|
280
|
-
if (oldRecord.task) {
|
|
281
|
-
originalTask = oldRecord.task;
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
catch (err) {
|
|
285
|
-
// ignore parse errors
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
const activity = collectActivity(mergedEvents, originalStartedAt, endedAt);
|
|
289
|
-
// Do not index purely empty ghost sessions
|
|
290
|
-
const isGhostSession = activity.llmCallCount === 0 && activity.toolCallCount === 0 && activity.totals.totalTokens === 0;
|
|
291
|
-
const telemetry = Object.fromEntries([...extensionTelemetry.entries()].sort(([a], [b]) => a.localeCompare(b)));
|
|
292
|
-
const record = {
|
|
293
|
-
kind: "notrace-run",
|
|
294
|
-
schemaVersion: SCHEMA_VERSION,
|
|
295
|
-
traceId: finalTraceId,
|
|
296
|
-
repository: {
|
|
297
|
-
name: repositoryName,
|
|
298
|
-
cwd: ctx.cwd,
|
|
299
|
-
branch: branchName,
|
|
300
|
-
},
|
|
301
|
-
session: {
|
|
302
|
-
id: finalTraceId,
|
|
303
|
-
startedAt: new Date(originalStartedAt).toISOString(),
|
|
304
|
-
endedAt: new Date(endedAt).toISOString(),
|
|
305
|
-
durationMs: activity.durationMs,
|
|
306
|
-
shutdownReason,
|
|
307
|
-
},
|
|
308
|
-
task: toTaskInfo(context) || originalTask,
|
|
378
|
+
await handleSessionShutdown(e, ctx, {
|
|
379
|
+
events,
|
|
380
|
+
startTime,
|
|
381
|
+
traceId,
|
|
382
|
+
extensionTelemetry,
|
|
309
383
|
captureMode: currentMode,
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
events: mergedEvents,
|
|
314
|
-
};
|
|
315
|
-
validateRunRecord(record);
|
|
316
|
-
const html = generateHtmlReport(record);
|
|
317
|
-
mkdirSync(outputDir, { recursive: true });
|
|
318
|
-
const htmlPath = path.join(outputDir, "notrace.html");
|
|
319
|
-
writePrivateFileAtomic(htmlPath, html);
|
|
320
|
-
writePrivateFileAtomic(recordPath, `${JSON.stringify(record, null, 2)}\n`);
|
|
321
|
-
const indexPath = path.join(notraceDir, "index.json");
|
|
322
|
-
const lockPath = `${indexPath}.lock`;
|
|
323
|
-
let lockAcquired = false;
|
|
324
|
-
for (let i = 0; i < 20; i++) {
|
|
325
|
-
try {
|
|
326
|
-
writeFileSync(lockPath, `${process.pid}`, { flag: "wx" });
|
|
327
|
-
lockAcquired = true;
|
|
328
|
-
break;
|
|
329
|
-
}
|
|
330
|
-
catch {
|
|
331
|
-
const t = Date.now();
|
|
332
|
-
while (Date.now() - t < 50) { } // busy wait 50ms
|
|
333
|
-
}
|
|
334
|
-
}
|
|
335
|
-
try {
|
|
336
|
-
const existing = readJsonFile(indexPath, { sessions: [] });
|
|
337
|
-
let sessions = Array.isArray(existing.sessions) ? existing.sessions.filter((s) => s.sessionId !== finalTraceId) : [];
|
|
338
|
-
if (!isGhostSession) {
|
|
339
|
-
sessions.push(createIndexEntry(record, htmlPath, recordPath));
|
|
340
|
-
}
|
|
341
|
-
writePrivateFileAtomic(indexPath, `${JSON.stringify({ sessions }, null, 2)}\n`);
|
|
342
|
-
writePrivateFileAtomic(path.join(notraceDir, "index.html"), generateDashboardHtml(sessions, {}));
|
|
343
|
-
}
|
|
344
|
-
finally {
|
|
345
|
-
if (lockAcquired && existsSync(lockPath)) {
|
|
346
|
-
try {
|
|
347
|
-
import("node:fs").then(fs => fs.rmSync ? fs.rmSync(lockPath) : fs.unlinkSync(lockPath));
|
|
348
|
-
}
|
|
349
|
-
catch { }
|
|
350
|
-
}
|
|
351
|
-
}
|
|
352
|
-
if (context) {
|
|
353
|
-
const displayPath = htmlPath.startsWith(os.homedir())
|
|
354
|
-
? `~${htmlPath.slice(os.homedir().length)}`
|
|
355
|
-
: htmlPath;
|
|
356
|
-
adapter.attach(context, {
|
|
357
|
-
html: displayPath,
|
|
358
|
-
record: recordPath
|
|
359
|
-
});
|
|
360
|
-
}
|
|
361
|
-
console.log(`\n\x1b[1m\x1b[38;5;208m[notrace] Session Retrospective: file://${htmlPath}\x1b[0m\n`);
|
|
384
|
+
notraceDir: process.env.NOTRACE_DIR || path.join(os.homedir(), ".notrace"),
|
|
385
|
+
adapter: getActiveAdapter(ctx.cwd),
|
|
386
|
+
});
|
|
362
387
|
});
|
|
363
388
|
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { handleSessionShutdown, type SessionShutdownDeps } from "../index.js";
|
|
5
|
+
import { cleanupTempNotraceDir, makeTempNotraceDir } from "./helpers.js";
|
|
6
|
+
|
|
7
|
+
const tempDirs: string[] = [];
|
|
8
|
+
|
|
9
|
+
function makeCtx(sessionId: string) {
|
|
10
|
+
return {
|
|
11
|
+
cwd: process.cwd(),
|
|
12
|
+
sessionManager: {
|
|
13
|
+
getSessionId: () => sessionId,
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function makeDeps(notraceDir: string, traceId: string, events: SessionShutdownDeps["events"], attach = vi.fn()): SessionShutdownDeps {
|
|
19
|
+
return {
|
|
20
|
+
events,
|
|
21
|
+
startTime: Date.now() - 1000,
|
|
22
|
+
traceId,
|
|
23
|
+
extensionTelemetry: new Map(),
|
|
24
|
+
captureMode: "full",
|
|
25
|
+
notraceDir,
|
|
26
|
+
adapter: {
|
|
27
|
+
name: "test",
|
|
28
|
+
detect: () => true,
|
|
29
|
+
getContext: () => ({ workflow: "test", taskId: "task-1", taskPath: null, taskDir: null }),
|
|
30
|
+
attach,
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function sessionDir(notraceDir: string, sessionId: string): string {
|
|
36
|
+
return path.join(notraceDir, "sessions", sessionId.replace(/[^a-z0-9]/gi, "-"));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function readJson(filePath: string) {
|
|
40
|
+
return JSON.parse(readFileSync(filePath, "utf-8"));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
afterEach(() => {
|
|
44
|
+
vi.restoreAllMocks();
|
|
45
|
+
while (tempDirs.length) cleanupTempNotraceDir(tempDirs.pop()!);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("handleSessionShutdown ghost sessions", () => {
|
|
49
|
+
it("skips session artifact writes, index entry creation, attach, and console log for ghost sessions", async () => {
|
|
50
|
+
const notraceDir = makeTempNotraceDir();
|
|
51
|
+
tempDirs.push(notraceDir);
|
|
52
|
+
const sessionId = "ghost-session";
|
|
53
|
+
const attach = vi.fn();
|
|
54
|
+
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
55
|
+
|
|
56
|
+
await handleSessionShutdown(
|
|
57
|
+
{ reason: "ghost" },
|
|
58
|
+
makeCtx(sessionId),
|
|
59
|
+
makeDeps(notraceDir, sessionId, [], attach),
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
const dir = sessionDir(notraceDir, sessionId);
|
|
63
|
+
expect(existsSync(path.join(dir, "notrace.html"))).toBe(false);
|
|
64
|
+
expect(existsSync(path.join(dir, "notrace.json"))).toBe(false);
|
|
65
|
+
|
|
66
|
+
const indexPath = path.join(notraceDir, "index.json");
|
|
67
|
+
if (existsSync(indexPath)) {
|
|
68
|
+
const index = readJson(indexPath);
|
|
69
|
+
expect(index.sessions.filter((session: { sessionId: string }) => session.sessionId === sessionId)).toHaveLength(0);
|
|
70
|
+
} else {
|
|
71
|
+
expect(existsSync(indexPath)).toBe(false);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
expect(attach).not.toHaveBeenCalled();
|
|
75
|
+
expect(log).not.toHaveBeenCalled();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("still writes artifacts and indexes non-ghost sessions", async () => {
|
|
79
|
+
const notraceDir = makeTempNotraceDir();
|
|
80
|
+
tempDirs.push(notraceDir);
|
|
81
|
+
const sessionId = "real-session";
|
|
82
|
+
const attach = vi.fn();
|
|
83
|
+
vi.spyOn(console, "log").mockImplementation(() => {});
|
|
84
|
+
|
|
85
|
+
await handleSessionShutdown(
|
|
86
|
+
{ reason: "normal" },
|
|
87
|
+
makeCtx(sessionId),
|
|
88
|
+
makeDeps(notraceDir, sessionId, [
|
|
89
|
+
{ type: "tool_start", toolName: "test-tool", args: {}, timestamp: Date.now() },
|
|
90
|
+
], attach),
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
const dir = sessionDir(notraceDir, sessionId);
|
|
94
|
+
const htmlPath = path.join(dir, "notrace.html");
|
|
95
|
+
const recordPath = path.join(dir, "notrace.json");
|
|
96
|
+
expect(existsSync(htmlPath)).toBe(true);
|
|
97
|
+
expect(existsSync(recordPath)).toBe(true);
|
|
98
|
+
|
|
99
|
+
const index = readJson(path.join(notraceDir, "index.json"));
|
|
100
|
+
expect(index.sessions.some((session: { sessionId: string }) => session.sessionId === sessionId)).toBe(true);
|
|
101
|
+
expect(attach).toHaveBeenCalledOnce();
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
2
|
+
import * as os from "node:os";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
|
|
5
|
+
export function makeTempNotraceDir(): string {
|
|
6
|
+
return mkdtempSync(path.join(os.tmpdir(), "notrace-test-"));
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function cleanupTempNotraceDir(dir: string): void {
|
|
10
|
+
rmSync(dir, { recursive: true, force: true });
|
|
11
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { execSync } from "node:child_process";
|
|
4
|
+
import { Worker, isMainThread, parentPort, workerData } from "node:worker_threads";
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { handleSessionShutdown, type SessionShutdownDeps } from "../index.js";
|
|
7
|
+
import { cleanupTempNotraceDir, makeTempNotraceDir } from "./helpers.js";
|
|
8
|
+
|
|
9
|
+
const tempDirs: string[] = [];
|
|
10
|
+
|
|
11
|
+
function makeDeps(notraceDir: string, traceId: string): SessionShutdownDeps {
|
|
12
|
+
return {
|
|
13
|
+
events: [{ type: "tool_start", toolName: "test-tool", args: {}, timestamp: Date.now() }],
|
|
14
|
+
startTime: Date.now() - 1000,
|
|
15
|
+
traceId,
|
|
16
|
+
extensionTelemetry: new Map(),
|
|
17
|
+
captureMode: "full",
|
|
18
|
+
notraceDir,
|
|
19
|
+
adapter: {
|
|
20
|
+
name: "test",
|
|
21
|
+
detect: () => true,
|
|
22
|
+
getContext: () => null,
|
|
23
|
+
attach: () => {},
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function makeCtx(sessionId: string) {
|
|
29
|
+
return {
|
|
30
|
+
cwd: process.cwd(),
|
|
31
|
+
sessionManager: {
|
|
32
|
+
getSessionId: () => sessionId,
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function readJson(filePath: string) {
|
|
38
|
+
return JSON.parse(readFileSync(filePath, "utf-8"));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function sessionDir(notraceDir: string, sessionId: string): string {
|
|
42
|
+
return path.join(notraceDir, "sessions", sessionId.replace(/[^a-z0-9]/gi, "-"));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function runWorker(sessionId: string, notraceDir: string): Promise<void> {
|
|
46
|
+
const distIndexPath = path.resolve(process.cwd(), "dist/notrace/index.js");
|
|
47
|
+
const workerScript = `
|
|
48
|
+
import { parentPort, workerData } from "node:worker_threads";
|
|
49
|
+
const { handleSessionShutdown } = await import(workerData.distIndexPath);
|
|
50
|
+
const deps = {
|
|
51
|
+
events: [{ type: "tool_start", toolName: "test-tool", args: {}, timestamp: Date.now() }],
|
|
52
|
+
startTime: Date.now() - 1000,
|
|
53
|
+
traceId: workerData.sessionId,
|
|
54
|
+
extensionTelemetry: new Map(),
|
|
55
|
+
captureMode: "full",
|
|
56
|
+
notraceDir: workerData.notraceDir,
|
|
57
|
+
adapter: { name: "test", detect: () => true, getContext: () => null, attach: () => {} },
|
|
58
|
+
};
|
|
59
|
+
const ctx = {
|
|
60
|
+
cwd: workerData.cwd,
|
|
61
|
+
sessionManager: { getSessionId: () => workerData.sessionId },
|
|
62
|
+
};
|
|
63
|
+
await handleSessionShutdown({ reason: "worker-test" }, ctx, deps);
|
|
64
|
+
parentPort.postMessage("done");
|
|
65
|
+
`;
|
|
66
|
+
|
|
67
|
+
await new Promise<void>((resolve, reject) => {
|
|
68
|
+
const worker = new Worker(workerScript, {
|
|
69
|
+
eval: true,
|
|
70
|
+
type: "module",
|
|
71
|
+
workerData: { sessionId, notraceDir, cwd: process.cwd(), distIndexPath },
|
|
72
|
+
});
|
|
73
|
+
worker.once("message", () => resolve());
|
|
74
|
+
worker.once("error", reject);
|
|
75
|
+
worker.once("exit", (code) => {
|
|
76
|
+
if (code !== 0) reject(new Error(`worker exited with code ${code}`));
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (!isMainThread) {
|
|
82
|
+
const { sessionId, notraceDir } = workerData as { sessionId: string; notraceDir: string };
|
|
83
|
+
await handleSessionShutdown({ reason: "worker-test" }, makeCtx(sessionId), makeDeps(notraceDir, sessionId));
|
|
84
|
+
parentPort?.postMessage("done");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (isMainThread) {
|
|
88
|
+
afterEach(() => {
|
|
89
|
+
vi.restoreAllMocks();
|
|
90
|
+
while (tempDirs.length) cleanupTempNotraceDir(tempDirs.pop()!);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe("handleSessionShutdown lock behavior", () => {
|
|
94
|
+
it("keeps both index entries from two concurrent shutdowns", async () => {
|
|
95
|
+
const notraceDir = makeTempNotraceDir();
|
|
96
|
+
tempDirs.push(notraceDir);
|
|
97
|
+
execSync("npm run build", { cwd: process.cwd(), stdio: "ignore" });
|
|
98
|
+
|
|
99
|
+
await Promise.all([
|
|
100
|
+
runWorker("session-a", notraceDir),
|
|
101
|
+
runWorker("session-b", notraceDir),
|
|
102
|
+
]);
|
|
103
|
+
|
|
104
|
+
const index = readJson(path.join(notraceDir, "index.json"));
|
|
105
|
+
const sessionIds = index.sessions.map((session: { sessionId: string }) => session.sessionId).sort();
|
|
106
|
+
|
|
107
|
+
expect(sessionIds).toEqual(["session-a", "session-b"]);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("warns and skips index update when lock acquisition fails", async () => {
|
|
111
|
+
const notraceDir = makeTempNotraceDir();
|
|
112
|
+
tempDirs.push(notraceDir);
|
|
113
|
+
|
|
114
|
+
const indexPath = path.join(notraceDir, "index.json");
|
|
115
|
+
const lockPath = `${indexPath}.lock`;
|
|
116
|
+
const seed = { sessions: [{ sessionId: "existing-session" }] };
|
|
117
|
+
writeFileSync(indexPath, `${JSON.stringify(seed, null, 2)}\n`);
|
|
118
|
+
writeFileSync(lockPath, "held");
|
|
119
|
+
|
|
120
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
121
|
+
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
122
|
+
|
|
123
|
+
await expect(handleSessionShutdown(
|
|
124
|
+
{ reason: "lock-held" },
|
|
125
|
+
makeCtx("skipped-session"),
|
|
126
|
+
makeDeps(notraceDir, "skipped-session"),
|
|
127
|
+
)).resolves.toBeUndefined();
|
|
128
|
+
|
|
129
|
+
expect(warn).toHaveBeenCalledWith(expect.stringContaining("Could not acquire index lock"));
|
|
130
|
+
expect(readJson(indexPath)).toEqual(seed);
|
|
131
|
+
expect(existsSync(path.join(sessionDir(notraceDir, "skipped-session"), "notrace.json"))).toBe(true);
|
|
132
|
+
expect(existsSync(path.join(sessionDir(notraceDir, "skipped-session"), "notrace.html"))).toBe(true);
|
|
133
|
+
expect(existsSync(lockPath)).toBe(true);
|
|
134
|
+
expect(log).toHaveBeenCalled();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("removes the lock file after a successful shutdown", async () => {
|
|
138
|
+
const notraceDir = makeTempNotraceDir();
|
|
139
|
+
tempDirs.push(notraceDir);
|
|
140
|
+
|
|
141
|
+
const lockPath = path.join(notraceDir, "index.json.lock");
|
|
142
|
+
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
143
|
+
|
|
144
|
+
await handleSessionShutdown(
|
|
145
|
+
{ reason: "success" },
|
|
146
|
+
makeCtx("lock-cleanup-session"),
|
|
147
|
+
makeDeps(notraceDir, "lock-cleanup-session"),
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
expect(existsSync(lockPath)).toBe(false);
|
|
151
|
+
expect(log).toHaveBeenCalled();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("writes a correct index entry for a normal shutdown", async () => {
|
|
155
|
+
const notraceDir = makeTempNotraceDir();
|
|
156
|
+
tempDirs.push(notraceDir);
|
|
157
|
+
const sessionId = "single-session";
|
|
158
|
+
|
|
159
|
+
mkdirSync(notraceDir, { recursive: true });
|
|
160
|
+
vi.spyOn(console, "log").mockImplementation(() => {});
|
|
161
|
+
|
|
162
|
+
await handleSessionShutdown(
|
|
163
|
+
{ reason: "normal" },
|
|
164
|
+
makeCtx(sessionId),
|
|
165
|
+
makeDeps(notraceDir, sessionId),
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
const index = readJson(path.join(notraceDir, "index.json"));
|
|
169
|
+
expect(index.sessions).toHaveLength(1);
|
|
170
|
+
expect(index.sessions[0].sessionId).toBe(sessionId);
|
|
171
|
+
expect(index.sessions[0].repositoryName).toBe(path.basename(process.cwd()));
|
|
172
|
+
expect(existsSync(index.sessions[0].artifacts.html)).toBe(true);
|
|
173
|
+
expect(existsSync(index.sessions[0].artifacts.record)).toBe(true);
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { normalizeUsage } from "../index.js";
|
|
3
|
+
|
|
4
|
+
describe("normalizeUsage", () => {
|
|
5
|
+
it("uses explicit totalTokens as-is", () => {
|
|
6
|
+
expect(normalizeUsage({
|
|
7
|
+
inputTokens: 10,
|
|
8
|
+
outputTokens: 20,
|
|
9
|
+
cacheReadTokens: 30,
|
|
10
|
+
cacheWriteTokens: 40,
|
|
11
|
+
totalTokens: 7,
|
|
12
|
+
})).toMatchObject({
|
|
13
|
+
inputTokens: 10,
|
|
14
|
+
outputTokens: 20,
|
|
15
|
+
cacheReadTokens: 30,
|
|
16
|
+
cacheWriteTokens: 40,
|
|
17
|
+
totalTokens: 7,
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("sums component fields when totalTokens is missing", () => {
|
|
22
|
+
expect(normalizeUsage({
|
|
23
|
+
inputTokens: 10,
|
|
24
|
+
outputTokens: 20,
|
|
25
|
+
cacheReadTokens: 3,
|
|
26
|
+
cacheWriteTokens: 4,
|
|
27
|
+
})).toMatchObject({
|
|
28
|
+
inputTokens: 10,
|
|
29
|
+
outputTokens: 20,
|
|
30
|
+
cacheReadTokens: 3,
|
|
31
|
+
cacheWriteTokens: 4,
|
|
32
|
+
totalTokens: 37,
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("normalizes empty or null usage to zero", () => {
|
|
37
|
+
expect(normalizeUsage({})).toMatchObject({
|
|
38
|
+
inputTokens: 0,
|
|
39
|
+
outputTokens: 0,
|
|
40
|
+
cacheReadTokens: 0,
|
|
41
|
+
cacheWriteTokens: 0,
|
|
42
|
+
totalTokens: 0,
|
|
43
|
+
totalCostUsd: 0,
|
|
44
|
+
});
|
|
45
|
+
expect(normalizeUsage(null)).toMatchObject({
|
|
46
|
+
inputTokens: 0,
|
|
47
|
+
outputTokens: 0,
|
|
48
|
+
cacheReadTokens: 0,
|
|
49
|
+
cacheWriteTokens: 0,
|
|
50
|
+
totalTokens: 0,
|
|
51
|
+
totalCostUsd: 0,
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("supports mixed field name variants when totalTokens is absent", () => {
|
|
56
|
+
expect(normalizeUsage({
|
|
57
|
+
input: 5,
|
|
58
|
+
outputTokens: 6,
|
|
59
|
+
cacheRead: 7,
|
|
60
|
+
cacheWriteTokens: 8,
|
|
61
|
+
})).toMatchObject({
|
|
62
|
+
inputTokens: 5,
|
|
63
|
+
outputTokens: 6,
|
|
64
|
+
cacheReadTokens: 7,
|
|
65
|
+
cacheWriteTokens: 8,
|
|
66
|
+
totalTokens: 26,
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("keeps totalCostUsd normalization unchanged", () => {
|
|
71
|
+
expect(normalizeUsage({
|
|
72
|
+
inputTokens: 1,
|
|
73
|
+
outputTokens: 2,
|
|
74
|
+
cost: { total: 0.123 },
|
|
75
|
+
})).toMatchObject({
|
|
76
|
+
totalTokens: 3,
|
|
77
|
+
totalCostUsd: 0.123,
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { readFileSync, writeFileSync, mkdirSync, existsSync, renameSync, chmodSync } from "node:fs";
|
|
2
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, renameSync, chmodSync, rmSync } from "node:fs";
|
|
3
3
|
import * as path from "node:path";
|
|
4
4
|
import * as os from "node:os";
|
|
5
5
|
import { execSync } from "node:child_process";
|
|
@@ -12,7 +12,7 @@ import type {
|
|
|
12
12
|
NotraceRunRecord,
|
|
13
13
|
WorkflowContext,
|
|
14
14
|
} from "./types.js";
|
|
15
|
-
import { getActiveAdapter } from "./adapters.js";
|
|
15
|
+
import { getActiveAdapter, type WorkflowAdapter } from "./adapters.js";
|
|
16
16
|
import { generateHtmlReport, generateDashboardHtml } from "./renderer.js";
|
|
17
17
|
|
|
18
18
|
const REDACTED = "[REDACTED by notrace]";
|
|
@@ -81,14 +81,18 @@ function asNumber(value: unknown): number {
|
|
|
81
81
|
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
-
function normalizeUsage(raw: unknown): Required<Pick<UsageLike, "inputTokens" | "outputTokens" | "cacheReadTokens" | "cacheWriteTokens" | "totalTokens">> & { totalCostUsd: number } {
|
|
84
|
+
export function normalizeUsage(raw: unknown): Required<Pick<UsageLike, "inputTokens" | "outputTokens" | "cacheReadTokens" | "cacheWriteTokens" | "totalTokens">> & { totalCostUsd: number } {
|
|
85
85
|
const usage = (raw && typeof raw === "object" ? raw : {}) as UsageLike;
|
|
86
|
+
const inputTokens = asNumber(usage.inputTokens ?? usage.input);
|
|
87
|
+
const outputTokens = asNumber(usage.outputTokens ?? usage.output);
|
|
88
|
+
const cacheReadTokens = asNumber(usage.cacheReadTokens ?? usage.cacheRead);
|
|
89
|
+
const cacheWriteTokens = asNumber(usage.cacheWriteTokens ?? usage.cacheWrite);
|
|
86
90
|
return {
|
|
87
|
-
inputTokens
|
|
88
|
-
outputTokens
|
|
89
|
-
cacheReadTokens
|
|
90
|
-
cacheWriteTokens
|
|
91
|
-
totalTokens: asNumber(usage.totalTokens),
|
|
91
|
+
inputTokens,
|
|
92
|
+
outputTokens,
|
|
93
|
+
cacheReadTokens,
|
|
94
|
+
cacheWriteTokens,
|
|
95
|
+
totalTokens: usage.totalTokens == null ? inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens : asNumber(usage.totalTokens),
|
|
92
96
|
totalCostUsd: asNumber(usage.cost?.total),
|
|
93
97
|
};
|
|
94
98
|
}
|
|
@@ -203,6 +207,144 @@ function createIndexEntry(record: NotraceRunRecord, htmlPath: string, recordPath
|
|
|
203
207
|
};
|
|
204
208
|
}
|
|
205
209
|
|
|
210
|
+
export type SessionShutdownDeps = {
|
|
211
|
+
events: NotraceEvent[];
|
|
212
|
+
startTime: number;
|
|
213
|
+
traceId: string;
|
|
214
|
+
extensionTelemetry: Map<string, NotraceExtensionTelemetry>;
|
|
215
|
+
captureMode: NotraceCaptureMode;
|
|
216
|
+
notraceDir: string;
|
|
217
|
+
adapter: WorkflowAdapter;
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
export async function handleSessionShutdown(e: any, ctx: any, deps: SessionShutdownDeps): Promise<void> {
|
|
221
|
+
const shutdownReason = typeof e?.reason === "string" ? e.reason : null;
|
|
222
|
+
const endedAt = Date.now();
|
|
223
|
+
const context = deps.adapter.getContext(ctx.cwd);
|
|
224
|
+
const finalTraceId = ctx.sessionManager?.getSessionId?.() || deps.traceId;
|
|
225
|
+
const outputDir = path.join(deps.notraceDir, "sessions", finalTraceId.replace(/[^a-z0-9]/gi, "-"));
|
|
226
|
+
const repositoryName = path.basename(ctx.cwd);
|
|
227
|
+
let branchName: string | null = null;
|
|
228
|
+
try {
|
|
229
|
+
branchName = execSync("git branch --show-current", { cwd: ctx.cwd, stdio: ["ignore", "pipe", "ignore"], encoding: "utf8", timeout: 1000 }).trim() || null;
|
|
230
|
+
} catch {
|
|
231
|
+
// not a git repo or no commits yet
|
|
232
|
+
}
|
|
233
|
+
const recordPath = path.join(outputDir, "notrace.json");
|
|
234
|
+
|
|
235
|
+
let mergedEvents = deps.events;
|
|
236
|
+
let originalStartedAt = deps.startTime;
|
|
237
|
+
let originalTask: any = null;
|
|
238
|
+
if (existsSync(recordPath)) {
|
|
239
|
+
try {
|
|
240
|
+
const oldRecord = readJsonFile<any>(recordPath, null);
|
|
241
|
+
if (Array.isArray(oldRecord.events)) {
|
|
242
|
+
mergedEvents = [...oldRecord.events, ...deps.events];
|
|
243
|
+
}
|
|
244
|
+
if (oldRecord.session?.startedAt) {
|
|
245
|
+
originalStartedAt = new Date(oldRecord.session.startedAt).getTime();
|
|
246
|
+
}
|
|
247
|
+
if (oldRecord.task) {
|
|
248
|
+
originalTask = oldRecord.task;
|
|
249
|
+
}
|
|
250
|
+
} catch (err) {
|
|
251
|
+
// ignore parse errors
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const activity = collectActivity(mergedEvents, originalStartedAt, endedAt);
|
|
256
|
+
|
|
257
|
+
// Do not index purely empty ghost sessions
|
|
258
|
+
const isGhostSession = activity.llmCallCount === 0 && activity.toolCallCount === 0 && activity.totals.totalTokens === 0;
|
|
259
|
+
|
|
260
|
+
const telemetry = Object.fromEntries([...deps.extensionTelemetry.entries()].sort(([a], [b]) => a.localeCompare(b)));
|
|
261
|
+
|
|
262
|
+
const record: NotraceRunRecord = {
|
|
263
|
+
kind: "notrace-run",
|
|
264
|
+
schemaVersion: SCHEMA_VERSION,
|
|
265
|
+
traceId: finalTraceId,
|
|
266
|
+
repository: {
|
|
267
|
+
name: repositoryName,
|
|
268
|
+
cwd: ctx.cwd,
|
|
269
|
+
branch: branchName,
|
|
270
|
+
},
|
|
271
|
+
session: {
|
|
272
|
+
id: finalTraceId,
|
|
273
|
+
startedAt: new Date(originalStartedAt).toISOString(),
|
|
274
|
+
endedAt: new Date(endedAt).toISOString(),
|
|
275
|
+
durationMs: activity.durationMs,
|
|
276
|
+
shutdownReason,
|
|
277
|
+
},
|
|
278
|
+
task: toTaskInfo(context) || originalTask,
|
|
279
|
+
captureMode: deps.captureMode,
|
|
280
|
+
conditions: buildConditions(mergedEvents, telemetry),
|
|
281
|
+
activity,
|
|
282
|
+
telemetry: { extensions: telemetry },
|
|
283
|
+
events: mergedEvents,
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
validateRunRecord(record);
|
|
287
|
+
const htmlPath = path.join(outputDir, "notrace.html");
|
|
288
|
+
|
|
289
|
+
if (!isGhostSession) {
|
|
290
|
+
const html = generateHtmlReport(record);
|
|
291
|
+
mkdirSync(outputDir, { recursive: true });
|
|
292
|
+
writePrivateFileAtomic(htmlPath, html);
|
|
293
|
+
writePrivateFileAtomic(recordPath, `${JSON.stringify(record, null, 2)}\n`);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const indexPath = path.join(deps.notraceDir, "index.json");
|
|
297
|
+
const lockPath = `${indexPath}.lock`;
|
|
298
|
+
let lockAcquired = false;
|
|
299
|
+
for (let i = 0; i < 20; i++) {
|
|
300
|
+
try {
|
|
301
|
+
writeFileSync(lockPath, `${process.pid}`, { flag: "wx" });
|
|
302
|
+
lockAcquired = true;
|
|
303
|
+
break;
|
|
304
|
+
} catch {
|
|
305
|
+
const t = Date.now(); while (Date.now() - t < 50) {} // busy wait 50ms
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (!lockAcquired) {
|
|
310
|
+
// Could not get exclusive access to the index after retrying. Skip the
|
|
311
|
+
// index/dashboard update rather than racing another process's
|
|
312
|
+
// read-modify-write on index.json. The per-session record and HTML
|
|
313
|
+
// report were already written above and are not affected.
|
|
314
|
+
console.warn(`[notrace] Could not acquire index lock, skipping index update for ${finalTraceId}`);
|
|
315
|
+
} else {
|
|
316
|
+
try {
|
|
317
|
+
const existing = readJsonFile<any>(indexPath, { sessions: [] });
|
|
318
|
+
let sessions = Array.isArray(existing.sessions) ? existing.sessions.filter((s: any) => s.sessionId !== finalTraceId) : [];
|
|
319
|
+
|
|
320
|
+
if (!isGhostSession) {
|
|
321
|
+
sessions.push(createIndexEntry(record, htmlPath, recordPath));
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
writePrivateFileAtomic(indexPath, `${JSON.stringify({ sessions }, null, 2)}\n`);
|
|
325
|
+
writePrivateFileAtomic(path.join(deps.notraceDir, "index.html"), generateDashboardHtml(sessions, {}));
|
|
326
|
+
} finally {
|
|
327
|
+
if (existsSync(lockPath)) {
|
|
328
|
+
try { rmSync(lockPath); } catch {}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (context && !isGhostSession) {
|
|
334
|
+
const displayPath = htmlPath.startsWith(os.homedir())
|
|
335
|
+
? `~${htmlPath.slice(os.homedir().length)}`
|
|
336
|
+
: htmlPath;
|
|
337
|
+
deps.adapter.attach(context, {
|
|
338
|
+
html: displayPath,
|
|
339
|
+
record: recordPath
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (!isGhostSession) {
|
|
344
|
+
console.log(`\n\x1b[1m\x1b[38;5;208m[notrace] Session Retrospective: file://${htmlPath}\x1b[0m\n`);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
206
348
|
function normalizeTelemetryPayload(raw: unknown): { extension: string; telemetry: NotraceExtensionTelemetry } | null {
|
|
207
349
|
if (!raw || typeof raw !== "object") return null;
|
|
208
350
|
const payload = raw as ExtensionTelemetryPayload;
|
|
@@ -235,7 +377,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
235
377
|
const startTime = Date.now();
|
|
236
378
|
let traceId = "";
|
|
237
379
|
let activeLlmPayload: unknown = null;
|
|
238
|
-
let shutdownReason: string | null = null;
|
|
239
380
|
const extensionTelemetry = new Map<string, NotraceExtensionTelemetry>();
|
|
240
381
|
currentMode = getInitialMode();
|
|
241
382
|
|
|
@@ -297,120 +438,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
297
438
|
});
|
|
298
439
|
|
|
299
440
|
pi.on("session_shutdown" as any, async (e: any, ctx: any) => {
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
const finalTraceId = ctx.sessionManager?.getSessionId?.() || traceId;
|
|
306
|
-
const outputDir = path.join(notraceDir, "sessions", finalTraceId.replace(/[^a-z0-9]/gi, "-"));
|
|
307
|
-
const repositoryName = path.basename(ctx.cwd);
|
|
308
|
-
let branchName: string | null = null;
|
|
309
|
-
try {
|
|
310
|
-
branchName = execSync("git branch --show-current", { cwd: ctx.cwd, stdio: ["ignore", "pipe", "ignore"], encoding: "utf8", timeout: 1000 }).trim() || null;
|
|
311
|
-
} catch {
|
|
312
|
-
// not a git repo or no commits yet
|
|
313
|
-
}
|
|
314
|
-
const recordPath = path.join(outputDir, "notrace.json");
|
|
315
|
-
|
|
316
|
-
let mergedEvents = events;
|
|
317
|
-
let originalStartedAt = startTime;
|
|
318
|
-
let originalTask: any = null;
|
|
319
|
-
if (existsSync(recordPath)) {
|
|
320
|
-
try {
|
|
321
|
-
const oldRecord = readJsonFile<any>(recordPath, null);
|
|
322
|
-
if (Array.isArray(oldRecord.events)) {
|
|
323
|
-
mergedEvents = [...oldRecord.events, ...events];
|
|
324
|
-
}
|
|
325
|
-
if (oldRecord.session?.startedAt) {
|
|
326
|
-
originalStartedAt = new Date(oldRecord.session.startedAt).getTime();
|
|
327
|
-
}
|
|
328
|
-
if (oldRecord.task) {
|
|
329
|
-
originalTask = oldRecord.task;
|
|
330
|
-
}
|
|
331
|
-
} catch (err) {
|
|
332
|
-
// ignore parse errors
|
|
333
|
-
}
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
const activity = collectActivity(mergedEvents, originalStartedAt, endedAt);
|
|
337
|
-
|
|
338
|
-
// Do not index purely empty ghost sessions
|
|
339
|
-
const isGhostSession = activity.llmCallCount === 0 && activity.toolCallCount === 0 && activity.totals.totalTokens === 0;
|
|
340
|
-
|
|
341
|
-
const telemetry = Object.fromEntries([...extensionTelemetry.entries()].sort(([a], [b]) => a.localeCompare(b)));
|
|
342
|
-
|
|
343
|
-
const record: NotraceRunRecord = {
|
|
344
|
-
kind: "notrace-run",
|
|
345
|
-
schemaVersion: SCHEMA_VERSION,
|
|
346
|
-
traceId: finalTraceId,
|
|
347
|
-
repository: {
|
|
348
|
-
name: repositoryName,
|
|
349
|
-
cwd: ctx.cwd,
|
|
350
|
-
branch: branchName,
|
|
351
|
-
},
|
|
352
|
-
session: {
|
|
353
|
-
id: finalTraceId,
|
|
354
|
-
startedAt: new Date(originalStartedAt).toISOString(),
|
|
355
|
-
endedAt: new Date(endedAt).toISOString(),
|
|
356
|
-
durationMs: activity.durationMs,
|
|
357
|
-
shutdownReason,
|
|
358
|
-
},
|
|
359
|
-
task: toTaskInfo(context) || originalTask,
|
|
441
|
+
await handleSessionShutdown(e, ctx, {
|
|
442
|
+
events,
|
|
443
|
+
startTime,
|
|
444
|
+
traceId,
|
|
445
|
+
extensionTelemetry,
|
|
360
446
|
captureMode: currentMode,
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
events: mergedEvents,
|
|
365
|
-
};
|
|
366
|
-
|
|
367
|
-
validateRunRecord(record);
|
|
368
|
-
const html = generateHtmlReport(record);
|
|
369
|
-
|
|
370
|
-
mkdirSync(outputDir, { recursive: true });
|
|
371
|
-
const htmlPath = path.join(outputDir, "notrace.html");
|
|
372
|
-
writePrivateFileAtomic(htmlPath, html);
|
|
373
|
-
writePrivateFileAtomic(recordPath, `${JSON.stringify(record, null, 2)}\n`);
|
|
374
|
-
|
|
375
|
-
const indexPath = path.join(notraceDir, "index.json");
|
|
376
|
-
const lockPath = `${indexPath}.lock`;
|
|
377
|
-
let lockAcquired = false;
|
|
378
|
-
for (let i = 0; i < 20; i++) {
|
|
379
|
-
try {
|
|
380
|
-
writeFileSync(lockPath, `${process.pid}`, { flag: "wx" });
|
|
381
|
-
lockAcquired = true;
|
|
382
|
-
break;
|
|
383
|
-
} catch {
|
|
384
|
-
const t = Date.now(); while (Date.now() - t < 50) {} // busy wait 50ms
|
|
385
|
-
}
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
try {
|
|
389
|
-
const existing = readJsonFile<any>(indexPath, { sessions: [] });
|
|
390
|
-
let sessions = Array.isArray(existing.sessions) ? existing.sessions.filter((s: any) => s.sessionId !== finalTraceId) : [];
|
|
391
|
-
|
|
392
|
-
if (!isGhostSession) {
|
|
393
|
-
sessions.push(createIndexEntry(record, htmlPath, recordPath));
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
writePrivateFileAtomic(indexPath, `${JSON.stringify({ sessions }, null, 2)}\n`);
|
|
397
|
-
writePrivateFileAtomic(path.join(notraceDir, "index.html"), generateDashboardHtml(sessions, {}));
|
|
398
|
-
} finally {
|
|
399
|
-
if (lockAcquired && existsSync(lockPath)) {
|
|
400
|
-
try { import("node:fs").then(fs => fs.rmSync ? fs.rmSync(lockPath) : fs.unlinkSync(lockPath)); } catch {}
|
|
401
|
-
}
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
if (context) {
|
|
405
|
-
const displayPath = htmlPath.startsWith(os.homedir())
|
|
406
|
-
? `~${htmlPath.slice(os.homedir().length)}`
|
|
407
|
-
: htmlPath;
|
|
408
|
-
adapter.attach(context, {
|
|
409
|
-
html: displayPath,
|
|
410
|
-
record: recordPath
|
|
411
|
-
});
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
console.log(`\n\x1b[1m\x1b[38;5;208m[notrace] Session Retrospective: file://${htmlPath}\x1b[0m\n`);
|
|
447
|
+
notraceDir: process.env.NOTRACE_DIR || path.join(os.homedir(), ".notrace"),
|
|
448
|
+
adapter: getActiveAdapter(ctx.cwd),
|
|
449
|
+
});
|
|
415
450
|
});
|
|
416
451
|
}
|
package/package.json
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@raquezha/notrace",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Zero-dependency, local-first interactive HTML Trace Viewer for the Pi Coding Agent",
|
|
5
5
|
"main": "dist/notrace/index.js",
|
|
6
6
|
"types": "dist/notrace/index.d.ts",
|
|
7
7
|
"type": "module",
|
|
8
8
|
"scripts": {
|
|
9
9
|
"build": "tsc",
|
|
10
|
+
"test": "vitest run --passWithNoTests",
|
|
10
11
|
"render:samples": "npm run build && node ./templates/render-samples.mjs",
|
|
11
12
|
"review": "node ./bin/notrace-review.mjs",
|
|
12
13
|
"compare": "node ./bin/notrace-compare.mjs",
|
|
@@ -16,7 +17,8 @@
|
|
|
16
17
|
"@earendil-works/pi-coding-agent": ">=0.74.0"
|
|
17
18
|
},
|
|
18
19
|
"devDependencies": {
|
|
19
|
-
"typescript": "^5.0.0"
|
|
20
|
+
"typescript": "^5.0.0",
|
|
21
|
+
"vitest": "^3.2.4"
|
|
20
22
|
},
|
|
21
23
|
"keywords": [
|
|
22
24
|
"pi-agent",
|