@raquezha/notrace 0.0.6 → 0.1.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 +22 -0
- package/README.md +24 -1
- package/bin/notrace-compare.mjs +153 -0
- package/bin/notrace-review.mjs +123 -0
- package/dist/notrace/adapters.js +4 -1
- package/dist/notrace/index.js +84 -16
- package/dist/notrace/renderer.js +219 -24
- package/dist/notrace/types.d.ts +1 -0
- package/extensions/notrace/adapters.ts +4 -1
- package/extensions/notrace/index.ts +75 -18
- package/extensions/notrace/renderer.ts +218 -24
- package/extensions/notrace/types.ts +1 -0
- package/package.json +7 -1
- package/templates/dashboard.sample.html +96 -14
- package/templates/session.sample.html +122 -12
- package/templates/sessions/019ed2ee-1000-76ee-b353-000000000001/notrace.html +122 -12
- package/templates/sessions/019ed2ee-1001-76ee-b353-000000000002/notrace.html +122 -12
- package/templates/sessions/019ed2ee-1002-76ee-b353-000000000003/notrace.html +122 -12
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# @raquezha/notrace
|
|
2
2
|
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- a2fc3cb: Implement machine-global observability dashboard and Mistral-style timeline parser.
|
|
8
|
+
- Storage migrated from `.notrace/` in the local working directory to a machine-wide `~/.notrace/` directory to prevent repository pollution and enable global insights.
|
|
9
|
+
- Dashboard updated with a new `Project` column for multi-repo tracking.
|
|
10
|
+
- Timeline parser overhauled to render LLM arrays, tool execution cards, and code blocks beautifully instead of raw JSON dumps.
|
|
11
|
+
|
|
12
|
+
### Patch Changes
|
|
13
|
+
|
|
14
|
+
- 8f31379: fix(noheadroom): match lowercase footer casing
|
|
15
|
+
feat(notrace): add session export to HTML retrospective
|
|
16
|
+
|
|
17
|
+
## 0.0.7
|
|
18
|
+
|
|
19
|
+
### Patch Changes
|
|
20
|
+
|
|
21
|
+
- 5a3e563: Improve session reports by rendering the session ID as a copyable chip under the notrace logo.
|
|
22
|
+
- 5a3e563: Enhance the trace header to include the active git branch alongside the repository name, and clarify the capture setting label.
|
|
23
|
+
- 7664e50: Polish notrace reliability and installed-package ergonomics: add review/compare package CLIs, validate run records before writing, atomically write private artifacts, recover from corrupt index JSON, and verify capture modes.
|
|
24
|
+
|
|
3
25
|
## 0.0.6
|
|
4
26
|
|
|
5
27
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -103,10 +103,12 @@ Mode meanings:
|
|
|
103
103
|
- `redacted`: captured payloads with common secret-like values redacted
|
|
104
104
|
- `metadata`: minimal capture, no prompt/tool bodies
|
|
105
105
|
|
|
106
|
-
**Security warning:**
|
|
106
|
+
**Security warning:** `full` reports can contain prompts, tool arguments, tool outputs, local paths, model payloads, and secrets returned by tools. `redacted` mode removes common secret-shaped values and sensitive keys, but redaction is best-effort and can miss project-specific secrets. `metadata` mode is safest for sharing because prompt/tool bodies are omitted, but reports can still reveal repository names, paths, timing, models, providers, and workflow metadata. Do not publish generated reports without review.
|
|
107
107
|
|
|
108
108
|
## Review
|
|
109
109
|
|
|
110
|
+
From this monorepo:
|
|
111
|
+
|
|
110
112
|
```bash
|
|
111
113
|
npm run review:notrace -- \
|
|
112
114
|
.notrace/sessions/<id>/notrace.json \
|
|
@@ -116,6 +118,17 @@ npm run review:notrace -- \
|
|
|
116
118
|
--next-change "Try same task with RepoScry enabled."
|
|
117
119
|
```
|
|
118
120
|
|
|
121
|
+
From an installed package:
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
npx -p @raquezha/notrace notrace-review \
|
|
125
|
+
.notrace/sessions/<id>/notrace.json \
|
|
126
|
+
--outcome partial \
|
|
127
|
+
--friction high \
|
|
128
|
+
--lesson "Headroom reduced tokens but needed manual steering." \
|
|
129
|
+
--next-change "Try same task with RepoScry enabled."
|
|
130
|
+
```
|
|
131
|
+
|
|
119
132
|
Review fields:
|
|
120
133
|
- `outcome`: `success`, `partial`, `failed`, `abandoned`, `inconclusive`
|
|
121
134
|
- `friction`: `low`, `medium`, `high`
|
|
@@ -124,12 +137,22 @@ Review fields:
|
|
|
124
137
|
|
|
125
138
|
## Compare
|
|
126
139
|
|
|
140
|
+
From this monorepo:
|
|
141
|
+
|
|
127
142
|
```bash
|
|
128
143
|
npm run compare:notrace -- \
|
|
129
144
|
.notrace/sessions/<baseline-id>/notrace.json \
|
|
130
145
|
.notrace/sessions/<candidate-id>/notrace.json
|
|
131
146
|
```
|
|
132
147
|
|
|
148
|
+
From an installed package:
|
|
149
|
+
|
|
150
|
+
```bash
|
|
151
|
+
npx -p @raquezha/notrace notrace-compare \
|
|
152
|
+
.notrace/sessions/<baseline-id>/notrace.json \
|
|
153
|
+
.notrace/sessions/<candidate-id>/notrace.json
|
|
154
|
+
```
|
|
155
|
+
|
|
133
156
|
## Templates
|
|
134
157
|
|
|
135
158
|
HTML source-of-truth lives in `templates/`:
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { resolve, relative, dirname, basename, extname, join } from "node:path";
|
|
4
|
+
|
|
5
|
+
function usage() {
|
|
6
|
+
console.error("Usage: notrace-compare <baseline-notrace.json> <candidate-notrace.json>");
|
|
7
|
+
process.exit(1);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const [, , baselineArg, candidateArg] = process.argv;
|
|
11
|
+
if (!baselineArg || !candidateArg) usage();
|
|
12
|
+
|
|
13
|
+
function loadReview(runPath) {
|
|
14
|
+
const reviewPath = join(dirname(runPath), `${basename(runPath, extname(runPath))}.review.json`);
|
|
15
|
+
try {
|
|
16
|
+
const data = JSON.parse(readFileSync(reviewPath, "utf8"));
|
|
17
|
+
if (data?.kind !== "notrace-review") return null;
|
|
18
|
+
return data;
|
|
19
|
+
} catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function loadRun(filePath) {
|
|
25
|
+
const absolutePath = resolve(filePath);
|
|
26
|
+
const data = JSON.parse(readFileSync(absolutePath, "utf8"));
|
|
27
|
+
if (data?.kind !== "notrace-run") {
|
|
28
|
+
throw new Error(`${filePath} is not a notrace run record`);
|
|
29
|
+
}
|
|
30
|
+
return { path: absolutePath, data, review: loadReview(absolutePath) };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function fmtNumber(value) {
|
|
34
|
+
return Number(value || 0).toLocaleString();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function fmtUsd(value) {
|
|
38
|
+
return `$${Number(value || 0).toFixed(5)}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function fmtMs(value) {
|
|
42
|
+
const ms = Number(value || 0);
|
|
43
|
+
if (ms < 1000) return `${ms}ms`;
|
|
44
|
+
return `${(ms / 1000).toFixed(2)}s`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function fmtDelta(delta, formatter = fmtNumber, invertGood = false) {
|
|
48
|
+
const good = invertGood ? delta > 0 : delta < 0;
|
|
49
|
+
const bad = invertGood ? delta < 0 : delta > 0;
|
|
50
|
+
const sign = delta > 0 ? "+" : "";
|
|
51
|
+
const text = `${sign}${formatter(delta)}`;
|
|
52
|
+
if (good) return `${text} better`;
|
|
53
|
+
if (bad) return `${text} worse`;
|
|
54
|
+
return `${text} same`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function list(value) {
|
|
58
|
+
return Array.isArray(value) && value.length ? value.join(", ") : "-";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const baseline = loadRun(baselineArg);
|
|
62
|
+
const candidate = loadRun(candidateArg);
|
|
63
|
+
|
|
64
|
+
const a = baseline.data;
|
|
65
|
+
const b = candidate.data;
|
|
66
|
+
const aReview = baseline.review;
|
|
67
|
+
const bReview = candidate.review;
|
|
68
|
+
const aActivity = a.activity || {};
|
|
69
|
+
const bActivity = b.activity || {};
|
|
70
|
+
const aTotals = aActivity.totals || {};
|
|
71
|
+
const bTotals = bActivity.totals || {};
|
|
72
|
+
|
|
73
|
+
const rows = [
|
|
74
|
+
{
|
|
75
|
+
label: "Total tokens",
|
|
76
|
+
baseline: fmtNumber(aTotals.totalTokens),
|
|
77
|
+
candidate: fmtNumber(bTotals.totalTokens),
|
|
78
|
+
delta: fmtDelta((bTotals.totalTokens || 0) - (aTotals.totalTokens || 0))
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
label: "Input tokens",
|
|
82
|
+
baseline: fmtNumber(aTotals.inputTokens),
|
|
83
|
+
candidate: fmtNumber(bTotals.inputTokens),
|
|
84
|
+
delta: fmtDelta((bTotals.inputTokens || 0) - (aTotals.inputTokens || 0))
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
label: "Output tokens",
|
|
88
|
+
baseline: fmtNumber(aTotals.outputTokens),
|
|
89
|
+
candidate: fmtNumber(bTotals.outputTokens),
|
|
90
|
+
delta: fmtDelta((bTotals.outputTokens || 0) - (aTotals.outputTokens || 0))
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
label: "Duration",
|
|
94
|
+
baseline: fmtMs(aActivity.durationMs),
|
|
95
|
+
candidate: fmtMs(bActivity.durationMs),
|
|
96
|
+
delta: fmtDelta((bActivity.durationMs || 0) - (aActivity.durationMs || 0), fmtMs)
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
label: "LLM calls",
|
|
100
|
+
baseline: fmtNumber(aActivity.llmCallCount),
|
|
101
|
+
candidate: fmtNumber(bActivity.llmCallCount),
|
|
102
|
+
delta: fmtDelta((bActivity.llmCallCount || 0) - (aActivity.llmCallCount || 0))
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
label: "Tool calls",
|
|
106
|
+
baseline: fmtNumber(aActivity.toolCallCount),
|
|
107
|
+
candidate: fmtNumber(bActivity.toolCallCount),
|
|
108
|
+
delta: fmtDelta((bActivity.toolCallCount || 0) - (aActivity.toolCallCount || 0))
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
label: "Tool errors",
|
|
112
|
+
baseline: fmtNumber(aActivity.toolErrorCount),
|
|
113
|
+
candidate: fmtNumber(bActivity.toolErrorCount),
|
|
114
|
+
delta: fmtDelta((bActivity.toolErrorCount || 0) - (aActivity.toolErrorCount || 0))
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
label: "Cost (USD)",
|
|
118
|
+
baseline: fmtUsd(aTotals.totalCostUsd),
|
|
119
|
+
candidate: fmtUsd(bTotals.totalCostUsd),
|
|
120
|
+
delta: fmtDelta((bTotals.totalCostUsd || 0) - (aTotals.totalCostUsd || 0), fmtUsd)
|
|
121
|
+
}
|
|
122
|
+
];
|
|
123
|
+
|
|
124
|
+
const labelWidth = Math.max(...rows.map((row) => row.label.length));
|
|
125
|
+
const baselineWidth = Math.max("Baseline".length, ...rows.map((row) => row.baseline.length));
|
|
126
|
+
const candidateWidth = Math.max("Candidate".length, ...rows.map((row) => row.candidate.length));
|
|
127
|
+
|
|
128
|
+
function pad(value, width) {
|
|
129
|
+
return String(value).padEnd(width);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
console.log("notrace compare\n");
|
|
133
|
+
console.log(`Baseline : ${relative(process.cwd(), baseline.path) || baseline.path}`);
|
|
134
|
+
console.log(`Candidate: ${relative(process.cwd(), candidate.path) || candidate.path}`);
|
|
135
|
+
console.log("");
|
|
136
|
+
console.log(`Task : ${b.task?.id || a.task?.id || "(none)"}`);
|
|
137
|
+
console.log(`Capture : ${a.captureMode} -> ${b.captureMode}`);
|
|
138
|
+
console.log(`Models : ${list(a.conditions?.models)} -> ${list(b.conditions?.models)}`);
|
|
139
|
+
console.log(`Providers : ${list(a.conditions?.providers)} -> ${list(b.conditions?.providers)}`);
|
|
140
|
+
console.log("");
|
|
141
|
+
console.log(`Review : ${(aReview?.outcome || "-")}/${(aReview?.friction || "-")} -> ${(bReview?.outcome || "-")}/${(bReview?.friction || "-")}`);
|
|
142
|
+
if (aReview?.lesson || bReview?.lesson) {
|
|
143
|
+
console.log(`Lessons : ${(aReview?.lesson || "-")} -> ${(bReview?.lesson || "-")}`);
|
|
144
|
+
}
|
|
145
|
+
if (aReview?.nextChange || bReview?.nextChange) {
|
|
146
|
+
console.log(`Next : ${(aReview?.nextChange || "-")} -> ${(bReview?.nextChange || "-")}`);
|
|
147
|
+
}
|
|
148
|
+
console.log("");
|
|
149
|
+
console.log(`${pad("Metric", labelWidth)} | ${pad("Baseline", baselineWidth)} | ${pad("Candidate", candidateWidth)} | Delta`);
|
|
150
|
+
console.log(`${"-".repeat(labelWidth)}-+-${"-".repeat(baselineWidth)}-+-${"-".repeat(candidateWidth)}-+-${"-".repeat(24)}`);
|
|
151
|
+
for (const row of rows) {
|
|
152
|
+
console.log(`${pad(row.label, labelWidth)} | ${pad(row.baseline, baselineWidth)} | ${pad(row.candidate, candidateWidth)} | ${row.delta}`);
|
|
153
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { resolve, relative, dirname, basename, extname, join } from "node:path";
|
|
4
|
+
|
|
5
|
+
const VALID_OUTCOMES = new Set(["success", "partial", "failed", "abandoned", "inconclusive"]);
|
|
6
|
+
const VALID_FRICTION = new Set(["low", "medium", "high"]);
|
|
7
|
+
|
|
8
|
+
function appendWorkLogEntry(taskDir, message) {
|
|
9
|
+
const workMd = join(taskDir, "WORK.md");
|
|
10
|
+
if (!existsSync(workMd)) return;
|
|
11
|
+
|
|
12
|
+
const text = readFileSync(workMd, "utf8");
|
|
13
|
+
const entry = `- ${new Date().toISOString()}: ${message}`;
|
|
14
|
+
|
|
15
|
+
if (!/^(## )?\[LOG\]\s*$/m.test(text)) {
|
|
16
|
+
writeFileSync(workMd, `${text.trimEnd()}\n\n## [LOG]\n${entry}\n`, { encoding: "utf8" });
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const lines = text.split("\n");
|
|
21
|
+
const logIndex = lines.findIndex((line) => /^(## )?\[LOG\]\s*$/.test(line));
|
|
22
|
+
if (logIndex === -1) return;
|
|
23
|
+
|
|
24
|
+
let nextSectionIndex = lines.length;
|
|
25
|
+
for (let i = logIndex + 1; i < lines.length; i++) {
|
|
26
|
+
if (/^(## )?\[[A-Z0-9_-]+\]\s*$/.test(lines[i])) {
|
|
27
|
+
nextSectionIndex = i;
|
|
28
|
+
break;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const before = lines.slice(0, nextSectionIndex);
|
|
33
|
+
const after = lines.slice(nextSectionIndex);
|
|
34
|
+
while (before.length > logIndex + 1 && before[before.length - 1]?.trim() === "") {
|
|
35
|
+
before.pop();
|
|
36
|
+
}
|
|
37
|
+
before.push(entry);
|
|
38
|
+
|
|
39
|
+
writeFileSync(workMd, `${[...before, ...after].join("\n").replace(/\n*$/, "\n")}`, { encoding: "utf8" });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function usage() {
|
|
43
|
+
console.error("Usage: notrace-review <notrace.json> [--outcome value] [--friction value] [--lesson text] [--next-change text]");
|
|
44
|
+
process.exit(1);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const args = process.argv.slice(2);
|
|
48
|
+
if (!args.length) usage();
|
|
49
|
+
|
|
50
|
+
const runPath = resolve(args[0]);
|
|
51
|
+
const flags = args.slice(1);
|
|
52
|
+
|
|
53
|
+
function takeFlag(name) {
|
|
54
|
+
const index = flags.indexOf(name);
|
|
55
|
+
if (index === -1) return undefined;
|
|
56
|
+
const value = flags[index + 1];
|
|
57
|
+
if (!value || value.startsWith("--")) {
|
|
58
|
+
throw new Error(`Missing value for ${name}`);
|
|
59
|
+
}
|
|
60
|
+
return value;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const outcome = takeFlag("--outcome");
|
|
64
|
+
const friction = takeFlag("--friction");
|
|
65
|
+
const lesson = takeFlag("--lesson");
|
|
66
|
+
const nextChange = takeFlag("--next-change");
|
|
67
|
+
|
|
68
|
+
if (!existsSync(runPath)) {
|
|
69
|
+
throw new Error(`Run record not found: ${runPath}`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const run = JSON.parse(readFileSync(runPath, "utf8"));
|
|
73
|
+
if (run?.kind !== "notrace-run") {
|
|
74
|
+
throw new Error(`Not a notrace run record: ${runPath}`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (outcome && !VALID_OUTCOMES.has(outcome)) {
|
|
78
|
+
throw new Error(`Invalid outcome: ${outcome}`);
|
|
79
|
+
}
|
|
80
|
+
if (friction && !VALID_FRICTION.has(friction)) {
|
|
81
|
+
throw new Error(`Invalid friction: ${friction}`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const reviewPath = join(dirname(runPath), `${basename(runPath, extname(runPath))}.review.json`);
|
|
85
|
+
const existing = existsSync(reviewPath)
|
|
86
|
+
? JSON.parse(readFileSync(reviewPath, "utf8"))
|
|
87
|
+
: {
|
|
88
|
+
schemaVersion: 1,
|
|
89
|
+
kind: "notrace-review",
|
|
90
|
+
traceId: run.traceId,
|
|
91
|
+
runRecord: basename(runPath),
|
|
92
|
+
outcome: null,
|
|
93
|
+
friction: null,
|
|
94
|
+
lesson: "",
|
|
95
|
+
nextChange: ""
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const review = {
|
|
99
|
+
...existing,
|
|
100
|
+
traceId: run.traceId,
|
|
101
|
+
runRecord: basename(runPath),
|
|
102
|
+
outcome: outcome ?? existing.outcome ?? null,
|
|
103
|
+
friction: friction ?? existing.friction ?? null,
|
|
104
|
+
lesson: lesson ?? existing.lesson ?? "",
|
|
105
|
+
nextChange: nextChange ?? existing.nextChange ?? ""
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
writeFileSync(reviewPath, `${JSON.stringify(review, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
109
|
+
|
|
110
|
+
if (run.repository?.cwd && (run.task?.dir || run.task?.path)) {
|
|
111
|
+
const taskDir = run.task?.dir
|
|
112
|
+
? resolve(run.task.dir)
|
|
113
|
+
: resolve(run.repository.cwd, run.task.path);
|
|
114
|
+
appendWorkLogEntry(taskDir, `notrace review recorded: outcome=${review.outcome ?? "-"}, friction=${review.friction ?? "-"}, review=${relative(taskDir, reviewPath)}`);
|
|
115
|
+
} else {
|
|
116
|
+
appendWorkLogEntry(dirname(runPath), `notrace review recorded: outcome=${review.outcome ?? "-"}, friction=${review.friction ?? "-"}, review=${basename(reviewPath)}`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
console.log(`notrace review ✓ ${reviewPath}`);
|
|
120
|
+
console.log(` outcome : ${review.outcome ?? "-"}`);
|
|
121
|
+
console.log(` friction : ${review.friction ?? "-"}`);
|
|
122
|
+
console.log(` lesson : ${review.lesson || "-"}`);
|
|
123
|
+
console.log(` nextChange: ${review.nextChange || "-"}`);
|
package/dist/notrace/adapters.js
CHANGED
|
@@ -22,8 +22,11 @@ function appendWorkLogEntry(taskDir, message) {
|
|
|
22
22
|
}
|
|
23
23
|
const before = lines.slice(0, nextSection);
|
|
24
24
|
const after = lines.slice(nextSection);
|
|
25
|
+
while (before.length > logIndex + 1 && before[before.length - 1]?.trim() === "") {
|
|
26
|
+
before.pop();
|
|
27
|
+
}
|
|
25
28
|
before.push(entry);
|
|
26
|
-
writeFileSync(workMd, `${before.join("\n")
|
|
29
|
+
writeFileSync(workMd, `${[...before, ...after].join("\n").replace(/\n*$/, "\n")}`);
|
|
27
30
|
}
|
|
28
31
|
catch { }
|
|
29
32
|
}
|
package/dist/notrace/index.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, renameSync, chmodSync } from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
import { execSync } from "node:child_process";
|
|
3
5
|
import { getActiveAdapter } from "./adapters.js";
|
|
4
6
|
import { generateHtmlReport, generateDashboardHtml } from "./renderer.js";
|
|
5
7
|
const REDACTED = "[REDACTED by notrace]";
|
|
@@ -21,6 +23,8 @@ function isSensitiveKey(key) {
|
|
|
21
23
|
return SENSITIVE_KEY_RE.test(normalized);
|
|
22
24
|
}
|
|
23
25
|
function sanitizeTraceValue(value) {
|
|
26
|
+
if (currentMode === "metadata")
|
|
27
|
+
return { omitted: true, reason: "metadata-capture" };
|
|
24
28
|
if (currentMode === "full")
|
|
25
29
|
return value;
|
|
26
30
|
if (value == null || typeof value !== "object") {
|
|
@@ -47,6 +51,34 @@ function normalizeUsage(raw) {
|
|
|
47
51
|
totalCostUsd: asNumber(usage.cost?.total),
|
|
48
52
|
};
|
|
49
53
|
}
|
|
54
|
+
function readJsonFile(filePath, fallback) {
|
|
55
|
+
try {
|
|
56
|
+
return JSON.parse(readFileSync(filePath, "utf-8"));
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return fallback;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
function writePrivateFileAtomic(filePath, content) {
|
|
63
|
+
const tmpPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
64
|
+
writeFileSync(tmpPath, content, { encoding: "utf-8", mode: 0o600 });
|
|
65
|
+
chmodSync(tmpPath, 0o600);
|
|
66
|
+
renameSync(tmpPath, filePath);
|
|
67
|
+
}
|
|
68
|
+
function validateRunRecord(record) {
|
|
69
|
+
if (record.kind !== "notrace-run")
|
|
70
|
+
throw new Error("notrace record validation failed: invalid kind");
|
|
71
|
+
if (record.schemaVersion !== SCHEMA_VERSION)
|
|
72
|
+
throw new Error("notrace record validation failed: invalid schemaVersion");
|
|
73
|
+
if (!record.traceId || !record.session?.id)
|
|
74
|
+
throw new Error("notrace record validation failed: missing session id");
|
|
75
|
+
if (!record.repository?.cwd)
|
|
76
|
+
throw new Error("notrace record validation failed: missing repository cwd");
|
|
77
|
+
if (!record.activity?.totals)
|
|
78
|
+
throw new Error("notrace record validation failed: missing activity totals");
|
|
79
|
+
if (!Array.isArray(record.events))
|
|
80
|
+
throw new Error("notrace record validation failed: events must be an array");
|
|
81
|
+
}
|
|
50
82
|
function collectActivity(events, startedAt, endedAt) {
|
|
51
83
|
const activity = {
|
|
52
84
|
turnCount: 0,
|
|
@@ -116,9 +148,10 @@ function toTaskInfo(context) {
|
|
|
116
148
|
dir: context.taskDir,
|
|
117
149
|
};
|
|
118
150
|
}
|
|
119
|
-
function createIndexEntry(record,
|
|
151
|
+
function createIndexEntry(record, htmlPath, recordPath) {
|
|
120
152
|
return {
|
|
121
153
|
sessionId: record.traceId,
|
|
154
|
+
repositoryName: record.repository.name,
|
|
122
155
|
startedAt: record.session.startedAt,
|
|
123
156
|
endedAt: record.session.endedAt,
|
|
124
157
|
captureMode: record.captureMode,
|
|
@@ -126,8 +159,8 @@ function createIndexEntry(record, cwd, htmlPath, recordPath) {
|
|
|
126
159
|
conditions: record.conditions,
|
|
127
160
|
activity: record.activity,
|
|
128
161
|
artifacts: {
|
|
129
|
-
html:
|
|
130
|
-
record:
|
|
162
|
+
html: htmlPath,
|
|
163
|
+
record: recordPath,
|
|
131
164
|
},
|
|
132
165
|
};
|
|
133
166
|
}
|
|
@@ -220,17 +253,24 @@ export default function (pi) {
|
|
|
220
253
|
const endedAt = Date.now();
|
|
221
254
|
const adapter = getActiveAdapter(ctx.cwd);
|
|
222
255
|
const context = adapter.getContext(ctx.cwd);
|
|
223
|
-
const notraceDir = path.
|
|
256
|
+
const notraceDir = process.env.NOTRACE_DIR || path.join(os.homedir(), ".notrace");
|
|
224
257
|
const finalTraceId = ctx.sessionManager?.getSessionId?.() || traceId;
|
|
225
258
|
const outputDir = path.join(notraceDir, "sessions", finalTraceId.replace(/[^a-z0-9]/gi, "-"));
|
|
226
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
|
+
}
|
|
227
267
|
const recordPath = path.join(outputDir, "notrace.json");
|
|
228
268
|
let mergedEvents = events;
|
|
229
269
|
let originalStartedAt = startTime;
|
|
230
270
|
let originalTask = null;
|
|
231
271
|
if (existsSync(recordPath)) {
|
|
232
272
|
try {
|
|
233
|
-
const oldRecord =
|
|
273
|
+
const oldRecord = readJsonFile(recordPath, null);
|
|
234
274
|
if (Array.isArray(oldRecord.events)) {
|
|
235
275
|
mergedEvents = [...oldRecord.events, ...events];
|
|
236
276
|
}
|
|
@@ -256,6 +296,7 @@ export default function (pi) {
|
|
|
256
296
|
repository: {
|
|
257
297
|
name: repositoryName,
|
|
258
298
|
cwd: ctx.cwd,
|
|
299
|
+
branch: branchName,
|
|
259
300
|
},
|
|
260
301
|
session: {
|
|
261
302
|
id: finalTraceId,
|
|
@@ -271,23 +312,50 @@ export default function (pi) {
|
|
|
271
312
|
telemetry: { extensions: telemetry },
|
|
272
313
|
events: mergedEvents,
|
|
273
314
|
};
|
|
315
|
+
validateRunRecord(record);
|
|
274
316
|
const html = generateHtmlReport(record);
|
|
275
317
|
mkdirSync(outputDir, { recursive: true });
|
|
276
318
|
const htmlPath = path.join(outputDir, "notrace.html");
|
|
277
|
-
|
|
278
|
-
|
|
319
|
+
writePrivateFileAtomic(htmlPath, html);
|
|
320
|
+
writePrivateFileAtomic(recordPath, `${JSON.stringify(record, null, 2)}\n`);
|
|
279
321
|
const indexPath = path.join(notraceDir, "index.json");
|
|
280
|
-
const
|
|
281
|
-
let
|
|
282
|
-
|
|
283
|
-
|
|
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
|
+
}
|
|
284
351
|
}
|
|
285
|
-
writeFileSync(indexPath, `${JSON.stringify({ repositoryName, sessions }, null, 2)}\n`);
|
|
286
|
-
writeFileSync(path.join(notraceDir, "index.html"), generateDashboardHtml(sessions, { repositoryName }));
|
|
287
352
|
if (context) {
|
|
353
|
+
const displayPath = htmlPath.startsWith(os.homedir())
|
|
354
|
+
? `~${htmlPath.slice(os.homedir().length)}`
|
|
355
|
+
: htmlPath;
|
|
288
356
|
adapter.attach(context, {
|
|
289
|
-
html:
|
|
290
|
-
record:
|
|
357
|
+
html: displayPath,
|
|
358
|
+
record: recordPath
|
|
291
359
|
});
|
|
292
360
|
}
|
|
293
361
|
console.log(`\n\x1b[1m\x1b[38;5;208m[notrace] Session Retrospective: file://${htmlPath}\x1b[0m\n`);
|