jev-devtools 0.1.3 → 0.3.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/README.md +50 -4
- package/THIRD_PARTY_NOTICES.txt +27 -0
- package/lib/flow/index.d.ts +82 -0
- package/lib/flow/index.js +588 -0
- package/lib/server/flows.d.ts +25 -0
- package/lib/server/flows.js +115 -0
- package/lib/server/index.d.ts +8 -0
- package/lib/server/index.js +163 -9
- package/lib/server/recorder.d.ts +12 -2
- package/lib/server/recorder.js +47 -9
- package/lib/src/ChoiceProbabilities.d.ts +4 -0
- package/lib/src/ChoiceProbabilities.js +19 -0
- package/lib/src/ConnectedInspector.js +46 -29
- package/lib/src/DiagramContent.d.ts +6 -0
- package/lib/src/DiagramContent.js +32 -0
- package/lib/src/FlowOverview.d.ts +7 -0
- package/lib/src/FlowOverview.js +228 -0
- package/lib/src/JevDevtools.d.ts +6 -1
- package/lib/src/JevDevtools.js +52 -48
- package/lib/src/JevDrawer.d.ts +3 -2
- package/lib/src/JevDrawer.js +12 -3
- package/lib/src/Questions.d.ts +5 -0
- package/lib/src/Questions.js +174 -0
- package/lib/src/TraceContent.d.ts +11 -0
- package/lib/src/TraceContent.js +32 -0
- package/lib/src/TraceDiagram.d.ts +4 -1
- package/lib/src/TraceDiagram.js +111 -25
- package/lib/src/condition-inputs.d.ts +9 -0
- package/lib/src/condition-inputs.js +100 -0
- package/lib/src/flow-condition.d.ts +4 -0
- package/lib/src/flow-condition.js +47 -0
- package/lib/src/flow-model.d.ts +7 -2
- package/lib/src/flow-model.js +3 -12
- package/lib/src/flow-protocol.d.ts +68 -0
- package/lib/src/flow-protocol.js +1 -0
- package/lib/src/flow-stream.d.ts +8 -0
- package/lib/src/flow-stream.js +80 -0
- package/lib/src/index.d.ts +1 -1
- package/lib/src/inspector-styles.js +1 -1
- package/lib/src/inspector.css +725 -8
- package/lib/src/protocol.d.ts +18 -0
- package/lib/src/trace-store.d.ts +6 -0
- package/lib/src/trace-store.js +66 -0
- package/lib/src/trace-stream.d.ts +2 -0
- package/lib/src/trace-stream.js +65 -0
- package/lib/src/useDiagramViewport.d.ts +5 -0
- package/lib/src/useDiagramViewport.js +34 -6
- package/package.json +7 -2
- package/skills/setup-jev-devtools/SKILL.md +6 -2
- package/skills/setup-jev-devtools/references/flows.md +150 -0
- package/skills/setup-jev-devtools/references/nextjs.md +1 -1
- package/skills/setup-jev-devtools/references/verify.md +3 -1
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { capture } from "./recorder.js";
|
|
2
|
+
/** Independent bounded run store. Definitions are retained only while referenced. */
|
|
3
|
+
export class FlowRecorder {
|
|
4
|
+
options;
|
|
5
|
+
runs = new Map();
|
|
6
|
+
definitions = new Map();
|
|
7
|
+
sessions = new Map();
|
|
8
|
+
definitionRevisions = new Map();
|
|
9
|
+
revisions = new Map();
|
|
10
|
+
revision = 0;
|
|
11
|
+
listeners = new Set();
|
|
12
|
+
constructor(options) {
|
|
13
|
+
this.options = options;
|
|
14
|
+
}
|
|
15
|
+
subscribe(fn) {
|
|
16
|
+
this.listeners.add(fn);
|
|
17
|
+
return () => {
|
|
18
|
+
this.listeners.delete(fn);
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
notify(sessions) {
|
|
22
|
+
for (const fn of this.listeners) {
|
|
23
|
+
try {
|
|
24
|
+
fn(sessions);
|
|
25
|
+
}
|
|
26
|
+
catch { }
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
start(definition, run, session) {
|
|
30
|
+
if (!this.definitions.has(definition.id)) {
|
|
31
|
+
const stored = structuredClone(definition);
|
|
32
|
+
for (const node of stored.nodes) {
|
|
33
|
+
if (node.conditionRule === undefined)
|
|
34
|
+
continue;
|
|
35
|
+
try {
|
|
36
|
+
node.conditionRule = capture(node.conditionRule, new Set(), this.options.redact
|
|
37
|
+
? (path, value) => this.options.redact(["conditions", node.id, ...path], value)
|
|
38
|
+
: undefined);
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
node.conditionRule = "[rule capture unavailable]";
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
this.definitions.set(definition.id, stored);
|
|
45
|
+
this.definitionRevisions.set(definition.id, ++this.revision);
|
|
46
|
+
}
|
|
47
|
+
this.runs.set(run.id, run);
|
|
48
|
+
this.sessions.set(run.id, session);
|
|
49
|
+
this.revisions.set(run.id, ++this.revision);
|
|
50
|
+
const sessions = new Set([session]);
|
|
51
|
+
const limit = Math.max(1, Math.min(200, this.options.limit ?? 50));
|
|
52
|
+
while (this.runs.size > limit) {
|
|
53
|
+
const id = this.runs.keys().next().value;
|
|
54
|
+
sessions.add(this.sessions.get(id));
|
|
55
|
+
this.remove(id);
|
|
56
|
+
}
|
|
57
|
+
this.prune();
|
|
58
|
+
this.notify(sessions);
|
|
59
|
+
}
|
|
60
|
+
update(run) {
|
|
61
|
+
if (!this.runs.has(run.id))
|
|
62
|
+
return; // Clear/eviction cannot resurrect pending runs.
|
|
63
|
+
const stored = { ...run };
|
|
64
|
+
if ("value" in stored) {
|
|
65
|
+
try {
|
|
66
|
+
stored.value = capture(stored.value, new Set(), this.options.redact);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
stored.value = "[capture unavailable]";
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
this.runs.set(run.id, stored);
|
|
73
|
+
this.revisions.set(run.id, ++this.revision);
|
|
74
|
+
this.notify(new Set([this.sessions.get(run.id)]));
|
|
75
|
+
}
|
|
76
|
+
remove(id) {
|
|
77
|
+
this.runs.delete(id);
|
|
78
|
+
this.sessions.delete(id);
|
|
79
|
+
this.revisions.delete(id);
|
|
80
|
+
}
|
|
81
|
+
prune() {
|
|
82
|
+
const used = new Set([...this.runs.values()].map((r) => r.definitionId));
|
|
83
|
+
for (const id of this.definitions.keys())
|
|
84
|
+
if (!used.has(id)) {
|
|
85
|
+
this.definitions.delete(id);
|
|
86
|
+
this.definitionRevisions.delete(id);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
clear(session) {
|
|
90
|
+
for (const id of this.runs.keys())
|
|
91
|
+
if (this.sessions.get(id) === session)
|
|
92
|
+
this.remove(id);
|
|
93
|
+
this.prune();
|
|
94
|
+
this.revision++;
|
|
95
|
+
this.notify(new Set([session]));
|
|
96
|
+
}
|
|
97
|
+
changes(session, cursor) {
|
|
98
|
+
const current = [...this.runs.values()]
|
|
99
|
+
.filter((r) => this.sessions.get(r.id) === session)
|
|
100
|
+
.reverse();
|
|
101
|
+
const used = new Set(current.map((r) => r.definitionId));
|
|
102
|
+
return {
|
|
103
|
+
revision: this.revision,
|
|
104
|
+
data: structuredClone({
|
|
105
|
+
version: 1,
|
|
106
|
+
definitions: [...used]
|
|
107
|
+
.filter((id) => cursor === undefined ||
|
|
108
|
+
this.definitionRevisions.get(id) > cursor)
|
|
109
|
+
.map((id) => this.definitions.get(id)),
|
|
110
|
+
upserts: current.filter((r) => cursor === undefined || this.revisions.get(r.id) > cursor),
|
|
111
|
+
order: current.map((r) => r.id),
|
|
112
|
+
}),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
}
|
package/lib/server/index.d.ts
CHANGED
|
@@ -19,6 +19,14 @@ export type ClientMetadata = {
|
|
|
19
19
|
};
|
|
20
20
|
/** Node runtime adapter: framework-neutral Request/Response plus Node middleware. */
|
|
21
21
|
export declare function createJevDevtools(options: JevDevtoolsServerOptions): {
|
|
22
|
+
startFlow: (definition: import("../src/flow-protocol.js").FlowDefinition, run: import("../src/flow-protocol.js").FlowRun) => {
|
|
23
|
+
update(run: import("../src/flow-protocol.js").FlowRun): void;
|
|
24
|
+
request<T>(link: {
|
|
25
|
+
runId: string;
|
|
26
|
+
stepId: string;
|
|
27
|
+
executionId: string;
|
|
28
|
+
}, onTrace: (id: string) => void, callback: () => T): T;
|
|
29
|
+
} | undefined;
|
|
22
30
|
handle: (request: Request) => Promise<Response>;
|
|
23
31
|
/**
|
|
24
32
|
* For Node framework routes whose Request URL uses an internal hostname.
|
package/lib/server/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { FlowRecorder } from "./flows.js";
|
|
1
2
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
3
|
import { Readable } from "node:stream";
|
|
3
4
|
import { pipeline } from "node:stream/promises";
|
|
@@ -17,7 +18,59 @@ export function createJevDevtools(options) {
|
|
|
17
18
|
if (options.enabled && !local && !options.authorize)
|
|
18
19
|
throw new Error("Non-loopback inspector endpoints require authorize");
|
|
19
20
|
const recorder = new JevRecorder(options), scope = new AsyncLocalStorage();
|
|
21
|
+
const flows = new FlowRecorder(options);
|
|
20
22
|
const streams = new Set();
|
|
23
|
+
const pending = new Set();
|
|
24
|
+
const encoder = new TextEncoder();
|
|
25
|
+
let scheduled;
|
|
26
|
+
const encodeUpdate = (session, incremental, cursor) => {
|
|
27
|
+
if (!incremental)
|
|
28
|
+
return {
|
|
29
|
+
bytes: encoder.encode(`event: snapshot\ndata: ${JSON.stringify({ version: 1, traces: recorder.snapshot(session) })}\n\n`),
|
|
30
|
+
revision: 0,
|
|
31
|
+
};
|
|
32
|
+
const { revision, upserts, order } = recorder.changes(session, cursor);
|
|
33
|
+
const initial = cursor === undefined;
|
|
34
|
+
const data = initial
|
|
35
|
+
? { version: 2, revision, traces: upserts }
|
|
36
|
+
: { version: 2, base: cursor, revision, upserts, order };
|
|
37
|
+
return {
|
|
38
|
+
bytes: encoder.encode(`event: ${initial ? "snapshot" : "delta"}\ndata: ${JSON.stringify(data)}\n\n`),
|
|
39
|
+
revision,
|
|
40
|
+
};
|
|
41
|
+
};
|
|
42
|
+
function schedule(flush) {
|
|
43
|
+
pending.add(flush);
|
|
44
|
+
if (scheduled)
|
|
45
|
+
return;
|
|
46
|
+
scheduled = setImmediate(() => {
|
|
47
|
+
scheduled = undefined;
|
|
48
|
+
const batch = [...pending];
|
|
49
|
+
pending.clear();
|
|
50
|
+
// Cache only for this synchronous delivery turn and only within a session.
|
|
51
|
+
// Each reader owns its bytes; mutating one Response must not corrupt another.
|
|
52
|
+
const encoded = new Map();
|
|
53
|
+
const updateFor = (session, incremental, cursor) => {
|
|
54
|
+
// Slow readers can have older cursors even in the same session.
|
|
55
|
+
const key = JSON.stringify([session, incremental, cursor]);
|
|
56
|
+
let update = encoded.get(key);
|
|
57
|
+
if (!update) {
|
|
58
|
+
update = encodeUpdate(session, incremental, cursor);
|
|
59
|
+
encoded.set(key, update);
|
|
60
|
+
}
|
|
61
|
+
return { bytes: update.bytes.slice(), revision: update.revision };
|
|
62
|
+
};
|
|
63
|
+
for (const deliver of batch)
|
|
64
|
+
deliver(updateFor);
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
function unschedule(flush) {
|
|
68
|
+
pending.delete(flush);
|
|
69
|
+
if (!pending.size && scheduled) {
|
|
70
|
+
clearImmediate(scheduled);
|
|
71
|
+
scheduled = undefined;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
21
74
|
let disposed = false;
|
|
22
75
|
const enabled = () => options.enabled === true && !disposed;
|
|
23
76
|
const sessionFor = async (request) => {
|
|
@@ -40,33 +93,118 @@ export function createJevDevtools(options) {
|
|
|
40
93
|
return json({ error: "Unauthorized" }, 403);
|
|
41
94
|
const session = await sessionFor(request);
|
|
42
95
|
if (url.pathname === endpoint && request.method === "GET")
|
|
43
|
-
return json({ version: 1, enabled: true });
|
|
96
|
+
return json({ version: 1, enabled: true, flows: true });
|
|
44
97
|
if (url.pathname === endpoint + "/clear") {
|
|
45
98
|
if (request.method !== "DELETE")
|
|
46
99
|
return json({ error: "Method not allowed" }, 405);
|
|
47
100
|
if (request.headers.get("origin") !== origin)
|
|
48
101
|
return json({ error: "Same-origin requests required" }, 403);
|
|
49
102
|
recorder.clear(session);
|
|
103
|
+
flows.clear(session);
|
|
50
104
|
return json({ ok: true });
|
|
51
105
|
}
|
|
106
|
+
let flowPull = () => { };
|
|
107
|
+
if (url.pathname === endpoint + "/flows/events") {
|
|
108
|
+
if (request.method !== "GET")
|
|
109
|
+
return json({ error: "Method not allowed" }, 405);
|
|
110
|
+
let stop = () => { };
|
|
111
|
+
const body = new ReadableStream({
|
|
112
|
+
start(controller) {
|
|
113
|
+
let closed = false, dirty = true, cursor, timer;
|
|
114
|
+
const flush = () => {
|
|
115
|
+
timer = undefined;
|
|
116
|
+
if (closed || !dirty || (controller.desiredSize ?? 0) <= 0)
|
|
117
|
+
return;
|
|
118
|
+
dirty = false;
|
|
119
|
+
const update = flows.changes(session, cursor);
|
|
120
|
+
cursor = update.revision;
|
|
121
|
+
controller.enqueue(encoder.encode(`event: flows\ndata: ${JSON.stringify(update.data)}\n\n`));
|
|
122
|
+
};
|
|
123
|
+
const wake = () => {
|
|
124
|
+
if (!closed && dirty && !timer)
|
|
125
|
+
timer = setImmediate(flush);
|
|
126
|
+
};
|
|
127
|
+
const unsubscribe = flows.subscribe((sessions) => {
|
|
128
|
+
if (sessions.has(session)) {
|
|
129
|
+
dirty = true;
|
|
130
|
+
wake();
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
const heartbeat = setInterval(() => {
|
|
134
|
+
if (!closed && (controller.desiredSize ?? 0) > 0)
|
|
135
|
+
controller.enqueue(encoder.encode(": keepalive\n\n"));
|
|
136
|
+
}, 15000);
|
|
137
|
+
stop = () => {
|
|
138
|
+
if (closed)
|
|
139
|
+
return;
|
|
140
|
+
closed = true;
|
|
141
|
+
unsubscribe();
|
|
142
|
+
clearImmediate(timer);
|
|
143
|
+
clearInterval(heartbeat);
|
|
144
|
+
request.signal.removeEventListener("abort", stop);
|
|
145
|
+
streams.delete(stop);
|
|
146
|
+
try {
|
|
147
|
+
controller.close();
|
|
148
|
+
}
|
|
149
|
+
catch { }
|
|
150
|
+
};
|
|
151
|
+
streams.add(stop);
|
|
152
|
+
request.signal.addEventListener("abort", stop, { once: true });
|
|
153
|
+
flowPull = wake;
|
|
154
|
+
if (request.signal.aborted)
|
|
155
|
+
stop();
|
|
156
|
+
else
|
|
157
|
+
flush();
|
|
158
|
+
},
|
|
159
|
+
pull() {
|
|
160
|
+
flowPull();
|
|
161
|
+
},
|
|
162
|
+
cancel() {
|
|
163
|
+
stop();
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
return new Response(body, {
|
|
167
|
+
headers: {
|
|
168
|
+
"content-type": "text/event-stream",
|
|
169
|
+
"cache-control": "no-store",
|
|
170
|
+
"x-accel-buffering": "no",
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
}
|
|
52
174
|
if (url.pathname !== endpoint + "/events")
|
|
53
175
|
return json({ error: "Not found" }, 404);
|
|
54
176
|
if (request.method !== "GET")
|
|
55
177
|
return json({ error: "Method not allowed" }, 405);
|
|
56
|
-
const
|
|
57
|
-
let
|
|
178
|
+
const incremental = url.searchParams.get("protocol") === "2";
|
|
179
|
+
let cursor;
|
|
180
|
+
let cleanup = () => { }, wake = () => { }, flush = () => { };
|
|
58
181
|
const stream = new ReadableStream({
|
|
59
182
|
start(controller) {
|
|
60
183
|
let closed = false, dirty = true;
|
|
61
|
-
flush = () => {
|
|
184
|
+
flush = (updateFor) => {
|
|
62
185
|
if (closed || !dirty || (controller.desiredSize ?? 0) <= 0)
|
|
63
186
|
return;
|
|
64
187
|
dirty = false;
|
|
65
|
-
|
|
188
|
+
try {
|
|
189
|
+
const update = updateFor(session, incremental, cursor);
|
|
190
|
+
controller.enqueue(update.bytes);
|
|
191
|
+
cursor = update.revision;
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
// Deferred observer failures must not escape into the application's
|
|
195
|
+
// event loop or prevent other viewers from receiving their update.
|
|
196
|
+
cleanup();
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
wake = () => {
|
|
200
|
+
if (!closed && dirty && (controller.desiredSize ?? 0) > 0)
|
|
201
|
+
schedule(flush);
|
|
66
202
|
};
|
|
67
|
-
const unsubscribe = recorder.subscribe(() => {
|
|
203
|
+
const unsubscribe = recorder.subscribe((sessions) => {
|
|
204
|
+
if (!sessions.has(session))
|
|
205
|
+
return;
|
|
68
206
|
dirty = true;
|
|
69
|
-
|
|
207
|
+
wake();
|
|
70
208
|
});
|
|
71
209
|
const heartbeat = setInterval(() => {
|
|
72
210
|
if (!closed && (controller.desiredSize ?? 0) > 0)
|
|
@@ -77,6 +215,7 @@ export function createJevDevtools(options) {
|
|
|
77
215
|
return;
|
|
78
216
|
closed = true;
|
|
79
217
|
unsubscribe();
|
|
218
|
+
unschedule(flush);
|
|
80
219
|
clearInterval(heartbeat);
|
|
81
220
|
request.signal.removeEventListener("abort", cleanup);
|
|
82
221
|
streams.delete(cleanup);
|
|
@@ -92,10 +231,12 @@ export function createJevDevtools(options) {
|
|
|
92
231
|
if (request.signal.aborted)
|
|
93
232
|
cleanup();
|
|
94
233
|
else
|
|
95
|
-
flush();
|
|
234
|
+
flush(encodeUpdate);
|
|
96
235
|
},
|
|
97
236
|
pull() {
|
|
98
|
-
|
|
237
|
+
// Reading must not bypass the batching boundary. A full queue retains
|
|
238
|
+
// only a dirty flag; the next readable turn delivers current history.
|
|
239
|
+
wake();
|
|
99
240
|
},
|
|
100
241
|
cancel() {
|
|
101
242
|
cleanup();
|
|
@@ -139,7 +280,20 @@ export function createJevDevtools(options) {
|
|
|
139
280
|
res.destroy();
|
|
140
281
|
}
|
|
141
282
|
}
|
|
283
|
+
const startFlow = (definition, run) => {
|
|
284
|
+
if (!enabled())
|
|
285
|
+
return;
|
|
286
|
+
flows.start(definition, run, scope.getStore() ?? "local");
|
|
287
|
+
return {
|
|
288
|
+
update: (next) => {
|
|
289
|
+
if (enabled())
|
|
290
|
+
flows.update(next);
|
|
291
|
+
},
|
|
292
|
+
request: (link, onTrace, callback) => recorder.withFlow(link, onTrace, callback),
|
|
293
|
+
};
|
|
294
|
+
};
|
|
142
295
|
return {
|
|
296
|
+
startFlow,
|
|
143
297
|
handle,
|
|
144
298
|
/**
|
|
145
299
|
* For Node framework routes whose Request URL uses an internal hostname.
|
package/lib/server/recorder.d.ts
CHANGED
|
@@ -1,19 +1,29 @@
|
|
|
1
1
|
import { TypeSafeClient } from "@typesafe-ai/sdk";
|
|
2
2
|
import type { Fetch } from "@typesafe-ai/sdk";
|
|
3
|
-
import type { Trace } from "../src/protocol.js";
|
|
3
|
+
import type { Json, Trace } from "../src/protocol.js";
|
|
4
4
|
export type CaptureOptions = {
|
|
5
5
|
limit?: number;
|
|
6
6
|
redact?: (path: string[], value: unknown) => unknown;
|
|
7
7
|
};
|
|
8
|
+
export declare function capture(value: unknown, warnings: Set<string>, redact?: CaptureOptions["redact"]): Json;
|
|
8
9
|
export declare class JevRecorder {
|
|
9
10
|
private options;
|
|
10
11
|
private traces;
|
|
11
12
|
private listeners;
|
|
13
|
+
private revision;
|
|
14
|
+
private revisions;
|
|
12
15
|
private context;
|
|
16
|
+
private flowContext;
|
|
17
|
+
withFlow<T>(link: NonNullable<Trace["flow"]>, onTrace: (id: string) => void, callback: () => T): T;
|
|
13
18
|
private limit;
|
|
14
19
|
constructor(options?: CaptureOptions);
|
|
15
20
|
snapshot(session: string): Trace[];
|
|
16
|
-
|
|
21
|
+
changes(session: string, after?: number): {
|
|
22
|
+
revision: number;
|
|
23
|
+
order: string[];
|
|
24
|
+
upserts: Trace[];
|
|
25
|
+
};
|
|
26
|
+
subscribe(listener: (sessions: ReadonlySet<string>) => void): () => void;
|
|
17
27
|
clear(session: string): void;
|
|
18
28
|
private notify;
|
|
19
29
|
private update;
|
package/lib/server/recorder.js
CHANGED
|
@@ -5,7 +5,7 @@ const secretKey = /^(authorization|cookie|set-cookie|api[-_]?key|password|secret
|
|
|
5
5
|
const safeMessage = (value) => value.replace(/Bearer\s+\S+/gi, "Bearer [redacted]").slice(0, 500);
|
|
6
6
|
// Snapshot before the caller can mutate its request. Bounded and cycle-safe;
|
|
7
7
|
// image handles become explicit previews/references instead of opaque SDK objects.
|
|
8
|
-
function capture(value, warnings, redact) {
|
|
8
|
+
export function capture(value, warnings, redact) {
|
|
9
9
|
const seen = new WeakSet();
|
|
10
10
|
let budget = 96_000;
|
|
11
11
|
const visit = (input, path, depth) => {
|
|
@@ -79,7 +79,13 @@ export class JevRecorder {
|
|
|
79
79
|
options;
|
|
80
80
|
traces = new Map();
|
|
81
81
|
listeners = new Set();
|
|
82
|
+
revision = 0;
|
|
83
|
+
revisions = new WeakMap();
|
|
82
84
|
context = new AsyncLocalStorage();
|
|
85
|
+
flowContext = new AsyncLocalStorage();
|
|
86
|
+
withFlow(link, onTrace, callback) {
|
|
87
|
+
return this.flowContext.run({ link, onTrace }, callback);
|
|
88
|
+
}
|
|
83
89
|
limit;
|
|
84
90
|
constructor(options = {}) {
|
|
85
91
|
this.options = options;
|
|
@@ -88,6 +94,18 @@ export class JevRecorder {
|
|
|
88
94
|
snapshot(session) {
|
|
89
95
|
return structuredClone([...this.traces.values()].filter((t) => t.session === session).reverse());
|
|
90
96
|
}
|
|
97
|
+
// Only changed payloads are cloned. IDs/order are bounded by the retained
|
|
98
|
+
// history; no tombstone log or per-session history cache is needed.
|
|
99
|
+
changes(session, after) {
|
|
100
|
+
const current = [...this.traces.values()]
|
|
101
|
+
.filter((t) => t.session === session)
|
|
102
|
+
.reverse();
|
|
103
|
+
return {
|
|
104
|
+
revision: this.revision,
|
|
105
|
+
order: current.map((t) => t.id),
|
|
106
|
+
upserts: structuredClone(current.filter((t) => after === undefined || this.revisions.get(t) > after)),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
91
109
|
subscribe(listener) {
|
|
92
110
|
this.listeners.add(listener);
|
|
93
111
|
return () => {
|
|
@@ -95,15 +113,21 @@ export class JevRecorder {
|
|
|
95
113
|
};
|
|
96
114
|
}
|
|
97
115
|
clear(session) {
|
|
116
|
+
let changed = false;
|
|
98
117
|
for (const [id, t] of this.traces)
|
|
99
|
-
if (t.session === session)
|
|
118
|
+
if (t.session === session) {
|
|
100
119
|
this.traces.delete(id);
|
|
101
|
-
|
|
120
|
+
changed = true;
|
|
121
|
+
}
|
|
122
|
+
if (changed) {
|
|
123
|
+
this.revision++;
|
|
124
|
+
this.notify(new Set([session]));
|
|
125
|
+
}
|
|
102
126
|
}
|
|
103
|
-
notify() {
|
|
127
|
+
notify(sessions) {
|
|
104
128
|
for (const listener of this.listeners) {
|
|
105
129
|
try {
|
|
106
|
-
listener();
|
|
130
|
+
listener(sessions);
|
|
107
131
|
}
|
|
108
132
|
catch {
|
|
109
133
|
/* Observers must not affect inference. */
|
|
@@ -114,7 +138,8 @@ export class JevRecorder {
|
|
|
114
138
|
const t = this.traces.get(id);
|
|
115
139
|
if (t) {
|
|
116
140
|
update(t);
|
|
117
|
-
this.
|
|
141
|
+
this.revisions.set(t, ++this.revision);
|
|
142
|
+
this.notify(new Set([t.session]));
|
|
118
143
|
}
|
|
119
144
|
}
|
|
120
145
|
// Use this in TypeSafeClient's supported `fetch` option. It observes attempts
|
|
@@ -175,15 +200,28 @@ export class JevRecorder {
|
|
|
175
200
|
version: 1,
|
|
176
201
|
id,
|
|
177
202
|
...(typeof meta === "function" ? meta() : meta),
|
|
203
|
+
flow: this.flowContext.getStore()?.link,
|
|
178
204
|
startedAt: Date.now(),
|
|
179
205
|
status: "pending",
|
|
180
206
|
request: captured,
|
|
181
207
|
attempts: [],
|
|
182
208
|
captureWarnings: [...warnings],
|
|
183
209
|
});
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
210
|
+
const trace = this.traces.get(id);
|
|
211
|
+
try {
|
|
212
|
+
this.flowContext.getStore()?.onTrace(id);
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
/* Observer only. */
|
|
216
|
+
}
|
|
217
|
+
this.revisions.set(trace, ++this.revision);
|
|
218
|
+
const sessions = new Set([trace.session]);
|
|
219
|
+
while (this.traces.size > this.limit) {
|
|
220
|
+
const oldest = this.traces.values().next().value;
|
|
221
|
+
sessions.add(oldest.session);
|
|
222
|
+
this.traces.delete(oldest.id);
|
|
223
|
+
}
|
|
224
|
+
this.notify(sessions);
|
|
187
225
|
const fail = (error) => this.update(id, (t) => {
|
|
188
226
|
const e = error;
|
|
189
227
|
t.status = e.name === "APIUserAbortError" ? "cancelled" : "error";
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useMemo, useState } from "react";
|
|
3
|
+
export function ChoiceProbabilities({ answer }) {
|
|
4
|
+
const [search, setSearch] = useState("");
|
|
5
|
+
const [page, setPage] = useState(0);
|
|
6
|
+
const rows = useMemo(() => Object.entries(answer.probabilities ?? {}).sort((a, b) => b[1] - a[1]), [answer.probabilities]);
|
|
7
|
+
const filtered = useMemo(() => {
|
|
8
|
+
const query = search.trim().toLocaleLowerCase();
|
|
9
|
+
return rows.filter(([label]) => label.toLocaleLowerCase().includes(query));
|
|
10
|
+
}, [rows, search]);
|
|
11
|
+
const pages = Math.max(1, Math.ceil(filtered.length / 20));
|
|
12
|
+
const current = Math.min(page, pages - 1);
|
|
13
|
+
return (_jsxs("div", { className: "lab-choice-browser", role: "region", "aria-label": "All Choice probabilities", "data-diagram-scroll": "", children: [_jsxs("div", { className: "lab-choice-controls", children: [_jsxs("label", { children: ["Search categories", _jsx("input", { autoFocus: true, type: "search", value: search, onChange: (event) => {
|
|
14
|
+
setSearch(event.target.value);
|
|
15
|
+
setPage(0);
|
|
16
|
+
} })] }), _jsxs("span", { role: "status", children: [filtered.length, " of ", rows.length, " categories \u00B7 highest probability first"] })] }), _jsxs("div", { className: "lab-choice-table-scroll", tabIndex: 0, "aria-label": "Probability table", children: [_jsxs("table", { children: [_jsxs("caption", { children: ["Captured probabilities. Values are not renormalized. Returned choice: ", answer.choice ?? "unavailable", "."] }), _jsx("thead", { children: _jsxs("tr", { children: [_jsx("th", { scope: "col", children: "Category" }), _jsx("th", { scope: "col", children: "Probability" }), _jsx("th", { scope: "col", children: "Exact value (0\u20131)" })] }) }), _jsx("tbody", { children: filtered
|
|
17
|
+
.slice(current * 20, (current + 1) * 20)
|
|
18
|
+
.map(([label, value]) => (_jsxs("tr", { children: [_jsxs("th", { scope: "row", children: [label, label === answer.choice && (_jsx("span", { className: "lab-choice-returned", children: "Returned choice" }))] }), _jsxs("td", { children: [Number((value * 100).toPrecision(6)), "%"] }), _jsx("td", { children: _jsx("code", { children: String(value) }) })] }, label))) })] }), !filtered.length && _jsx("p", { children: "No matching categories." })] }), _jsxs("div", { className: "lab-choice-pagination", children: [_jsx("button", { disabled: current === 0, onClick: () => setPage(current - 1), children: "Previous categories" }), _jsxs("span", { "aria-live": "polite", children: ["Page ", current + 1, " of ", pages] }), _jsx("button", { disabled: current + 1 >= pages, onClick: () => setPage(current + 1), children: "Next categories" })] })] }));
|
|
19
|
+
}
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
-
import { useEffect, useState } from "react";
|
|
2
|
+
import { memo, useCallback, useEffect, useRef, useState } from "react";
|
|
3
3
|
import { JevPanel } from "./JevDevtools.js";
|
|
4
4
|
import { JevDrawer } from "./JevDrawer.js";
|
|
5
|
+
import { connectTraceStream } from "./trace-stream.js";
|
|
6
|
+
import { connectFlowStream, emptyFlows, } from "./flow-stream.js";
|
|
7
|
+
const RetainedPanel = memo(JevPanel);
|
|
5
8
|
function endpointUrl(endpoint) {
|
|
6
9
|
const url = new URL(endpoint, window.location.origin);
|
|
7
10
|
if (url.origin !== window.location.origin ||
|
|
@@ -13,12 +16,39 @@ function endpointUrl(endpoint) {
|
|
|
13
16
|
return url.href.replace(/\/$/, "");
|
|
14
17
|
}
|
|
15
18
|
export default function ConnectedInspector({ endpoint = "/api/jev-devtools", defaultOpen = true, }) {
|
|
16
|
-
const [
|
|
19
|
+
const [open, setOpen] = useState(defaultOpen);
|
|
20
|
+
const [visited, setVisited] = useState(defaultOpen);
|
|
21
|
+
const visible = useRef(defaultOpen);
|
|
22
|
+
const latest = useRef({
|
|
23
|
+
traces: [],
|
|
24
|
+
connection: "connecting",
|
|
25
|
+
flows: emptyFlows,
|
|
26
|
+
});
|
|
27
|
+
const [snapshot, setSnapshot] = useState(latest.current);
|
|
28
|
+
const toggle = (value) => {
|
|
29
|
+
visible.current = value;
|
|
30
|
+
setOpen(value);
|
|
31
|
+
if (value) {
|
|
32
|
+
setVisited(true);
|
|
33
|
+
setSnapshot(latest.current);
|
|
34
|
+
}
|
|
35
|
+
};
|
|
17
36
|
useEffect(() => {
|
|
18
|
-
let alive = true,
|
|
37
|
+
let alive = true, disconnect = () => { }, disconnectFlows = () => { };
|
|
19
38
|
const abort = new AbortController();
|
|
20
|
-
|
|
21
|
-
|
|
39
|
+
// Keep capture current while closed without scheduling React work. Reopening
|
|
40
|
+
// publishes only the latest snapshot; the retained panel keeps its UI state.
|
|
41
|
+
const update = (patch) => {
|
|
42
|
+
const next = { ...latest.current, ...patch };
|
|
43
|
+
if (next.traces === latest.current.traces &&
|
|
44
|
+
next.connection === latest.current.connection &&
|
|
45
|
+
next.flows === latest.current.flows)
|
|
46
|
+
return;
|
|
47
|
+
latest.current = next;
|
|
48
|
+
if (visible.current)
|
|
49
|
+
setSnapshot(latest.current);
|
|
50
|
+
};
|
|
51
|
+
update({ traces: [], connection: "connecting", flows: emptyFlows });
|
|
22
52
|
void (async () => {
|
|
23
53
|
try {
|
|
24
54
|
const url = endpointUrl(endpoint);
|
|
@@ -35,44 +65,31 @@ export default function ConnectedInspector({ endpoint = "/api/jev-devtools", def
|
|
|
35
65
|
throw new Error("Unsupported inspector endpoint");
|
|
36
66
|
if (!alive)
|
|
37
67
|
return;
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
return;
|
|
42
|
-
try {
|
|
43
|
-
const data = JSON.parse(event.data);
|
|
44
|
-
if (data.version !== 1 || !Array.isArray(data.traces))
|
|
45
|
-
throw new Error();
|
|
46
|
-
setTraces(data.traces);
|
|
47
|
-
setConnection("connected");
|
|
48
|
-
}
|
|
49
|
-
catch {
|
|
50
|
-
setConnection("Invalid inspector update");
|
|
51
|
-
}
|
|
52
|
-
});
|
|
53
|
-
events.onerror = () => {
|
|
54
|
-
if (alive)
|
|
55
|
-
setConnection("reconnecting");
|
|
56
|
-
};
|
|
68
|
+
if (config.flows)
|
|
69
|
+
disconnectFlows = connectFlowStream(url, (flows) => update({ flows }));
|
|
70
|
+
disconnect = connectTraceStream(url, (traces) => update({ traces }), (connection) => update({ connection }));
|
|
57
71
|
}
|
|
58
72
|
catch (error) {
|
|
59
73
|
if (alive)
|
|
60
|
-
|
|
74
|
+
update({
|
|
75
|
+
connection: error instanceof Error ? error.message : "Inspector unavailable",
|
|
76
|
+
});
|
|
61
77
|
}
|
|
62
78
|
})();
|
|
63
79
|
return () => {
|
|
64
80
|
alive = false;
|
|
65
81
|
abort.abort();
|
|
66
|
-
|
|
82
|
+
disconnect();
|
|
83
|
+
disconnectFlows();
|
|
67
84
|
};
|
|
68
85
|
}, [endpoint]);
|
|
69
|
-
|
|
86
|
+
const clear = useCallback(async () => {
|
|
70
87
|
const response = await fetch(endpointUrl(endpoint) + "/clear", {
|
|
71
88
|
method: "DELETE",
|
|
72
89
|
credentials: "same-origin",
|
|
73
90
|
});
|
|
74
91
|
if (!response.ok)
|
|
75
92
|
throw new Error(`Cannot clear history (${response.status})`);
|
|
76
|
-
}
|
|
77
|
-
return (_jsx(JevDrawer, {
|
|
93
|
+
}, [endpoint]);
|
|
94
|
+
return (_jsx(JevDrawer, { open: open, onOpenChange: toggle, children: visited && _jsx(RetainedPanel, { ...snapshot, onClear: clear }) }));
|
|
78
95
|
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { Json } from "./protocol.js";
|
|
2
|
+
/** Bounded, lazy property rendering inside a fixed-size diagram card. */
|
|
3
|
+
export declare const DiagramContent: import("react").MemoExoticComponent<({ value, label, }: {
|
|
4
|
+
value: Json | undefined;
|
|
5
|
+
label: string;
|
|
6
|
+
}) => import("react").JSX.Element>;
|