jev-devtools 0.2.0 → 0.4.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.
Files changed (44) hide show
  1. package/README.md +50 -6
  2. package/lib/flow/index.d.ts +143 -0
  3. package/lib/flow/index.js +1029 -0
  4. package/lib/flow/runtime.d.ts +5 -0
  5. package/lib/flow/runtime.js +81 -0
  6. package/lib/server/flows.d.ts +28 -0
  7. package/lib/server/flows.js +187 -0
  8. package/lib/server/index.d.ts +14 -1
  9. package/lib/server/index.js +100 -1
  10. package/lib/server/recorder.d.ts +4 -1
  11. package/lib/server/recorder.js +19 -3
  12. package/lib/src/ChoiceProbabilities.js +1 -1
  13. package/lib/src/ConnectedInspector.js +13 -4
  14. package/lib/src/DiagramContent.d.ts +6 -0
  15. package/lib/src/DiagramContent.js +32 -0
  16. package/lib/src/FlowDetailsPanel.d.ts +8 -0
  17. package/lib/src/FlowDetailsPanel.js +79 -0
  18. package/lib/src/FlowOverview.d.ts +8 -0
  19. package/lib/src/FlowOverview.js +440 -0
  20. package/lib/src/JevDevtools.d.ts +6 -1
  21. package/lib/src/JevDevtools.js +18 -4
  22. package/lib/src/TraceDiagram.d.ts +4 -1
  23. package/lib/src/TraceDiagram.js +30 -12
  24. package/lib/src/condition-inputs.d.ts +9 -0
  25. package/lib/src/condition-inputs.js +109 -0
  26. package/lib/src/flow-condition.d.ts +4 -0
  27. package/lib/src/flow-condition.js +48 -0
  28. package/lib/src/flow-model.d.ts +7 -2
  29. package/lib/src/flow-model.js +2 -11
  30. package/lib/src/flow-protocol.d.ts +93 -0
  31. package/lib/src/flow-protocol.js +1 -0
  32. package/lib/src/flow-stream.d.ts +8 -0
  33. package/lib/src/flow-stream.js +80 -0
  34. package/lib/src/inspector-styles.js +1 -1
  35. package/lib/src/inspector.css +746 -8
  36. package/lib/src/protocol.d.ts +5 -0
  37. package/lib/src/useDiagramViewport.d.ts +5 -1
  38. package/lib/src/useDiagramViewport.js +31 -9
  39. package/package.json +11 -4
  40. package/skills/setup-jev-devtools/SKILL.md +8 -4
  41. package/skills/setup-jev-devtools/references/flows.md +227 -0
  42. package/skills/setup-jev-devtools/references/nextjs.md +3 -1
  43. package/skills/setup-jev-devtools/references/react-node.md +2 -2
  44. package/skills/setup-jev-devtools/references/verify.md +1 -1
@@ -0,0 +1,5 @@
1
+ import type { Json } from "../src/protocol.js";
2
+ /** Detached JSON snapshot, without capture truncation or lossy JSON.stringify. */
3
+ export declare function jsonSnapshot(value: unknown, seen?: Set<object>, depth?: number): Json;
4
+ /** Cancellation ends awaiting, even when user code ignores the cooperative signal. */
5
+ export declare function runCallback(callback: (signal: AbortSignal) => unknown, parent: AbortSignal, timeoutMs?: number): Promise<unknown>;
@@ -0,0 +1,81 @@
1
+ /** Detached JSON snapshot, without capture truncation or lossy JSON.stringify. */
2
+ export function jsonSnapshot(value, seen = new Set(), depth = 0) {
3
+ if (depth > 40)
4
+ throw new TypeError("Task data nesting exceeds 40");
5
+ if (value === null || typeof value === "string" || typeof value === "boolean")
6
+ return value;
7
+ if (typeof value === "number" && Number.isFinite(value))
8
+ return value;
9
+ if (!value ||
10
+ typeof value !== "object" ||
11
+ (!Array.isArray(value) &&
12
+ Object.getPrototypeOf(value) !== Object.prototype &&
13
+ Object.getPrototypeOf(value) !== null))
14
+ throw new TypeError("Task inputs and outputs must be JSON-compatible");
15
+ if (seen.has(value))
16
+ throw new TypeError("Task data cannot contain cycles");
17
+ if (Object.getOwnPropertySymbols(value).length)
18
+ throw new TypeError("Task data cannot contain symbol keys");
19
+ seen.add(value);
20
+ try {
21
+ if (Array.isArray(value))
22
+ return Array.from(value, (v) => jsonSnapshot(v, seen, depth + 1));
23
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [
24
+ k,
25
+ jsonSnapshot(v, seen, depth + 1),
26
+ ]));
27
+ }
28
+ finally {
29
+ seen.delete(value);
30
+ }
31
+ }
32
+ /** Cancellation ends awaiting, even when user code ignores the cooperative signal. */
33
+ export function runCallback(callback, parent, timeoutMs) {
34
+ const controller = new AbortController();
35
+ const started = performance.now();
36
+ return new Promise((resolve, reject) => {
37
+ let timer;
38
+ let settled = false;
39
+ const cleanup = () => {
40
+ clearTimeout(timer);
41
+ parent.removeEventListener("abort", cancel);
42
+ };
43
+ const finish = (error, value, failed = false) => {
44
+ if (settled)
45
+ return;
46
+ settled = true;
47
+ cleanup();
48
+ if (failed)
49
+ reject(error);
50
+ else
51
+ resolve(value);
52
+ };
53
+ const stop = (error) => {
54
+ controller.abort(error);
55
+ finish(error, undefined, true);
56
+ };
57
+ const cancel = () => stop(new DOMException("Flow cancelled", "AbortError"));
58
+ if (parent.aborted) {
59
+ cancel();
60
+ return;
61
+ }
62
+ parent.addEventListener("abort", cancel, { once: true });
63
+ if (timeoutMs !== undefined)
64
+ timer = setTimeout(() => stop(new DOMException("Task timed out", "TimeoutError")), timeoutMs);
65
+ Promise.resolve()
66
+ .then(() => {
67
+ if (controller.signal.aborted)
68
+ throw controller.signal.reason;
69
+ return callback(controller.signal);
70
+ })
71
+ .then((value) => {
72
+ // CPU-bound callbacks cannot be interrupted, but their late result must
73
+ // still not advance the flow after the configured deadline.
74
+ if (timeoutMs !== undefined &&
75
+ performance.now() - started >= timeoutMs)
76
+ stop(new DOMException("Task timed out", "TimeoutError"));
77
+ else
78
+ finish(undefined, value);
79
+ }, (error) => finish(error, undefined, true));
80
+ });
81
+ }
@@ -0,0 +1,28 @@
1
+ import type { FlowDefinition, FlowRun, FlowUpdate } from "../src/flow-protocol.js";
2
+ import { type CaptureOptions } from "./recorder.js";
3
+ /** Independent bounded run store. Definitions are retained only while referenced. */
4
+ export declare class FlowRecorder {
5
+ private options;
6
+ private runs;
7
+ private definitions;
8
+ private sessions;
9
+ private definitionRevisions;
10
+ private revisions;
11
+ private revision;
12
+ private listeners;
13
+ private itemCaptures;
14
+ private taskCaptures;
15
+ constructor(options: CaptureOptions);
16
+ subscribe(fn: (sessions: Set<string>) => void): () => void;
17
+ private notify;
18
+ start(definition: FlowDefinition, run: FlowRun, session: string): void;
19
+ private captureRun;
20
+ update(run: FlowRun): void;
21
+ private remove;
22
+ private prune;
23
+ clear(session: string): void;
24
+ changes(session: string, cursor?: number): {
25
+ revision: number;
26
+ data: FlowUpdate;
27
+ };
28
+ }
@@ -0,0 +1,187 @@
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
+ itemCaptures = new WeakMap();
13
+ taskCaptures = new WeakMap();
14
+ constructor(options) {
15
+ this.options = options;
16
+ }
17
+ subscribe(fn) {
18
+ this.listeners.add(fn);
19
+ return () => {
20
+ this.listeners.delete(fn);
21
+ };
22
+ }
23
+ notify(sessions) {
24
+ for (const fn of this.listeners) {
25
+ try {
26
+ fn(sessions);
27
+ }
28
+ catch { }
29
+ }
30
+ }
31
+ start(definition, run, session) {
32
+ if (!this.definitions.has(definition.id)) {
33
+ const stored = structuredClone(definition);
34
+ const redactRules = (definition, prefix = []) => {
35
+ for (const node of definition.nodes) {
36
+ if (node.itemDefinition)
37
+ redactRules(node.itemDefinition, [...prefix, node.id, "items"]);
38
+ if (node.conditionRule === undefined)
39
+ continue;
40
+ try {
41
+ node.conditionRule = capture(node.conditionRule, new Set(), this.options.redact
42
+ ? (path, value) => this.options.redact(["conditions", ...prefix, node.id, ...path], value)
43
+ : undefined);
44
+ }
45
+ catch {
46
+ node.conditionRule = "[rule capture unavailable]";
47
+ }
48
+ }
49
+ };
50
+ redactRules(stored);
51
+ this.definitions.set(definition.id, stored);
52
+ this.definitionRevisions.set(definition.id, ++this.revision);
53
+ }
54
+ this.runs.set(run.id, run);
55
+ this.sessions.set(run.id, session);
56
+ this.revisions.set(run.id, ++this.revision);
57
+ const sessions = new Set([session]);
58
+ const limit = Math.max(1, Math.min(200, this.options.limit ?? 50));
59
+ while (this.runs.size > limit) {
60
+ const id = this.runs.keys().next().value;
61
+ sessions.add(this.sessions.get(id));
62
+ this.remove(id);
63
+ }
64
+ this.prune();
65
+ this.notify(sessions);
66
+ }
67
+ captureRun(run, prefix = []) {
68
+ // Child updates are immutable snapshots; unchanged items need no recapture.
69
+ const previous = prefix.length ? this.itemCaptures.get(run) : undefined;
70
+ if (previous)
71
+ return previous;
72
+ const stored = {
73
+ ...run,
74
+ steps: Object.fromEntries(Object.entries(run.steps).map(([id, raw]) => {
75
+ const step = { ...raw };
76
+ if (raw.collection)
77
+ step.collection = {
78
+ ...raw.collection,
79
+ items: raw.collection.items.map((item) => ({
80
+ ...item,
81
+ ...(item.run
82
+ ? {
83
+ run: this.captureRun(item.run, [
84
+ ...prefix,
85
+ "steps",
86
+ id,
87
+ "items",
88
+ String(item.index),
89
+ ]),
90
+ }
91
+ : {}),
92
+ })),
93
+ };
94
+ if (step.data === undefined)
95
+ return [id, { ...step }];
96
+ const key = step.data;
97
+ let captured = key && typeof key === "object"
98
+ ? this.taskCaptures.get(key)
99
+ : undefined;
100
+ if (!captured) {
101
+ const warnings = new Set();
102
+ let data;
103
+ try {
104
+ data =
105
+ capture(step.data, warnings, this.options.redact
106
+ ? (path, value) => this.options.redact([...prefix, "steps", id, "data", ...path], value)
107
+ : undefined) ?? null;
108
+ }
109
+ catch {
110
+ data = "[capture unavailable]";
111
+ warnings.add("Task capture unavailable.");
112
+ }
113
+ captured = { data, warnings: [...warnings] };
114
+ if (key && typeof key === "object")
115
+ this.taskCaptures.set(key, captured);
116
+ }
117
+ return [
118
+ id,
119
+ {
120
+ ...step,
121
+ data: captured.data,
122
+ captureWarnings: captured.warnings,
123
+ },
124
+ ];
125
+ })),
126
+ };
127
+ if ("value" in stored) {
128
+ try {
129
+ stored.value = capture(stored.value, new Set(), this.options.redact
130
+ ? (path, value) => this.options.redact([...prefix, ...path], value)
131
+ : undefined);
132
+ }
133
+ catch {
134
+ stored.value = "[capture unavailable]";
135
+ }
136
+ }
137
+ if (prefix.length)
138
+ this.itemCaptures.set(run, stored);
139
+ return stored;
140
+ }
141
+ update(run) {
142
+ if (!this.runs.has(run.id))
143
+ return; // Clear/eviction cannot resurrect pending runs.
144
+ this.runs.set(run.id, this.captureRun(run));
145
+ this.revisions.set(run.id, ++this.revision);
146
+ this.notify(new Set([this.sessions.get(run.id)]));
147
+ }
148
+ remove(id) {
149
+ this.runs.delete(id);
150
+ this.sessions.delete(id);
151
+ this.revisions.delete(id);
152
+ }
153
+ prune() {
154
+ const used = new Set([...this.runs.values()].map((r) => r.definitionId));
155
+ for (const id of this.definitions.keys())
156
+ if (!used.has(id)) {
157
+ this.definitions.delete(id);
158
+ this.definitionRevisions.delete(id);
159
+ }
160
+ }
161
+ clear(session) {
162
+ for (const id of this.runs.keys())
163
+ if (this.sessions.get(id) === session)
164
+ this.remove(id);
165
+ this.prune();
166
+ this.revision++;
167
+ this.notify(new Set([session]));
168
+ }
169
+ changes(session, cursor) {
170
+ const current = [...this.runs.values()]
171
+ .filter((r) => this.sessions.get(r.id) === session)
172
+ .reverse();
173
+ const used = new Set(current.map((r) => r.definitionId));
174
+ return {
175
+ revision: this.revision,
176
+ data: structuredClone({
177
+ version: 1,
178
+ definitions: [...used]
179
+ .filter((id) => cursor === undefined ||
180
+ this.definitionRevisions.get(id) > cursor)
181
+ .map((id) => this.definitions.get(id)),
182
+ upserts: current.filter((r) => cursor === undefined || this.revisions.get(r.id) > cursor),
183
+ order: current.map((r) => r.id),
184
+ }),
185
+ };
186
+ }
187
+ }
@@ -8,7 +8,10 @@ export type JevDevtoolsServerOptions = CaptureOptions & {
8
8
  enabled?: boolean;
9
9
  origin: string;
10
10
  endpoint?: string;
11
- /** Required for non-loopback origins. Apply your application's authentication. */
11
+ /**
12
+ * Required for non-loopback origins. Without it, Node middleware only accepts
13
+ * loopback connections. Apply your application's authentication.
14
+ */
12
15
  authorize?: (request: Request) => boolean | Promise<boolean>;
13
16
  /** Server-derived scope. Defaults to a shared local-development history. */
14
17
  getSession?: (request: Request) => string | Promise<string>;
@@ -19,11 +22,21 @@ export type ClientMetadata = {
19
22
  };
20
23
  /** Node runtime adapter: framework-neutral Request/Response plus Node middleware. */
21
24
  export declare function createJevDevtools(options: JevDevtoolsServerOptions): {
25
+ startFlow: (definition: import("../src/flow-protocol.js").FlowDefinition, run: import("../src/flow-protocol.js").FlowRun) => {
26
+ update(run: import("../src/flow-protocol.js").FlowRun): void;
27
+ request<T>(link: {
28
+ runId: string;
29
+ stepId: string;
30
+ executionId: string;
31
+ }, onTrace: (id: string) => void, callback: () => T): T;
32
+ } | undefined;
22
33
  handle: (request: Request) => Promise<Response>;
23
34
  /**
24
35
  * For Node framework routes whose Request URL uses an internal hostname.
25
36
  * Require the actual Host header to match the configured public authority;
26
37
  * never trust forwarded host/protocol headers. Origin checks still apply.
38
+ * A Request does not expose the socket peer and any non-browser client can
39
+ * set Host, so bind the server to loopback or set authorize.
27
40
  */
28
41
  handleNodeRoute(request: Request): Promise<Response>;
29
42
  middleware: (req: IncomingMessage, res: ServerResponse, next: () => void) => Promise<void>;
@@ -1,9 +1,12 @@
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";
4
5
  import { TypeSafeClient } from "@typesafe-ai/sdk";
5
6
  import { JevRecorder } from "./recorder.js";
6
7
  export { JevRecorder } from "./recorder.js";
8
+ // Host and Origin are client-controlled, so locality is decided by the socket peer.
9
+ const isLoopback = (address = "") => address === "::1" || /^(::ffff:)?127\./.test(address);
7
10
  const json = (data, status = 200) => Response.json(data, { status, headers: { "cache-control": "no-store" } });
8
11
  /** Node runtime adapter: framework-neutral Request/Response plus Node middleware. */
9
12
  export function createJevDevtools(options) {
@@ -17,6 +20,7 @@ export function createJevDevtools(options) {
17
20
  if (options.enabled && !local && !options.authorize)
18
21
  throw new Error("Non-loopback inspector endpoints require authorize");
19
22
  const recorder = new JevRecorder(options), scope = new AsyncLocalStorage();
23
+ const flows = new FlowRecorder(options);
20
24
  const streams = new Set();
21
25
  const pending = new Set();
22
26
  const encoder = new TextEncoder();
@@ -91,15 +95,84 @@ export function createJevDevtools(options) {
91
95
  return json({ error: "Unauthorized" }, 403);
92
96
  const session = await sessionFor(request);
93
97
  if (url.pathname === endpoint && request.method === "GET")
94
- return json({ version: 1, enabled: true });
98
+ return json({ version: 1, enabled: true, flows: true });
95
99
  if (url.pathname === endpoint + "/clear") {
96
100
  if (request.method !== "DELETE")
97
101
  return json({ error: "Method not allowed" }, 405);
98
102
  if (request.headers.get("origin") !== origin)
99
103
  return json({ error: "Same-origin requests required" }, 403);
100
104
  recorder.clear(session);
105
+ flows.clear(session);
101
106
  return json({ ok: true });
102
107
  }
108
+ let flowPull = () => { };
109
+ if (url.pathname === endpoint + "/flows/events") {
110
+ if (request.method !== "GET")
111
+ return json({ error: "Method not allowed" }, 405);
112
+ let stop = () => { };
113
+ const body = new ReadableStream({
114
+ start(controller) {
115
+ let closed = false, dirty = true, cursor, timer;
116
+ const flush = () => {
117
+ timer = undefined;
118
+ if (closed || !dirty || (controller.desiredSize ?? 0) <= 0)
119
+ return;
120
+ dirty = false;
121
+ const update = flows.changes(session, cursor);
122
+ cursor = update.revision;
123
+ controller.enqueue(encoder.encode(`event: flows\ndata: ${JSON.stringify(update.data)}\n\n`));
124
+ };
125
+ const wake = () => {
126
+ if (!closed && dirty && !timer)
127
+ timer = setImmediate(flush);
128
+ };
129
+ const unsubscribe = flows.subscribe((sessions) => {
130
+ if (sessions.has(session)) {
131
+ dirty = true;
132
+ wake();
133
+ }
134
+ });
135
+ const heartbeat = setInterval(() => {
136
+ if (!closed && (controller.desiredSize ?? 0) > 0)
137
+ controller.enqueue(encoder.encode(": keepalive\n\n"));
138
+ }, 15000);
139
+ stop = () => {
140
+ if (closed)
141
+ return;
142
+ closed = true;
143
+ unsubscribe();
144
+ clearImmediate(timer);
145
+ clearInterval(heartbeat);
146
+ request.signal.removeEventListener("abort", stop);
147
+ streams.delete(stop);
148
+ try {
149
+ controller.close();
150
+ }
151
+ catch { }
152
+ };
153
+ streams.add(stop);
154
+ request.signal.addEventListener("abort", stop, { once: true });
155
+ flowPull = wake;
156
+ if (request.signal.aborted)
157
+ stop();
158
+ else
159
+ flush();
160
+ },
161
+ pull() {
162
+ flowPull();
163
+ },
164
+ cancel() {
165
+ stop();
166
+ },
167
+ });
168
+ return new Response(body, {
169
+ headers: {
170
+ "content-type": "text/event-stream",
171
+ "cache-control": "no-store",
172
+ "x-accel-buffering": "no",
173
+ },
174
+ });
175
+ }
103
176
  if (url.pathname !== endpoint + "/events")
104
177
  return json({ error: "Not found" }, 404);
105
178
  if (request.method !== "GET")
@@ -185,6 +258,17 @@ export function createJevDevtools(options) {
185
258
  next();
186
259
  return;
187
260
  }
261
+ if (enabled() &&
262
+ !options.authorize &&
263
+ !isLoopback(req.socket.remoteAddress)) {
264
+ res
265
+ .writeHead(403, {
266
+ "content-type": "application/json",
267
+ "cache-control": "no-store",
268
+ })
269
+ .end('{"error":"Loopback connections required without authorize"}');
270
+ return;
271
+ }
188
272
  const abort = new AbortController();
189
273
  res.once("close", () => abort.abort());
190
274
  try {
@@ -209,12 +293,27 @@ export function createJevDevtools(options) {
209
293
  res.destroy();
210
294
  }
211
295
  }
296
+ const startFlow = (definition, run) => {
297
+ if (!enabled())
298
+ return;
299
+ flows.start(definition, run, scope.getStore() ?? "local");
300
+ return {
301
+ update: (next) => {
302
+ if (enabled())
303
+ flows.update(next);
304
+ },
305
+ request: (link, onTrace, callback) => recorder.withFlow(link, onTrace, callback),
306
+ };
307
+ };
212
308
  return {
309
+ startFlow,
213
310
  handle,
214
311
  /**
215
312
  * For Node framework routes whose Request URL uses an internal hostname.
216
313
  * Require the actual Host header to match the configured public authority;
217
314
  * never trust forwarded host/protocol headers. Origin checks still apply.
315
+ * A Request does not expose the socket peer and any non-browser client can
316
+ * set Host, so bind the server to loopback or set authorize.
218
317
  */
219
318
  handleNodeRoute(request) {
220
319
  if (!enabled())
@@ -1,10 +1,11 @@
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;
@@ -12,6 +13,8 @@ export declare class JevRecorder {
12
13
  private revision;
13
14
  private revisions;
14
15
  private context;
16
+ private flowContext;
17
+ withFlow<T>(link: NonNullable<Trace["flow"]>, onTrace: (id: string) => void, callback: () => T): T;
15
18
  private limit;
16
19
  constructor(options?: CaptureOptions);
17
20
  snapshot(session: string): Trace[];
@@ -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) => {
@@ -67,10 +67,15 @@ function capture(value, warnings, redact) {
67
67
  .map((v, i) => visit(v, [...path, String(i)], depth + 1));
68
68
  }
69
69
  const entries = Object.entries(input);
70
- if (entries.length > 150)
70
+ // Choice maps need room for the full roster; retain the overall byte/depth
71
+ // budget and the smaller general-purpose object/array limit.
72
+ const entryLimit = ["criteria", "probabilities"].includes(path.at(-1) ?? "")
73
+ ? 256
74
+ : 150;
75
+ if (entries.length > entryLimit)
71
76
  warnings.add("Long collection truncated in capture.");
72
77
  return Object.fromEntries(entries
73
- .slice(0, 150)
78
+ .slice(0, entryLimit)
74
79
  .map(([key, v]) => [key, visit(v, [...path, key], depth + 1)]));
75
80
  };
76
81
  return visit(value, [], 0);
@@ -82,6 +87,10 @@ export class JevRecorder {
82
87
  revision = 0;
83
88
  revisions = new WeakMap();
84
89
  context = new AsyncLocalStorage();
90
+ flowContext = new AsyncLocalStorage();
91
+ withFlow(link, onTrace, callback) {
92
+ return this.flowContext.run({ link, onTrace }, callback);
93
+ }
85
94
  limit;
86
95
  constructor(options = {}) {
87
96
  this.options = options;
@@ -196,6 +205,7 @@ export class JevRecorder {
196
205
  version: 1,
197
206
  id,
198
207
  ...(typeof meta === "function" ? meta() : meta),
208
+ flow: this.flowContext.getStore()?.link,
199
209
  startedAt: Date.now(),
200
210
  status: "pending",
201
211
  request: captured,
@@ -203,6 +213,12 @@ export class JevRecorder {
203
213
  captureWarnings: [...warnings],
204
214
  });
205
215
  const trace = this.traces.get(id);
216
+ try {
217
+ this.flowContext.getStore()?.onTrace(id);
218
+ }
219
+ catch {
220
+ /* Observer only. */
221
+ }
206
222
  this.revisions.set(trace, ++this.revision);
207
223
  const sessions = new Set([trace.session]);
208
224
  while (this.traces.size > this.limit) {
@@ -10,7 +10,7 @@ export function ChoiceProbabilities({ answer }) {
10
10
  }, [rows, search]);
11
11
  const pages = Math.max(1, Math.ceil(filtered.length / 20));
12
12
  const current = Math.min(page, pages - 1);
13
- return (_jsxs("div", { className: "lab-choice-browser", role: "region", "aria-label": "All Choice probabilities", children: [_jsxs("div", { className: "lab-choice-controls", children: [_jsxs("label", { children: ["Search categories", _jsx("input", { autoFocus: true, type: "search", value: search, onChange: (event) => {
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
14
  setSearch(event.target.value);
15
15
  setPage(0);
16
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
@@ -3,6 +3,7 @@ import { memo, useCallback, useEffect, useRef, useState } from "react";
3
3
  import { JevPanel } from "./JevDevtools.js";
4
4
  import { JevDrawer } from "./JevDrawer.js";
5
5
  import { connectTraceStream } from "./trace-stream.js";
6
+ import { connectFlowStream, emptyFlows, } from "./flow-stream.js";
6
7
  const RetainedPanel = memo(JevPanel);
7
8
  function endpointUrl(endpoint) {
8
9
  const url = new URL(endpoint, window.location.origin);
@@ -18,7 +19,11 @@ export default function ConnectedInspector({ endpoint = "/api/jev-devtools", def
18
19
  const [open, setOpen] = useState(defaultOpen);
19
20
  const [visited, setVisited] = useState(defaultOpen);
20
21
  const visible = useRef(defaultOpen);
21
- const latest = useRef({ traces: [], connection: "connecting" });
22
+ const latest = useRef({
23
+ traces: [],
24
+ connection: "connecting",
25
+ flows: emptyFlows,
26
+ });
22
27
  const [snapshot, setSnapshot] = useState(latest.current);
23
28
  const toggle = (value) => {
24
29
  visible.current = value;
@@ -29,20 +34,21 @@ export default function ConnectedInspector({ endpoint = "/api/jev-devtools", def
29
34
  }
30
35
  };
31
36
  useEffect(() => {
32
- let alive = true, disconnect = () => { };
37
+ let alive = true, disconnect = () => { }, disconnectFlows = () => { };
33
38
  const abort = new AbortController();
34
39
  // Keep capture current while closed without scheduling React work. Reopening
35
40
  // publishes only the latest snapshot; the retained panel keeps its UI state.
36
41
  const update = (patch) => {
37
42
  const next = { ...latest.current, ...patch };
38
43
  if (next.traces === latest.current.traces &&
39
- next.connection === latest.current.connection)
44
+ next.connection === latest.current.connection &&
45
+ next.flows === latest.current.flows)
40
46
  return;
41
47
  latest.current = next;
42
48
  if (visible.current)
43
49
  setSnapshot(latest.current);
44
50
  };
45
- update({ traces: [], connection: "connecting" });
51
+ update({ traces: [], connection: "connecting", flows: emptyFlows });
46
52
  void (async () => {
47
53
  try {
48
54
  const url = endpointUrl(endpoint);
@@ -59,6 +65,8 @@ export default function ConnectedInspector({ endpoint = "/api/jev-devtools", def
59
65
  throw new Error("Unsupported inspector endpoint");
60
66
  if (!alive)
61
67
  return;
68
+ if (config.flows)
69
+ disconnectFlows = connectFlowStream(url, (flows) => update({ flows }));
62
70
  disconnect = connectTraceStream(url, (traces) => update({ traces }), (connection) => update({ connection }));
63
71
  }
64
72
  catch (error) {
@@ -72,6 +80,7 @@ export default function ConnectedInspector({ endpoint = "/api/jev-devtools", def
72
80
  alive = false;
73
81
  abort.abort();
74
82
  disconnect();
83
+ disconnectFlows();
75
84
  };
76
85
  }, [endpoint]);
77
86
  const clear = useCallback(async () => {
@@ -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>;