@raquezha/notrace 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +18 -0
- package/dist/notrace/index.d.ts +34 -0
- package/dist/notrace/index.js +144 -118
- package/dist/notrace/report-app/__tests__/analytics.test.d.ts +1 -0
- package/dist/notrace/report-app/__tests__/analytics.test.js +35 -0
- package/dist/notrace/report-app/__tests__/card.test.d.ts +1 -0
- package/dist/notrace/report-app/__tests__/card.test.js +26 -0
- package/dist/notrace/report-app/__tests__/event.test.d.ts +1 -0
- package/dist/notrace/report-app/__tests__/event.test.js +20 -0
- package/dist/notrace/report-app/__tests__/format.test.d.ts +1 -0
- package/dist/notrace/report-app/__tests__/format.test.js +41 -0
- package/dist/notrace/report-app/__tests__/report.test.d.ts +1 -0
- package/dist/notrace/report-app/__tests__/report.test.js +31 -0
- package/dist/notrace/report-app/analytics.d.ts +3 -0
- package/dist/notrace/report-app/analytics.js +78 -0
- package/dist/notrace/report-app/client.d.ts +2 -0
- package/dist/notrace/report-app/client.js +105 -0
- package/dist/notrace/report-app/components/card.d.ts +4 -0
- package/dist/notrace/report-app/components/card.js +36 -0
- package/dist/notrace/report-app/components/dashboard.d.ts +1 -0
- package/dist/notrace/report-app/components/dashboard.js +16 -0
- package/dist/notrace/report-app/components/event.d.ts +5 -0
- package/dist/notrace/report-app/components/event.js +42 -0
- package/dist/notrace/report-app/components/message.d.ts +2 -0
- package/dist/notrace/report-app/components/message.js +43 -0
- package/dist/notrace/report-app/dashboard-report.d.ts +1 -0
- package/dist/notrace/report-app/dashboard-report.js +6 -0
- package/dist/notrace/report-app/escape.d.ts +1 -0
- package/dist/notrace/report-app/escape.js +10 -0
- package/dist/notrace/report-app/format.d.ts +13 -0
- package/dist/notrace/report-app/format.js +102 -0
- package/dist/notrace/report-app/report.d.ts +1 -0
- package/dist/notrace/report-app/report.js +29 -0
- package/dist/notrace/report-app/shell.d.ts +5 -0
- package/dist/notrace/report-app/shell.js +19 -0
- package/dist/notrace/report-app/styles.d.ts +1 -0
- package/dist/notrace/report-app/styles.js +431 -0
- package/dist/notrace/report-app/types.d.ts +28 -0
- package/dist/notrace/report-app/types.js +1 -0
- 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 +160 -124
- package/extensions/notrace/report-app/__tests__/analytics.test.ts +41 -0
- package/extensions/notrace/report-app/__tests__/card.test.ts +29 -0
- package/extensions/notrace/report-app/__tests__/event.test.ts +23 -0
- package/extensions/notrace/report-app/__tests__/format.test.ts +46 -0
- package/extensions/notrace/report-app/__tests__/report.test.ts +33 -0
- package/extensions/notrace/report-app/analytics.ts +79 -0
- package/extensions/notrace/report-app/client.ts +106 -0
- package/extensions/notrace/report-app/components/card.ts +38 -0
- package/extensions/notrace/report-app/components/dashboard.ts +17 -0
- package/extensions/notrace/report-app/components/event.ts +39 -0
- package/extensions/notrace/report-app/components/message.ts +39 -0
- package/extensions/notrace/report-app/dashboard-report.ts +7 -0
- package/extensions/notrace/report-app/escape.ts +10 -0
- package/extensions/notrace/report-app/format.ts +107 -0
- package/extensions/notrace/report-app/report.ts +33 -0
- package/extensions/notrace/report-app/shell.ts +24 -0
- package/extensions/notrace/report-app/styles.ts +431 -0
- package/extensions/notrace/report-app/types.ts +35 -0
- package/package.json +4 -2
- package/templates/dashboard.sample.html +103 -63
- package/templates/dashboard.sample.json +73 -10
- package/templates/render-samples.mjs +119 -1
- package/templates/session.sample.html +125 -168
- package/templates/session.sample.json +66 -7
- package/templates/sessions/019ed2ee-1000-76ee-b353-000000000001/notrace.html +125 -163
- package/templates/sessions/019ed2ee-1000-76ee-b353-000000000001/notrace.json +50 -0
- package/templates/sessions/019ed2ee-1001-76ee-b353-000000000002/notrace.html +125 -162
- package/templates/sessions/019ed2ee-1001-76ee-b353-000000000002/notrace.json +50 -0
- package/templates/sessions/019ed2ee-1002-76ee-b353-000000000003/notrace.html +125 -163
- package/templates/sessions/019ed2ee-1002-76ee-b353-000000000003/notrace.json +50 -0
- package/templates/sessions/019ed2ee-massive/notrace.html +498 -0
- package/templates/sessions/019ed2ee-massive/notrace.json +14660 -0
- package/tsconfig.json +1 -1
- package/dist/notrace/renderer.d.ts +0 -4
- package/dist/notrace/renderer.js +0 -800
- package/extensions/notrace/renderer.ts +0 -810
|
@@ -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,8 +12,9 @@ import type {
|
|
|
12
12
|
NotraceRunRecord,
|
|
13
13
|
WorkflowContext,
|
|
14
14
|
} from "./types.js";
|
|
15
|
-
import { getActiveAdapter } from "./adapters.js";
|
|
16
|
-
import { generateHtmlReport
|
|
15
|
+
import { getActiveAdapter, type WorkflowAdapter } from "./adapters.js";
|
|
16
|
+
import { generateHtmlReport } from "./report-app/report.js";
|
|
17
|
+
import { generateDashboardHtml } from "./report-app/dashboard-report.js";
|
|
17
18
|
|
|
18
19
|
const REDACTED = "[REDACTED by notrace]";
|
|
19
20
|
const SENSITIVE_VALUE_RE = /(bearer\s+[a-z0-9._~+/=-]{12,}|sk-[a-z0-9_-]{16,}|gh[pousr]_[a-z0-9_]{16,}|AKIA[0-9A-Z]{16})/gi;
|
|
@@ -81,14 +82,18 @@ function asNumber(value: unknown): number {
|
|
|
81
82
|
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
82
83
|
}
|
|
83
84
|
|
|
84
|
-
function normalizeUsage(raw: unknown): Required<Pick<UsageLike, "inputTokens" | "outputTokens" | "cacheReadTokens" | "cacheWriteTokens" | "totalTokens">> & { totalCostUsd: number } {
|
|
85
|
+
export function normalizeUsage(raw: unknown): Required<Pick<UsageLike, "inputTokens" | "outputTokens" | "cacheReadTokens" | "cacheWriteTokens" | "totalTokens">> & { totalCostUsd: number } {
|
|
85
86
|
const usage = (raw && typeof raw === "object" ? raw : {}) as UsageLike;
|
|
87
|
+
const inputTokens = asNumber(usage.inputTokens ?? usage.input);
|
|
88
|
+
const outputTokens = asNumber(usage.outputTokens ?? usage.output);
|
|
89
|
+
const cacheReadTokens = asNumber(usage.cacheReadTokens ?? usage.cacheRead);
|
|
90
|
+
const cacheWriteTokens = asNumber(usage.cacheWriteTokens ?? usage.cacheWrite);
|
|
86
91
|
return {
|
|
87
|
-
inputTokens
|
|
88
|
-
outputTokens
|
|
89
|
-
cacheReadTokens
|
|
90
|
-
cacheWriteTokens
|
|
91
|
-
totalTokens: asNumber(usage.totalTokens),
|
|
92
|
+
inputTokens,
|
|
93
|
+
outputTokens,
|
|
94
|
+
cacheReadTokens,
|
|
95
|
+
cacheWriteTokens,
|
|
96
|
+
totalTokens: usage.totalTokens == null ? inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens : asNumber(usage.totalTokens),
|
|
92
97
|
totalCostUsd: asNumber(usage.cost?.total),
|
|
93
98
|
};
|
|
94
99
|
}
|
|
@@ -203,6 +208,144 @@ function createIndexEntry(record: NotraceRunRecord, htmlPath: string, recordPath
|
|
|
203
208
|
};
|
|
204
209
|
}
|
|
205
210
|
|
|
211
|
+
export type SessionShutdownDeps = {
|
|
212
|
+
events: NotraceEvent[];
|
|
213
|
+
startTime: number;
|
|
214
|
+
traceId: string;
|
|
215
|
+
extensionTelemetry: Map<string, NotraceExtensionTelemetry>;
|
|
216
|
+
captureMode: NotraceCaptureMode;
|
|
217
|
+
notraceDir: string;
|
|
218
|
+
adapter: WorkflowAdapter;
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
export async function handleSessionShutdown(e: any, ctx: any, deps: SessionShutdownDeps): Promise<void> {
|
|
222
|
+
const shutdownReason = typeof e?.reason === "string" ? e.reason : null;
|
|
223
|
+
const endedAt = Date.now();
|
|
224
|
+
const context = deps.adapter.getContext(ctx.cwd);
|
|
225
|
+
const finalTraceId = ctx.sessionManager?.getSessionId?.() || deps.traceId;
|
|
226
|
+
const outputDir = path.join(deps.notraceDir, "sessions", finalTraceId.replace(/[^a-z0-9]/gi, "-"));
|
|
227
|
+
const repositoryName = path.basename(ctx.cwd);
|
|
228
|
+
let branchName: string | null = null;
|
|
229
|
+
try {
|
|
230
|
+
branchName = execSync("git branch --show-current", { cwd: ctx.cwd, stdio: ["ignore", "pipe", "ignore"], encoding: "utf8", timeout: 1000 }).trim() || null;
|
|
231
|
+
} catch {
|
|
232
|
+
// not a git repo or no commits yet
|
|
233
|
+
}
|
|
234
|
+
const recordPath = path.join(outputDir, "notrace.json");
|
|
235
|
+
|
|
236
|
+
let mergedEvents = deps.events;
|
|
237
|
+
let originalStartedAt = deps.startTime;
|
|
238
|
+
let originalTask: any = null;
|
|
239
|
+
if (existsSync(recordPath)) {
|
|
240
|
+
try {
|
|
241
|
+
const oldRecord = readJsonFile<any>(recordPath, null);
|
|
242
|
+
if (Array.isArray(oldRecord.events)) {
|
|
243
|
+
mergedEvents = [...oldRecord.events, ...deps.events];
|
|
244
|
+
}
|
|
245
|
+
if (oldRecord.session?.startedAt) {
|
|
246
|
+
originalStartedAt = new Date(oldRecord.session.startedAt).getTime();
|
|
247
|
+
}
|
|
248
|
+
if (oldRecord.task) {
|
|
249
|
+
originalTask = oldRecord.task;
|
|
250
|
+
}
|
|
251
|
+
} catch (err) {
|
|
252
|
+
// ignore parse errors
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const activity = collectActivity(mergedEvents, originalStartedAt, endedAt);
|
|
257
|
+
|
|
258
|
+
// Do not index purely empty ghost sessions
|
|
259
|
+
const isGhostSession = activity.llmCallCount === 0 && activity.toolCallCount === 0 && activity.totals.totalTokens === 0;
|
|
260
|
+
|
|
261
|
+
const telemetry = Object.fromEntries([...deps.extensionTelemetry.entries()].sort(([a], [b]) => a.localeCompare(b)));
|
|
262
|
+
|
|
263
|
+
const record: NotraceRunRecord = {
|
|
264
|
+
kind: "notrace-run",
|
|
265
|
+
schemaVersion: SCHEMA_VERSION,
|
|
266
|
+
traceId: finalTraceId,
|
|
267
|
+
repository: {
|
|
268
|
+
name: repositoryName,
|
|
269
|
+
cwd: ctx.cwd,
|
|
270
|
+
branch: branchName,
|
|
271
|
+
},
|
|
272
|
+
session: {
|
|
273
|
+
id: finalTraceId,
|
|
274
|
+
startedAt: new Date(originalStartedAt).toISOString(),
|
|
275
|
+
endedAt: new Date(endedAt).toISOString(),
|
|
276
|
+
durationMs: activity.durationMs,
|
|
277
|
+
shutdownReason,
|
|
278
|
+
},
|
|
279
|
+
task: toTaskInfo(context) || originalTask,
|
|
280
|
+
captureMode: deps.captureMode,
|
|
281
|
+
conditions: buildConditions(mergedEvents, telemetry),
|
|
282
|
+
activity,
|
|
283
|
+
telemetry: { extensions: telemetry },
|
|
284
|
+
events: mergedEvents,
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
validateRunRecord(record);
|
|
288
|
+
const htmlPath = path.join(outputDir, "notrace.html");
|
|
289
|
+
|
|
290
|
+
if (!isGhostSession) {
|
|
291
|
+
const html = generateHtmlReport(record);
|
|
292
|
+
mkdirSync(outputDir, { recursive: true });
|
|
293
|
+
writePrivateFileAtomic(htmlPath, html);
|
|
294
|
+
writePrivateFileAtomic(recordPath, `${JSON.stringify(record, null, 2)}\n`);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const indexPath = path.join(deps.notraceDir, "index.json");
|
|
298
|
+
const lockPath = `${indexPath}.lock`;
|
|
299
|
+
let lockAcquired = false;
|
|
300
|
+
for (let i = 0; i < 20; i++) {
|
|
301
|
+
try {
|
|
302
|
+
writeFileSync(lockPath, `${process.pid}`, { flag: "wx" });
|
|
303
|
+
lockAcquired = true;
|
|
304
|
+
break;
|
|
305
|
+
} catch {
|
|
306
|
+
const t = Date.now(); while (Date.now() - t < 50) {} // busy wait 50ms
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (!lockAcquired) {
|
|
311
|
+
// Could not get exclusive access to the index after retrying. Skip the
|
|
312
|
+
// index/dashboard update rather than racing another process's
|
|
313
|
+
// read-modify-write on index.json. The per-session record and HTML
|
|
314
|
+
// report were already written above and are not affected.
|
|
315
|
+
console.warn(`[notrace] Could not acquire index lock, skipping index update for ${finalTraceId}`);
|
|
316
|
+
} else {
|
|
317
|
+
try {
|
|
318
|
+
const existing = readJsonFile<any>(indexPath, { sessions: [] });
|
|
319
|
+
let sessions = Array.isArray(existing.sessions) ? existing.sessions.filter((s: any) => s.sessionId !== finalTraceId) : [];
|
|
320
|
+
|
|
321
|
+
if (!isGhostSession) {
|
|
322
|
+
sessions.push(createIndexEntry(record, htmlPath, recordPath));
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
writePrivateFileAtomic(indexPath, `${JSON.stringify({ sessions }, null, 2)}\n`);
|
|
326
|
+
writePrivateFileAtomic(path.join(deps.notraceDir, "index.html"), generateDashboardHtml(sessions, {}));
|
|
327
|
+
} finally {
|
|
328
|
+
if (existsSync(lockPath)) {
|
|
329
|
+
try { rmSync(lockPath); } catch {}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (context && !isGhostSession) {
|
|
335
|
+
const displayPath = htmlPath.startsWith(os.homedir())
|
|
336
|
+
? `~${htmlPath.slice(os.homedir().length)}`
|
|
337
|
+
: htmlPath;
|
|
338
|
+
deps.adapter.attach(context, {
|
|
339
|
+
html: displayPath,
|
|
340
|
+
record: recordPath
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (!isGhostSession) {
|
|
345
|
+
console.log(`\n\x1b[1m\x1b[38;5;208m[notrace] Session Retrospective: file://${htmlPath}\x1b[0m\n`);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
206
349
|
function normalizeTelemetryPayload(raw: unknown): { extension: string; telemetry: NotraceExtensionTelemetry } | null {
|
|
207
350
|
if (!raw || typeof raw !== "object") return null;
|
|
208
351
|
const payload = raw as ExtensionTelemetryPayload;
|
|
@@ -235,7 +378,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
235
378
|
const startTime = Date.now();
|
|
236
379
|
let traceId = "";
|
|
237
380
|
let activeLlmPayload: unknown = null;
|
|
238
|
-
let shutdownReason: string | null = null;
|
|
239
381
|
const extensionTelemetry = new Map<string, NotraceExtensionTelemetry>();
|
|
240
382
|
currentMode = getInitialMode();
|
|
241
383
|
|
|
@@ -297,120 +439,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
297
439
|
});
|
|
298
440
|
|
|
299
441
|
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,
|
|
442
|
+
await handleSessionShutdown(e, ctx, {
|
|
443
|
+
events,
|
|
444
|
+
startTime,
|
|
445
|
+
traceId,
|
|
446
|
+
extensionTelemetry,
|
|
360
447
|
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`);
|
|
448
|
+
notraceDir: process.env.NOTRACE_DIR || path.join(os.homedir(), ".notrace"),
|
|
449
|
+
adapter: getActiveAdapter(ctx.cwd),
|
|
450
|
+
});
|
|
415
451
|
});
|
|
416
452
|
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { buildModelSummary, buildModelSwitches, groupByModel } from "../analytics.js";
|
|
3
|
+
|
|
4
|
+
const events = [
|
|
5
|
+
{ type: "llm_completion", model: "a", provider: "x", timestamp: 1000, usage: { input: 10, output: 5, totalTokens: 15, cost: { total: 1 } } },
|
|
6
|
+
{ type: "llm_completion", model: "a", provider: "x", timestamp: "2026-06-17T17:00:02.000Z", usage: { input: 20, output: 10, totalTokens: 30, cost: { total: 2 } }, errorMessage: "oops" },
|
|
7
|
+
{ type: "llm_completion", model: "b", provider: "y", timestamp: "2026-06-17T17:00:03.000Z", usage: { input: 40, output: 20, cost: { total: 3 } } }, // missing totalTokens
|
|
8
|
+
];
|
|
9
|
+
|
|
10
|
+
describe("report-app analytics", () => {
|
|
11
|
+
it("groups model usage and falls back when totalTokens is missing", () => {
|
|
12
|
+
const grouped = groupByModel(events as any);
|
|
13
|
+
expect(grouped.a.count).toBe(2);
|
|
14
|
+
expect(grouped.a.inputTokens).toBe(30);
|
|
15
|
+
expect(grouped.a.errors).toBe(1);
|
|
16
|
+
|
|
17
|
+
// b is missing totalTokens, should fallback to input + output (40 + 20)
|
|
18
|
+
expect(grouped.b.totalTokens).toBe(60);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("tracks model switches and normalizes string timestamps", () => {
|
|
22
|
+
const switches = buildModelSwitches(events as any);
|
|
23
|
+
expect(switches).toHaveLength(1);
|
|
24
|
+
expect(switches[0].from).toBe("a");
|
|
25
|
+
expect(switches[0].to).toBe("b");
|
|
26
|
+
expect(switches[0].providerChanged).toBe(true);
|
|
27
|
+
|
|
28
|
+
// timestamp Delta between "2026-06-17T17:00:02.000Z" and "2026-06-17T17:00:03.000Z" is 1000ms
|
|
29
|
+
expect(switches[0].timeDelta).toBe(1000);
|
|
30
|
+
// falls back to input + output when totalTokens is missing
|
|
31
|
+
expect(switches[0].tokens).toBe(60);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("builds summary", () => {
|
|
35
|
+
const summary = buildModelSummary(events as any);
|
|
36
|
+
expect(summary.firstModel).toBe("a");
|
|
37
|
+
expect(summary.finalModel).toBe("b");
|
|
38
|
+
expect(summary.switchCount).toBe(1);
|
|
39
|
+
expect(summary.uniqueModels).toBe(2);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { renderCollapsibleSection, renderKeyValueList, renderToolResultHtml, renderToolUseHtml } from "../components/card.js";
|
|
3
|
+
|
|
4
|
+
describe("report-app card", () => {
|
|
5
|
+
it("renders tool use and result cards", () => {
|
|
6
|
+
const useHtml = renderToolUseHtml("bash", { command: "echo hi" });
|
|
7
|
+
const resultHtml = renderToolResultHtml("tool-1", { stdout: "ok" }, true);
|
|
8
|
+
expect(useHtml).toContain("chat-tool-use");
|
|
9
|
+
expect(useHtml).toContain("bash");
|
|
10
|
+
expect(resultHtml).toContain("Tool Result: tool-1");
|
|
11
|
+
expect(resultHtml).toContain("color: var(--err);");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("escapes injected content", () => {
|
|
15
|
+
const html = renderToolUseHtml("<script>alert(1)</script>", "<script>alert(1)</script>");
|
|
16
|
+
expect(html).toContain("<script>alert(1)</script>");
|
|
17
|
+
expect(html).not.toContain("<script>alert(1)</script>");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("renders collapsible sections and key-value lists", () => {
|
|
21
|
+
const section = renderCollapsibleSection("Models", "<div>body</div>", true);
|
|
22
|
+
const kv = renderKeyValueList([["A", "B"], ["Empty", ""]]);
|
|
23
|
+
expect(section).toContain("panel collapsible");
|
|
24
|
+
expect(section).toContain(" open");
|
|
25
|
+
expect(kv).toContain("kv-list");
|
|
26
|
+
expect(kv).toContain("Empty");
|
|
27
|
+
expect(kv).toContain(">-<");
|
|
28
|
+
});
|
|
29
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { renderEventCard } from "../components/event.js";
|
|
3
|
+
|
|
4
|
+
describe("report-app event", () => {
|
|
5
|
+
it("escapes hostile content in lazy event body attributes", () => {
|
|
6
|
+
const hostileEvent = {
|
|
7
|
+
type: "llm_completion",
|
|
8
|
+
outputContent: '<img src=x onerror=alert(1)><script>alert(1)</script>" autofocus onfocus=alert(1)',
|
|
9
|
+
timestamp: 1000
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const html = renderEventCard(hostileEvent);
|
|
13
|
+
|
|
14
|
+
// The data-lazy-event-body attribute should contain the URI-encoded then HTML-escaped string.
|
|
15
|
+
// It must NOT contain the raw dangerous strings.
|
|
16
|
+
expect(html).not.toContain("<script>alert(1)</script>");
|
|
17
|
+
expect(html).not.toContain('onerror=alert(1)');
|
|
18
|
+
expect(html).toContain("data-lazy-event-body=");
|
|
19
|
+
// Double check that the encoded payload still holds the data safely.
|
|
20
|
+
expect(html).toContain("alert(1)"); // uri encoded form might just be alert(1) but the < > and " will be %3C %3E %22
|
|
21
|
+
expect(html).toContain("%26lt%3Bscript%26gt%3Balert(1)%26lt%3B%2Fscript%26gt%3B");
|
|
22
|
+
});
|
|
23
|
+
});
|