session-steward 0.3.0 → 0.5.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 +31 -0
- package/README.md +27 -14
- package/bin/session-steward-cli.mjs +14 -0
- package/dist/assets/index-B6C5qvGV.js +9 -0
- package/dist/assets/index-B8U6eLCA.css +2 -0
- package/dist/index.html +2 -2
- package/lib/cli.mjs +139 -2
- package/lib/providers/claude-code/events.mjs +498 -0
- package/lib/providers/claude-code/index.mjs +2 -0
- package/lib/providers/claude-code/store.mjs +60 -15
- package/lib/providers/codex/database-families.mjs +177 -0
- package/lib/providers/codex/events.mjs +731 -0
- package/lib/providers/codex/index.mjs +2 -0
- package/lib/providers/codex/store.mjs +466 -329
- package/lib/server.mjs +70 -4
- package/lib/session-event-reader.mjs +126 -0
- package/lib/session-events.mjs +307 -0
- package/lib/storage/jsonl.mjs +148 -0
- package/package.json +7 -2
- package/dist/assets/index-CN9iax_v.css +0 -2
- package/dist/assets/index-fUX3qen0.js +0 -9
package/lib/server.mjs
CHANGED
|
@@ -6,6 +6,10 @@ import path from "node:path";
|
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
7
|
|
|
8
8
|
import { getProvider, listProviders } from "./providers/index.mjs";
|
|
9
|
+
import {
|
|
10
|
+
DEFAULT_SESSION_EVENT_LIMIT,
|
|
11
|
+
MAX_SESSION_EVENT_LIMIT,
|
|
12
|
+
} from "./session-event-reader.mjs";
|
|
9
13
|
import { createProviderSettings } from "./settings.mjs";
|
|
10
14
|
import { classifyInstalledVersion } from "./version-support.mjs";
|
|
11
15
|
|
|
@@ -174,6 +178,35 @@ function getPositiveInteger(value, fallback, maximum = Number.MAX_SAFE_INTEGER)
|
|
|
174
178
|
return Math.min(parsed, maximum);
|
|
175
179
|
}
|
|
176
180
|
|
|
181
|
+
function getSessionEventLimit(value) {
|
|
182
|
+
if (value === null) return DEFAULT_SESSION_EVENT_LIMIT;
|
|
183
|
+
if (!/^\d+$/u.test(value)) {
|
|
184
|
+
throw new Error(`limit must be between 1 and ${MAX_SESSION_EVENT_LIMIT}.`);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const limit = Number(value);
|
|
188
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_SESSION_EVENT_LIMIT) {
|
|
189
|
+
throw new Error(`limit must be between 1 and ${MAX_SESSION_EVENT_LIMIT}.`);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return limit;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function getSessionId(value) {
|
|
196
|
+
if (
|
|
197
|
+
typeof value !== "string"
|
|
198
|
+
|| value.length === 0
|
|
199
|
+
|| value.length > 512
|
|
200
|
+
|| value.trim() !== value
|
|
201
|
+
|| value.includes("\0")
|
|
202
|
+
|| /[\\/]/u.test(value)
|
|
203
|
+
) {
|
|
204
|
+
throw new Error("Enter a valid session ID.");
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return value;
|
|
208
|
+
}
|
|
209
|
+
|
|
177
210
|
function getInactiveBeforeMs(value) {
|
|
178
211
|
if (value === null || value === "") {
|
|
179
212
|
return null;
|
|
@@ -294,6 +327,7 @@ function summarizeDeletionResult(result) {
|
|
|
294
327
|
deletedTranscriptCount: result.deletedTranscriptPaths.length,
|
|
295
328
|
recoveryBackupDeleted: false,
|
|
296
329
|
skippedTranscriptCount: result.skippedTranscriptPaths.length,
|
|
330
|
+
unrecognizedLocationCount: result.unrecognizedLocationCount ?? 0,
|
|
297
331
|
};
|
|
298
332
|
}
|
|
299
333
|
|
|
@@ -810,10 +844,38 @@ export async function startLocalServer({ claudeHome, codexHome, configDirectory,
|
|
|
810
844
|
return;
|
|
811
845
|
}
|
|
812
846
|
|
|
847
|
+
if (request.method === "GET" && requestUrl.pathname === "/api/session-events") {
|
|
848
|
+
const controller = new AbortController();
|
|
849
|
+
const abortRead = () => controller.abort();
|
|
850
|
+
request.once("aborted", abortRead);
|
|
851
|
+
response.once("close", () => {
|
|
852
|
+
if (!response.writableEnded) abortRead();
|
|
853
|
+
});
|
|
854
|
+
const providerId = resolveProviderId(requestUrl.searchParams.get("provider"));
|
|
855
|
+
const provider = getProvider(providerId);
|
|
856
|
+
const id = getSessionId(requestUrl.searchParams.get("id"));
|
|
857
|
+
const result = await provider.readSessionEvents({
|
|
858
|
+
...providerOptions(providerId, settings.getHome(providerId)),
|
|
859
|
+
id,
|
|
860
|
+
limit: getSessionEventLimit(requestUrl.searchParams.get("limit")),
|
|
861
|
+
signal: controller.signal,
|
|
862
|
+
});
|
|
863
|
+
|
|
864
|
+
if (controller.signal.aborted) return;
|
|
865
|
+
|
|
866
|
+
if (!result) {
|
|
867
|
+
sendJson(response, 404, { error: "Session not found." });
|
|
868
|
+
return;
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
sendJson(response, 200, result);
|
|
872
|
+
return;
|
|
873
|
+
}
|
|
874
|
+
|
|
813
875
|
if (request.method === "GET" && requestUrl.pathname.startsWith("/api/sessions/")) {
|
|
814
876
|
const providerId = resolveProviderId(requestUrl.searchParams.get("provider"));
|
|
815
877
|
const provider = getProvider(providerId);
|
|
816
|
-
const id = decodeURIComponent(requestUrl.pathname.slice("/api/sessions/".length));
|
|
878
|
+
const id = getSessionId(decodeURIComponent(requestUrl.pathname.slice("/api/sessions/".length)));
|
|
817
879
|
const record = await provider.getSessionRecord({
|
|
818
880
|
...providerOptions(providerId, settings.getHome(providerId)),
|
|
819
881
|
id,
|
|
@@ -864,9 +926,13 @@ export async function startLocalServer({ claudeHome, codexHome, configDirectory,
|
|
|
864
926
|
deletionPlans.delete(deletionPlans.keys().next().value);
|
|
865
927
|
}
|
|
866
928
|
deletionPlans.set(id, savedPlan);
|
|
867
|
-
const warnings =
|
|
868
|
-
|
|
869
|
-
|
|
929
|
+
const warnings = [];
|
|
930
|
+
if (preflight.activeThreadDetection === "unavailable") {
|
|
931
|
+
warnings.push(`The current ${provider.displayName} runtime cannot identify an active session. Confirm it is safe to delete the selected sessions.`);
|
|
932
|
+
}
|
|
933
|
+
if (plan.unrecognizedLocationCount > 0) {
|
|
934
|
+
warnings.push(`${plan.unrecognizedLocationCount} ${plan.unrecognizedLocationCount === 1 ? "location" : "locations"} in your Claude folder ${plan.unrecognizedLocationCount === 1 ? "was" : "were"} not recognized and will not be examined.`);
|
|
935
|
+
}
|
|
870
936
|
sendJson(response, 200, {
|
|
871
937
|
plan: {
|
|
872
938
|
...summarizePlan(plan, preflight, scope),
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import {
|
|
2
|
+
SESSION_EVENT_READ_MODE,
|
|
3
|
+
SESSION_EVENT_WINDOW_END,
|
|
4
|
+
} from "./session-events.mjs";
|
|
5
|
+
|
|
6
|
+
export const DEFAULT_SESSION_EVENT_LIMIT = 100;
|
|
7
|
+
export const MAX_SESSION_EVENT_LIMIT = 1_000;
|
|
8
|
+
|
|
9
|
+
const MAX_PENDING_SESSION_EVENTS = 2_048;
|
|
10
|
+
const MAX_UNMAPPED_SESSION_EVENT_TYPES = 128;
|
|
11
|
+
const SESSION_EVENT_READ_MODES = new Set(Object.values(SESSION_EVENT_READ_MODE));
|
|
12
|
+
const WRAPPED_CONTEXT_PATTERN = /^<([A-Za-z][\w:.-]*[_-][\w:.-]*)(?:\s[^>]*)?>[\s\S]*?<\/\1>\s*/u;
|
|
13
|
+
|
|
14
|
+
export function isInjectedSessionAsk(value) {
|
|
15
|
+
if (typeof value !== "string") return false;
|
|
16
|
+
let remaining = value.trim();
|
|
17
|
+
if (!remaining) return false;
|
|
18
|
+
if (/^#{1,6}\s+[^\n]*\binstructions?\b/iu.test(remaining)) return true;
|
|
19
|
+
let matched = false;
|
|
20
|
+
|
|
21
|
+
while (remaining) {
|
|
22
|
+
const match = WRAPPED_CONTEXT_PATTERN.exec(remaining);
|
|
23
|
+
if (!match) return false;
|
|
24
|
+
matched = true;
|
|
25
|
+
remaining = remaining.slice(match[0].length).trimStart();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return matched;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function createSessionEventReadState({
|
|
32
|
+
limit = DEFAULT_SESSION_EVENT_LIMIT,
|
|
33
|
+
mode = SESSION_EVENT_READ_MODE.RECENT,
|
|
34
|
+
} = {}) {
|
|
35
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_SESSION_EVENT_LIMIT) {
|
|
36
|
+
throw new TypeError(`limit must be between 1 and ${MAX_SESSION_EVENT_LIMIT}.`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (!SESSION_EVENT_READ_MODES.has(mode)) {
|
|
40
|
+
throw new TypeError(`Unsupported session event read mode: ${mode}`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const events = new Array(limit);
|
|
44
|
+
const pending = new Map();
|
|
45
|
+
const pendingIds = new WeakMap();
|
|
46
|
+
let length = 0;
|
|
47
|
+
let start = 0;
|
|
48
|
+
|
|
49
|
+
function untrack(event) {
|
|
50
|
+
const pendingId = pendingIds.get(event);
|
|
51
|
+
if (pendingId && pending.get(pendingId) === event) pending.delete(pendingId);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
add(event, { pendingId = null } = {}) {
|
|
56
|
+
if (pendingId) {
|
|
57
|
+
pending.delete(pendingId);
|
|
58
|
+
pending.set(pendingId, event);
|
|
59
|
+
pendingIds.set(event, pendingId);
|
|
60
|
+
|
|
61
|
+
while (pending.size > MAX_PENDING_SESSION_EVENTS) {
|
|
62
|
+
pending.delete(pending.keys().next().value);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (length < limit) {
|
|
67
|
+
events[(start + length) % limit] = event;
|
|
68
|
+
length += 1;
|
|
69
|
+
} else if (mode === SESSION_EVENT_READ_MODE.RECENT) {
|
|
70
|
+
untrack(events[start]);
|
|
71
|
+
events[start] = event;
|
|
72
|
+
start = (start + 1) % limit;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return mode === SESSION_EVENT_READ_MODE.PREVIEW && length === limit;
|
|
76
|
+
},
|
|
77
|
+
resolve(pendingId, update, { consume = true } = {}) {
|
|
78
|
+
const event = pending.get(pendingId);
|
|
79
|
+
if (!event) return false;
|
|
80
|
+
if (consume) pending.delete(pendingId);
|
|
81
|
+
update(event);
|
|
82
|
+
return true;
|
|
83
|
+
},
|
|
84
|
+
values() {
|
|
85
|
+
return Array.from({ length }, (_, index) => events[(start + index) % limit]);
|
|
86
|
+
},
|
|
87
|
+
window({ complete, stoppedEarly }) {
|
|
88
|
+
return {
|
|
89
|
+
complete,
|
|
90
|
+
end: stoppedEarly
|
|
91
|
+
? SESSION_EVENT_WINDOW_END.OLDEST
|
|
92
|
+
: complete
|
|
93
|
+
? SESSION_EVENT_WINDOW_END.NEWEST
|
|
94
|
+
: SESSION_EVENT_WINDOW_END.PARTIAL,
|
|
95
|
+
outcomesMayBeUnresolved: stoppedEarly || !complete,
|
|
96
|
+
};
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function createUnmappedSessionEventTracker() {
|
|
102
|
+
const counts = new Map();
|
|
103
|
+
let other = 0;
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
add(type) {
|
|
107
|
+
const normalizedType = typeof type === "string" && type.trim()
|
|
108
|
+
? type.trim()
|
|
109
|
+
: "unknown";
|
|
110
|
+
if (counts.has(normalizedType)) {
|
|
111
|
+
counts.set(normalizedType, counts.get(normalizedType) + 1);
|
|
112
|
+
} else if (counts.size < MAX_UNMAPPED_SESSION_EVENT_TYPES) {
|
|
113
|
+
counts.set(normalizedType, 1);
|
|
114
|
+
} else {
|
|
115
|
+
other += 1;
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
values(limit = 5) {
|
|
119
|
+
const entries = [...counts].map(([type, count]) => ({ count, type }));
|
|
120
|
+
if (other > 0) entries.push({ count: other, type: "other unmapped types" });
|
|
121
|
+
return entries
|
|
122
|
+
.sort((left, right) => right.count - left.count || left.type.localeCompare(right.type))
|
|
123
|
+
.slice(0, limit);
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
}
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
export const SESSION_EVENT_KIND = Object.freeze({
|
|
2
|
+
ASK: "ask",
|
|
3
|
+
DECIDED: "decided",
|
|
4
|
+
EDIT: "edit",
|
|
5
|
+
PLAN: "plan",
|
|
6
|
+
RAN: "ran",
|
|
7
|
+
SAID: "said",
|
|
8
|
+
SUMMARY: "summary",
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
export const SESSION_EVENT_COVERAGE_THRESHOLD = 90;
|
|
12
|
+
|
|
13
|
+
export const SESSION_EVENT_READ_MODE = Object.freeze({
|
|
14
|
+
PREVIEW: "preview",
|
|
15
|
+
RECENT: "recent",
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
export const SESSION_EVENT_REASON = Object.freeze({
|
|
19
|
+
NO_RECOGNIZED_EVENTS: "no-recognized-events",
|
|
20
|
+
NO_TRANSCRIPT_PATH: "no-transcript-path",
|
|
21
|
+
TRANSCRIPT_MISSING: "transcript-missing",
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
export const SESSION_EVENT_WINDOW_END = Object.freeze({
|
|
25
|
+
NEWEST: "newest",
|
|
26
|
+
OLDEST: "oldest",
|
|
27
|
+
PARTIAL: "partial",
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const SESSION_EVENT_KINDS = new Set(Object.values(SESSION_EVENT_KIND));
|
|
31
|
+
const SESSION_EVENT_REASONS = new Set(Object.values(SESSION_EVENT_REASON));
|
|
32
|
+
const SESSION_EVENT_WINDOW_ENDS = new Set(Object.values(SESSION_EVENT_WINDOW_END));
|
|
33
|
+
|
|
34
|
+
function nullableBoolean(value, field) {
|
|
35
|
+
if (value === null || typeof value === "boolean") return value;
|
|
36
|
+
throw new TypeError(`${field} must be a boolean or null.`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function nullableCount(value, field) {
|
|
40
|
+
if (value === null || (Number.isInteger(value) && value >= 0)) return value;
|
|
41
|
+
throw new TypeError(`${field} must be a non-negative integer or null.`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function nullableString(value, field) {
|
|
45
|
+
if (value === null || typeof value === "string") return value;
|
|
46
|
+
throw new TypeError(`${field} must be a string or null.`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function requiredCount(value, field) {
|
|
50
|
+
if (Number.isInteger(value) && value >= 0) return value;
|
|
51
|
+
throw new TypeError(`${field} must be a non-negative integer.`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function requiredBoolean(value, field) {
|
|
55
|
+
if (typeof value === "boolean") return value;
|
|
56
|
+
throw new TypeError(`${field} must be a boolean.`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function requiredString(value, field) {
|
|
60
|
+
if (typeof value === "string") return value;
|
|
61
|
+
throw new TypeError(`${field} must be a string.`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function unmappedTypeEntries(value) {
|
|
65
|
+
if (!Array.isArray(value)) {
|
|
66
|
+
throw new TypeError("coverage.unmappedTypes must be an array.");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return value.map((entry) => ({
|
|
70
|
+
count: requiredCount(entry?.count, "coverage.unmappedTypes.count"),
|
|
71
|
+
type: requiredString(entry?.type, "coverage.unmappedTypes.type"),
|
|
72
|
+
}));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function sessionEventBase({ atMs, kind, sequence }) {
|
|
76
|
+
if (!SESSION_EVENT_KINDS.has(kind)) {
|
|
77
|
+
throw new TypeError(`Unsupported session event kind: ${kind}`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (atMs !== null && !Number.isFinite(atMs)) {
|
|
81
|
+
throw new TypeError("atMs must be a finite number or null.");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
atMs,
|
|
86
|
+
kind,
|
|
87
|
+
sequence: requiredCount(sequence, "sequence"),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function createSessionEvent({
|
|
92
|
+
added = null,
|
|
93
|
+
answer = null,
|
|
94
|
+
applied = null,
|
|
95
|
+
atMs = null,
|
|
96
|
+
command = null,
|
|
97
|
+
error = null,
|
|
98
|
+
failed = null,
|
|
99
|
+
files = [],
|
|
100
|
+
injected = false,
|
|
101
|
+
kind,
|
|
102
|
+
question = null,
|
|
103
|
+
removed = null,
|
|
104
|
+
sequence,
|
|
105
|
+
steps = [],
|
|
106
|
+
text = null,
|
|
107
|
+
unclassified = false,
|
|
108
|
+
unextracted = false,
|
|
109
|
+
workdir = null,
|
|
110
|
+
} = {}) {
|
|
111
|
+
const base = sessionEventBase({ atMs, kind, sequence });
|
|
112
|
+
|
|
113
|
+
if (kind === SESSION_EVENT_KIND.ASK) {
|
|
114
|
+
return {
|
|
115
|
+
atMs: base.atMs,
|
|
116
|
+
injected: requiredBoolean(injected, "injected"),
|
|
117
|
+
kind: base.kind,
|
|
118
|
+
sequence: base.sequence,
|
|
119
|
+
text: requiredString(text, "text"),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (kind === SESSION_EVENT_KIND.DECIDED) {
|
|
124
|
+
return {
|
|
125
|
+
answer: nullableString(answer, "answer"),
|
|
126
|
+
atMs: base.atMs,
|
|
127
|
+
kind: base.kind,
|
|
128
|
+
question: requiredString(question, "question"),
|
|
129
|
+
sequence: base.sequence,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (kind === SESSION_EVENT_KIND.EDIT) {
|
|
134
|
+
if (!Array.isArray(files) || files.some((file) => typeof file !== "string")) {
|
|
135
|
+
throw new TypeError("files must be an array of strings.");
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
added: nullableCount(added, "added"),
|
|
140
|
+
applied: nullableBoolean(applied, "applied"),
|
|
141
|
+
atMs: base.atMs,
|
|
142
|
+
files: [...files],
|
|
143
|
+
kind: base.kind,
|
|
144
|
+
removed: nullableCount(removed, "removed"),
|
|
145
|
+
sequence: base.sequence,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (kind === SESSION_EVENT_KIND.PLAN) {
|
|
150
|
+
if (!Array.isArray(steps)) {
|
|
151
|
+
throw new TypeError("steps must be an array.");
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
atMs: base.atMs,
|
|
156
|
+
kind: base.kind,
|
|
157
|
+
sequence: base.sequence,
|
|
158
|
+
steps: steps.map((step) => ({
|
|
159
|
+
status: requiredString(step?.status, "step.status"),
|
|
160
|
+
text: requiredString(step?.text, "step.text"),
|
|
161
|
+
})),
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (kind === SESSION_EVENT_KIND.RAN) {
|
|
166
|
+
return {
|
|
167
|
+
atMs: base.atMs,
|
|
168
|
+
command: nullableString(command, "command"),
|
|
169
|
+
error: nullableString(error, "error"),
|
|
170
|
+
failed: nullableBoolean(failed, "failed"),
|
|
171
|
+
kind: base.kind,
|
|
172
|
+
sequence: base.sequence,
|
|
173
|
+
unclassified: requiredBoolean(unclassified, "unclassified"),
|
|
174
|
+
unextracted: requiredBoolean(unextracted, "unextracted"),
|
|
175
|
+
workdir: nullableString(workdir, "workdir"),
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
atMs: base.atMs,
|
|
181
|
+
kind: base.kind,
|
|
182
|
+
sequence: base.sequence,
|
|
183
|
+
text: requiredString(text, "text"),
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function createSessionEventCoverage({
|
|
188
|
+
duplicates = 0,
|
|
189
|
+
oversized = 0,
|
|
190
|
+
recognized = 0,
|
|
191
|
+
skipped = 0,
|
|
192
|
+
total = 0,
|
|
193
|
+
unmapped = 0,
|
|
194
|
+
unmappedTypes = [],
|
|
195
|
+
unparseable = 0,
|
|
196
|
+
} = {}) {
|
|
197
|
+
return {
|
|
198
|
+
duplicates: requiredCount(duplicates, "coverage.duplicates"),
|
|
199
|
+
oversized: requiredCount(oversized, "coverage.oversized"),
|
|
200
|
+
recognized: requiredCount(recognized, "coverage.recognized"),
|
|
201
|
+
skipped: requiredCount(skipped, "coverage.skipped"),
|
|
202
|
+
total: requiredCount(total, "coverage.total"),
|
|
203
|
+
unmapped: requiredCount(unmapped, "coverage.unmapped"),
|
|
204
|
+
unmappedTypes: unmappedTypeEntries(unmappedTypes),
|
|
205
|
+
unparseable: requiredCount(unparseable, "coverage.unparseable"),
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function sessionEventCoveragePercent(coverage) {
|
|
210
|
+
const normalizedCoverage = createSessionEventCoverage(coverage);
|
|
211
|
+
const considered = normalizedCoverage.total - normalizedCoverage.skipped;
|
|
212
|
+
if (considered === 0) return 100;
|
|
213
|
+
return Math.round((normalizedCoverage.recognized / considered) * 100);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function createSessionEventHeader({
|
|
217
|
+
cwd = null,
|
|
218
|
+
git = null,
|
|
219
|
+
model = null,
|
|
220
|
+
origin = null,
|
|
221
|
+
provider,
|
|
222
|
+
version = null,
|
|
223
|
+
} = {}) {
|
|
224
|
+
let normalizedGit = null;
|
|
225
|
+
|
|
226
|
+
if (git !== null) {
|
|
227
|
+
if (typeof git !== "object" || Array.isArray(git)) {
|
|
228
|
+
throw new TypeError("git must be an object or null.");
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
normalizedGit = {
|
|
232
|
+
branch: nullableString(git.branch ?? null, "git.branch"),
|
|
233
|
+
commit: nullableString(git.commit ?? null, "git.commit"),
|
|
234
|
+
repository: nullableString(git.repository ?? null, "git.repository"),
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return {
|
|
239
|
+
cwd: nullableString(cwd, "cwd"),
|
|
240
|
+
git: normalizedGit,
|
|
241
|
+
model: nullableString(model, "model"),
|
|
242
|
+
origin: nullableString(origin, "origin"),
|
|
243
|
+
provider: requiredString(provider, "provider"),
|
|
244
|
+
version: nullableString(version, "version"),
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export function createSessionEventsResult({
|
|
249
|
+
coverage,
|
|
250
|
+
events = [],
|
|
251
|
+
header,
|
|
252
|
+
reason = null,
|
|
253
|
+
window = {},
|
|
254
|
+
} = {}) {
|
|
255
|
+
const normalizedCoverage = createSessionEventCoverage(coverage);
|
|
256
|
+
|
|
257
|
+
if (
|
|
258
|
+
normalizedCoverage.recognized
|
|
259
|
+
+ normalizedCoverage.skipped
|
|
260
|
+
+ normalizedCoverage.unmapped
|
|
261
|
+
+ normalizedCoverage.unparseable
|
|
262
|
+
+ normalizedCoverage.oversized
|
|
263
|
+
!== normalizedCoverage.total
|
|
264
|
+
) {
|
|
265
|
+
throw new TypeError("Session event coverage counts must add up to the total.");
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (!Array.isArray(events)) {
|
|
269
|
+
throw new TypeError("events must be an array.");
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (reason !== null && !SESSION_EVENT_REASONS.has(reason)) {
|
|
273
|
+
throw new TypeError(`Unsupported session event reason: ${reason}`);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (reason !== null && events.length > 0) {
|
|
277
|
+
throw new TypeError("A session event reason requires an empty event list.");
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const complete = window.complete ?? true;
|
|
281
|
+
const end = window.end ?? SESSION_EVENT_WINDOW_END.NEWEST;
|
|
282
|
+
const outcomesMayBeUnresolved = window.outcomesMayBeUnresolved ?? false;
|
|
283
|
+
|
|
284
|
+
if (typeof complete !== "boolean") {
|
|
285
|
+
throw new TypeError("window.complete must be a boolean.");
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (end !== null && !SESSION_EVENT_WINDOW_ENDS.has(end)) {
|
|
289
|
+
throw new TypeError(`Unsupported session event window end: ${end}`);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (typeof outcomesMayBeUnresolved !== "boolean") {
|
|
293
|
+
throw new TypeError("window.outcomesMayBeUnresolved must be a boolean.");
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return {
|
|
297
|
+
coverage: normalizedCoverage,
|
|
298
|
+
events: [...events],
|
|
299
|
+
header: createSessionEventHeader(header),
|
|
300
|
+
reason,
|
|
301
|
+
window: {
|
|
302
|
+
complete,
|
|
303
|
+
end,
|
|
304
|
+
outcomesMayBeUnresolved,
|
|
305
|
+
},
|
|
306
|
+
};
|
|
307
|
+
}
|
package/lib/storage/jsonl.mjs
CHANGED
|
@@ -4,6 +4,11 @@ import { promises as fs } from "node:fs";
|
|
|
4
4
|
import readline from "node:readline";
|
|
5
5
|
import { finished } from "node:stream/promises";
|
|
6
6
|
|
|
7
|
+
export const DEFAULT_JSONL_MAX_LINE_BYTES = 8 * 1024 * 1024;
|
|
8
|
+
|
|
9
|
+
const JSONL_READ_CHUNK_BYTES = 64 * 1024;
|
|
10
|
+
const JSONL_STARTING_LINE_BYTES = 4 * 1024;
|
|
11
|
+
|
|
7
12
|
function parseLine(raw, index) {
|
|
8
13
|
try {
|
|
9
14
|
return { index, parsed: JSON.parse(raw), raw };
|
|
@@ -35,6 +40,149 @@ export async function* readJsonlEntries(filePath) {
|
|
|
35
40
|
}
|
|
36
41
|
}
|
|
37
42
|
|
|
43
|
+
function appendLineBytes(state, bytes, maxLineBytes) {
|
|
44
|
+
if (state.oversized || bytes.length === 0) return;
|
|
45
|
+
|
|
46
|
+
if (state.length + bytes.length > maxLineBytes) {
|
|
47
|
+
state.length = 0;
|
|
48
|
+
state.oversized = true;
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const requiredBytes = state.length + bytes.length;
|
|
53
|
+
if (state.buffer.length < requiredBytes) {
|
|
54
|
+
const capacity = Math.min(
|
|
55
|
+
maxLineBytes,
|
|
56
|
+
Math.max(requiredBytes, state.buffer.length * 2),
|
|
57
|
+
);
|
|
58
|
+
const buffer = Buffer.allocUnsafe(capacity);
|
|
59
|
+
state.buffer.copy(buffer, 0, 0, state.length);
|
|
60
|
+
state.buffer = buffer;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
bytes.copy(state.buffer, state.length);
|
|
64
|
+
state.length += bytes.length;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function createLineState(maxLineBytes) {
|
|
68
|
+
return {
|
|
69
|
+
buffer: Buffer.allocUnsafe(Math.min(JSONL_STARTING_LINE_BYTES, maxLineBytes)),
|
|
70
|
+
length: 0,
|
|
71
|
+
oversized: false,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function resetLineState(state) {
|
|
76
|
+
state.length = 0;
|
|
77
|
+
state.oversized = false;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function snapshotEntry(state, index) {
|
|
81
|
+
if (state.oversized) {
|
|
82
|
+
return {
|
|
83
|
+
index,
|
|
84
|
+
oversized: true,
|
|
85
|
+
parsed: null,
|
|
86
|
+
raw: null,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
let bytes = state.buffer.subarray(0, state.length);
|
|
91
|
+
if (bytes.at(-1) === 13) bytes = bytes.subarray(0, -1);
|
|
92
|
+
if (bytes.length === 0) return null;
|
|
93
|
+
|
|
94
|
+
const entry = parseLine(bytes.toString("utf8"), index);
|
|
95
|
+
return {
|
|
96
|
+
index: entry.index,
|
|
97
|
+
oversized: false,
|
|
98
|
+
parsed: entry.parsed,
|
|
99
|
+
raw: entry.raw,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function visitJsonlSnapshotEntries(
|
|
104
|
+
filePath,
|
|
105
|
+
visit,
|
|
106
|
+
{ maxLineBytes = DEFAULT_JSONL_MAX_LINE_BYTES } = {},
|
|
107
|
+
) {
|
|
108
|
+
if (!Number.isSafeInteger(maxLineBytes) || maxLineBytes < 1) {
|
|
109
|
+
throw new TypeError("maxLineBytes must be a positive integer.");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const handle = await fs.open(filePath, "r");
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
const { size: snapshotBytes } = await handle.stat();
|
|
116
|
+
let complete = true;
|
|
117
|
+
let index = 0;
|
|
118
|
+
let offset = 0;
|
|
119
|
+
const buffer = Buffer.allocUnsafe(Math.min(JSONL_READ_CHUNK_BYTES, Math.max(1, snapshotBytes)));
|
|
120
|
+
const state = createLineState(maxLineBytes);
|
|
121
|
+
let stoppedEarly = false;
|
|
122
|
+
|
|
123
|
+
async function flushLine() {
|
|
124
|
+
const entry = snapshotEntry(state, index);
|
|
125
|
+
resetLineState(state);
|
|
126
|
+
if (!entry) return true;
|
|
127
|
+
index += 1;
|
|
128
|
+
return (await visit(entry)) !== false;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
while (offset < snapshotBytes) {
|
|
132
|
+
const requestedBytes = Math.min(JSONL_READ_CHUNK_BYTES, snapshotBytes - offset);
|
|
133
|
+
let bytesRead;
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
({ bytesRead } = await handle.read(buffer, 0, requestedBytes, offset));
|
|
137
|
+
} catch (error) {
|
|
138
|
+
if (error?.code === "ENOENT") {
|
|
139
|
+
complete = false;
|
|
140
|
+
break;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
throw error;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (bytesRead === 0) {
|
|
147
|
+
complete = false;
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
offset += bytesRead;
|
|
152
|
+
let start = 0;
|
|
153
|
+
|
|
154
|
+
while (start < bytesRead) {
|
|
155
|
+
const newline = buffer.indexOf(10, start);
|
|
156
|
+
const end = newline === -1 || newline >= bytesRead ? bytesRead : newline;
|
|
157
|
+
appendLineBytes(state, buffer.subarray(start, end), maxLineBytes);
|
|
158
|
+
|
|
159
|
+
if (newline === -1 || newline >= bytesRead) break;
|
|
160
|
+
|
|
161
|
+
if (!(await flushLine())) {
|
|
162
|
+
stoppedEarly = true;
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
start = newline + 1;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (stoppedEarly) break;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (!stoppedEarly && (state.oversized || state.length > 0)) {
|
|
173
|
+
if (!(await flushLine())) stoppedEarly = true;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
complete: complete && !stoppedEarly,
|
|
178
|
+
snapshotBytes,
|
|
179
|
+
stoppedEarly,
|
|
180
|
+
};
|
|
181
|
+
} finally {
|
|
182
|
+
await handle.close();
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
38
186
|
export async function inspectJsonlMatches(filePath, matches, { sampleLimit = 100 } = {}) {
|
|
39
187
|
let count = 0;
|
|
40
188
|
const samples = [];
|