@raquezha/notrace 0.1.1 → 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 CHANGED
@@ -1,5 +1,13 @@
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
+
3
11
  ## 0.1.1
4
12
 
5
13
  ### Patch Changes
@@ -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 {};
@@ -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: asNumber(usage.inputTokens ?? usage.input),
47
- outputTokens: asNumber(usage.outputTokens ?? usage.output),
48
- cacheReadTokens: asNumber(usage.cacheReadTokens ?? usage.cacheRead),
49
- cacheWriteTokens: asNumber(usage.cacheWriteTokens ?? usage.cacheWrite),
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
- shutdownReason = typeof e?.reason === "string" ? e.reason : null;
253
- const endedAt = Date.now();
254
- const adapter = getActiveAdapter(ctx.cwd);
255
- const context = adapter.getContext(ctx.cwd);
256
- const notraceDir = process.env.NOTRACE_DIR || path.join(os.homedir(), ".notrace");
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
- conditions: buildConditions(mergedEvents, telemetry),
311
- activity,
312
- telemetry: { extensions: telemetry },
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: asNumber(usage.inputTokens ?? usage.input),
88
- outputTokens: asNumber(usage.outputTokens ?? usage.output),
89
- cacheReadTokens: asNumber(usage.cacheReadTokens ?? usage.cacheRead),
90
- cacheWriteTokens: asNumber(usage.cacheWriteTokens ?? usage.cacheWrite),
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
- shutdownReason = typeof e?.reason === "string" ? e.reason : null;
301
- const endedAt = Date.now();
302
- const adapter = getActiveAdapter(ctx.cwd);
303
- const context = adapter.getContext(ctx.cwd);
304
- const notraceDir = process.env.NOTRACE_DIR || path.join(os.homedir(), ".notrace");
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
- conditions: buildConditions(mergedEvents, telemetry),
362
- activity,
363
- telemetry: { extensions: telemetry },
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.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",