jev-devtools 0.2.0 → 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.
Files changed (39) hide show
  1. package/README.md +47 -3
  2. package/lib/flow/index.d.ts +82 -0
  3. package/lib/flow/index.js +588 -0
  4. package/lib/server/flows.d.ts +25 -0
  5. package/lib/server/flows.js +115 -0
  6. package/lib/server/index.d.ts +8 -0
  7. package/lib/server/index.js +85 -1
  8. package/lib/server/recorder.d.ts +4 -1
  9. package/lib/server/recorder.js +12 -1
  10. package/lib/src/ChoiceProbabilities.js +1 -1
  11. package/lib/src/ConnectedInspector.js +13 -4
  12. package/lib/src/DiagramContent.d.ts +6 -0
  13. package/lib/src/DiagramContent.js +32 -0
  14. package/lib/src/FlowOverview.d.ts +7 -0
  15. package/lib/src/FlowOverview.js +228 -0
  16. package/lib/src/JevDevtools.d.ts +6 -1
  17. package/lib/src/JevDevtools.js +18 -4
  18. package/lib/src/TraceDiagram.d.ts +4 -1
  19. package/lib/src/TraceDiagram.js +30 -12
  20. package/lib/src/condition-inputs.d.ts +9 -0
  21. package/lib/src/condition-inputs.js +100 -0
  22. package/lib/src/flow-condition.d.ts +4 -0
  23. package/lib/src/flow-condition.js +47 -0
  24. package/lib/src/flow-model.d.ts +7 -2
  25. package/lib/src/flow-model.js +2 -11
  26. package/lib/src/flow-protocol.d.ts +68 -0
  27. package/lib/src/flow-protocol.js +1 -0
  28. package/lib/src/flow-stream.d.ts +8 -0
  29. package/lib/src/flow-stream.js +80 -0
  30. package/lib/src/inspector-styles.js +1 -1
  31. package/lib/src/inspector.css +501 -8
  32. package/lib/src/protocol.d.ts +5 -0
  33. package/lib/src/useDiagramViewport.d.ts +4 -0
  34. package/lib/src/useDiagramViewport.js +20 -3
  35. package/package.json +6 -2
  36. package/skills/setup-jev-devtools/SKILL.md +6 -2
  37. package/skills/setup-jev-devtools/references/flows.md +150 -0
  38. package/skills/setup-jev-devtools/references/nextjs.md +1 -1
  39. package/skills/setup-jev-devtools/references/verify.md +1 -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
+ }
@@ -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.
@@ -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,6 +18,7 @@ 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();
21
23
  const pending = new Set();
22
24
  const encoder = new TextEncoder();
@@ -91,15 +93,84 @@ export function createJevDevtools(options) {
91
93
  return json({ error: "Unauthorized" }, 403);
92
94
  const session = await sessionFor(request);
93
95
  if (url.pathname === endpoint && request.method === "GET")
94
- return json({ version: 1, enabled: true });
96
+ return json({ version: 1, enabled: true, flows: true });
95
97
  if (url.pathname === endpoint + "/clear") {
96
98
  if (request.method !== "DELETE")
97
99
  return json({ error: "Method not allowed" }, 405);
98
100
  if (request.headers.get("origin") !== origin)
99
101
  return json({ error: "Same-origin requests required" }, 403);
100
102
  recorder.clear(session);
103
+ flows.clear(session);
101
104
  return json({ ok: true });
102
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
+ }
103
174
  if (url.pathname !== endpoint + "/events")
104
175
  return json({ error: "Not found" }, 404);
105
176
  if (request.method !== "GET")
@@ -209,7 +280,20 @@ export function createJevDevtools(options) {
209
280
  res.destroy();
210
281
  }
211
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
+ };
212
295
  return {
296
+ startFlow,
213
297
  handle,
214
298
  /**
215
299
  * For Node framework routes whose Request URL uses an internal hostname.
@@ -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) => {
@@ -82,6 +82,10 @@ export class JevRecorder {
82
82
  revision = 0;
83
83
  revisions = new WeakMap();
84
84
  context = new AsyncLocalStorage();
85
+ flowContext = new AsyncLocalStorage();
86
+ withFlow(link, onTrace, callback) {
87
+ return this.flowContext.run({ link, onTrace }, callback);
88
+ }
85
89
  limit;
86
90
  constructor(options = {}) {
87
91
  this.options = options;
@@ -196,6 +200,7 @@ export class JevRecorder {
196
200
  version: 1,
197
201
  id,
198
202
  ...(typeof meta === "function" ? meta() : meta),
203
+ flow: this.flowContext.getStore()?.link,
199
204
  startedAt: Date.now(),
200
205
  status: "pending",
201
206
  request: captured,
@@ -203,6 +208,12 @@ export class JevRecorder {
203
208
  captureWarnings: [...warnings],
204
209
  });
205
210
  const trace = this.traces.get(id);
211
+ try {
212
+ this.flowContext.getStore()?.onTrace(id);
213
+ }
214
+ catch {
215
+ /* Observer only. */
216
+ }
206
217
  this.revisions.set(trace, ++this.revision);
207
218
  const sessions = new Set([trace.session]);
208
219
  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>;
@@ -0,0 +1,32 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { memo, useMemo, useState } from "react";
3
+ const PAGE_SIZE = 12;
4
+ function TextValue({ value }) {
5
+ const [expanded, setExpanded] = useState(false);
6
+ const long = value.length > 140 || value.split("\n").length > 3;
7
+ return (_jsxs("span", { className: "diagram-text-value", children: [_jsx("span", { className: long && !expanded ? "diagram-text-preview" : undefined, children: value === "" ? (_jsx("span", { className: "diagram-value-empty", children: "empty string" })) : long && !expanded ? (`${value.slice(0, 140)}${value.length > 140 ? "…" : ""}`) : (value) }), long && (_jsx("button", { type: "button", className: "diagram-content-toggle", "aria-expanded": expanded, onClick: () => setExpanded(!expanded), children: expanded ? "Less text" : "Full text" }))] }));
8
+ }
9
+ function Properties({ value }) {
10
+ const [visible, setVisible] = useState(PAGE_SIZE);
11
+ const entries = useMemo(() => Object.entries(value), [value]);
12
+ if (!entries.length)
13
+ return (_jsx("span", { className: "diagram-value-empty", children: Array.isArray(value) ? "[] Empty array" : "{} Empty object" }));
14
+ return (_jsxs("div", { className: "diagram-properties", children: [entries.slice(0, visible).map(([name, child]) => (_jsx(Property, { name: name, value: child }, name))), visible < entries.length && (_jsxs("button", { type: "button", className: "diagram-content-toggle", onClick: () => setVisible((count) => count + PAGE_SIZE), children: ["Show ", Math.min(PAGE_SIZE, entries.length - visible), " more (", entries.length - visible, " remaining)"] })), visible > PAGE_SIZE && (_jsx("button", { type: "button", className: "diagram-content-toggle", onClick: () => setVisible(PAGE_SIZE), children: "Fewer properties" }))] }));
15
+ }
16
+ function Scalar({ value }) {
17
+ if (typeof value === "string")
18
+ return _jsx(TextValue, { value: value });
19
+ return (_jsx("span", { className: `diagram-value-${value === null || value === undefined ? "empty" : typeof value}`, children: value === undefined ? "Not captured" : String(value) }));
20
+ }
21
+ function Property({ name, value }) {
22
+ const [expanded, setExpanded] = useState(false);
23
+ const object = value !== null && typeof value === "object";
24
+ if (!object)
25
+ return (_jsxs("div", { className: `diagram-property ${typeof value === "string" ? "diagram-property-text" : ""}`, children: [_jsx("span", { className: "diagram-property-key", children: name || '""' }), _jsx(Scalar, { value: value })] }));
26
+ const count = Object.keys(value).length;
27
+ return (_jsxs("div", { className: "diagram-property-group", children: [_jsxs("button", { type: "button", className: "diagram-property-disclosure", "aria-label": `${expanded ? "Collapse" : "Expand"} ${name || '""'}`, "aria-expanded": expanded, onClick: () => setExpanded(!expanded), children: [_jsx("span", { "aria-hidden": "true", className: "diagram-property-caret", children: expanded ? "▾" : "▸" }), _jsx("span", { className: "diagram-property-key", children: name || '""' }), _jsx("span", { className: "diagram-property-type", children: Array.isArray(value) ? `[${count}]` : `{${count}}` })] }), expanded && (_jsx("div", { className: "diagram-property-children", children: _jsx(Properties, { value: value }) }))] }));
28
+ }
29
+ /** Bounded, lazy property rendering inside a fixed-size diagram card. */
30
+ export const DiagramContent = memo(function DiagramContent({ value, label, }) {
31
+ return (_jsx("div", { className: "diagram-content", "data-diagram-scroll": true, role: "region", "aria-label": label, tabIndex: 0, children: value !== null && typeof value === "object" ? (_jsx(Properties, { value: value })) : (_jsx(Scalar, { value: value })) }));
32
+ });
@@ -0,0 +1,7 @@
1
+ import type { FlowDefinition, FlowRun } from "./flow-protocol.js";
2
+ import type { Trace } from "./protocol.js";
3
+ export declare const FlowOverview: import("react").MemoExoticComponent<({ definition, run, traces, }: {
4
+ definition: FlowDefinition;
5
+ run: FlowRun;
6
+ traces: Trace[];
7
+ }) => import("react").JSX.Element>;