jev-devtools 0.3.0 → 0.4.1

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.
@@ -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
+ }
@@ -10,10 +10,13 @@ export declare class FlowRecorder {
10
10
  private revisions;
11
11
  private revision;
12
12
  private listeners;
13
+ private itemCaptures;
14
+ private taskCaptures;
13
15
  constructor(options: CaptureOptions);
14
16
  subscribe(fn: (sessions: Set<string>) => void): () => void;
15
17
  private notify;
16
18
  start(definition: FlowDefinition, run: FlowRun, session: string): void;
19
+ private captureRun;
17
20
  update(run: FlowRun): void;
18
21
  private remove;
19
22
  private prune;
@@ -9,6 +9,8 @@ export class FlowRecorder {
9
9
  revisions = new Map();
10
10
  revision = 0;
11
11
  listeners = new Set();
12
+ itemCaptures = new WeakMap();
13
+ taskCaptures = new WeakMap();
12
14
  constructor(options) {
13
15
  this.options = options;
14
16
  }
@@ -29,18 +31,23 @@ export class FlowRecorder {
29
31
  start(definition, run, session) {
30
32
  if (!this.definitions.has(definition.id)) {
31
33
  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);
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
+ }
39
48
  }
40
- catch {
41
- node.conditionRule = "[rule capture unavailable]";
42
- }
43
- }
49
+ };
50
+ redactRules(stored);
44
51
  this.definitions.set(definition.id, stored);
45
52
  this.definitionRevisions.set(definition.id, ++this.revision);
46
53
  }
@@ -57,19 +64,84 @@ export class FlowRecorder {
57
64
  this.prune();
58
65
  this.notify(sessions);
59
66
  }
60
- update(run) {
61
- if (!this.runs.has(run.id))
62
- return; // Clear/eviction cannot resurrect pending runs.
63
- const stored = { ...run };
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
+ };
64
127
  if ("value" in stored) {
65
128
  try {
66
- stored.value = capture(stored.value, new Set(), this.options.redact);
129
+ stored.value = capture(stored.value, new Set(), this.options.redact
130
+ ? (path, value) => this.options.redact([...prefix, ...path], value)
131
+ : undefined);
67
132
  }
68
133
  catch {
69
134
  stored.value = "[capture unavailable]";
70
135
  }
71
136
  }
72
- this.runs.set(run.id, stored);
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));
73
145
  this.revisions.set(run.id, ++this.revision);
74
146
  this.notify(new Set([this.sessions.get(run.id)]));
75
147
  }
@@ -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>;
@@ -32,6 +35,8 @@ export declare function createJevDevtools(options: JevDevtoolsServerOptions): {
32
35
  * For Node framework routes whose Request URL uses an internal hostname.
33
36
  * Require the actual Host header to match the configured public authority;
34
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.
35
40
  */
36
41
  handleNodeRoute(request: Request): Promise<Response>;
37
42
  middleware: (req: IncomingMessage, res: ServerResponse, next: () => void) => Promise<void>;
@@ -5,6 +5,8 @@ import { pipeline } from "node:stream/promises";
5
5
  import { TypeSafeClient } from "@typesafe-ai/sdk";
6
6
  import { JevRecorder } from "./recorder.js";
7
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);
8
10
  const json = (data, status = 200) => Response.json(data, { status, headers: { "cache-control": "no-store" } });
9
11
  /** Node runtime adapter: framework-neutral Request/Response plus Node middleware. */
10
12
  export function createJevDevtools(options) {
@@ -256,6 +258,17 @@ export function createJevDevtools(options) {
256
258
  next();
257
259
  return;
258
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
+ }
259
272
  const abort = new AbortController();
260
273
  res.once("close", () => abort.abort());
261
274
  try {
@@ -299,6 +312,8 @@ export function createJevDevtools(options) {
299
312
  * For Node framework routes whose Request URL uses an internal hostname.
300
313
  * Require the actual Host header to match the configured public authority;
301
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.
302
317
  */
303
318
  handleNodeRoute(request) {
304
319
  if (!enabled())
@@ -67,10 +67,15 @@ export 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);
@@ -0,0 +1,8 @@
1
+ import { type ReactNode } from "react";
2
+ /** Keep metadata out of the canvas until requested; size it within the flow workspace. */
3
+ export declare function FlowDetailsPanel({ children, selection, onLayoutChange, inline, }: {
4
+ children: ReactNode;
5
+ selection?: string;
6
+ onLayoutChange: () => void;
7
+ inline?: boolean;
8
+ }): import("react").JSX.Element;
@@ -0,0 +1,79 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useId, useRef, useState } from "react";
3
+ /** Keep metadata out of the canvas until requested; size it within the flow workspace. */
4
+ export function FlowDetailsPanel({ children, selection, onLayoutChange, inline = false, }) {
5
+ const id = useId();
6
+ const panel = useRef(null);
7
+ const drag = useRef(null);
8
+ const [open, setOpen] = useState(false);
9
+ const [height, setHeight] = useState(240);
10
+ const [max, setMax] = useState(0);
11
+ const min = Math.min(96, max);
12
+ const visibleHeight = Math.max(min, Math.min(max, height));
13
+ useEffect(() => {
14
+ const workspace = panel.current?.parentElement;
15
+ if (!workspace)
16
+ return;
17
+ const measure = () => {
18
+ // Inline panels grow the canvas instead of taking space from a fixed viewport.
19
+ // Exclude the panel itself so resizing cannot grow its own upper bound.
20
+ const available = workspace.clientHeight -
21
+ (inline ? panel.current?.offsetHeight ?? 0 : 0);
22
+ // Leave room for the bar/divider and a usable canvas, including short drawers.
23
+ setMax(Math.max(0, available - Math.min(140, available * 0.45) - 44));
24
+ };
25
+ measure();
26
+ const observer = new ResizeObserver(measure);
27
+ observer.observe(workspace);
28
+ return () => observer.disconnect();
29
+ }, [inline]);
30
+ const resize = (next) => {
31
+ onLayoutChange();
32
+ setHeight(Math.max(min, Math.min(max, next)));
33
+ };
34
+ return (_jsxs("div", { className: "flow-details-panel", ref: panel, "data-diagram-scroll": true, children: [open && (_jsx("div", { className: "flow-details-resize", role: "separator", tabIndex: 0, "aria-label": "Resize flow details", "aria-orientation": "horizontal", "aria-controls": id, "aria-valuemin": Math.round(min), "aria-valuemax": Math.round(max), "aria-valuenow": Math.round(visibleHeight), "aria-valuetext": `${Math.round(visibleHeight)} pixels high`, onPointerDown: (event) => {
35
+ if (event.button !== 0)
36
+ return;
37
+ event.preventDefault();
38
+ event.currentTarget.focus({ preventScroll: true });
39
+ event.currentTarget.setPointerCapture(event.pointerId);
40
+ drag.current = {
41
+ y: event.clientY,
42
+ height: visibleHeight,
43
+ // Inline item details share the outer canvas zoom.
44
+ scale: event.currentTarget.getBoundingClientRect().height /
45
+ event.currentTarget.offsetHeight || 1,
46
+ };
47
+ }, onPointerMove: (event) => {
48
+ if (drag.current)
49
+ resize(drag.current.height +
50
+ (drag.current.y - event.clientY) / drag.current.scale);
51
+ }, onPointerUp: (event) => {
52
+ drag.current = null;
53
+ if (event.currentTarget.hasPointerCapture(event.pointerId))
54
+ event.currentTarget.releasePointerCapture(event.pointerId);
55
+ }, onPointerCancel: () => {
56
+ drag.current = null;
57
+ }, onLostPointerCapture: () => {
58
+ drag.current = null;
59
+ }, onKeyDown: (event) => {
60
+ const increment = event.shiftKey ? 80 : 20;
61
+ const next = event.key === "ArrowUp"
62
+ ? visibleHeight + increment
63
+ : event.key === "ArrowDown"
64
+ ? visibleHeight - increment
65
+ : event.key === "Home"
66
+ ? min
67
+ : event.key === "End"
68
+ ? max
69
+ : undefined;
70
+ if (next !== undefined) {
71
+ event.preventDefault();
72
+ event.stopPropagation();
73
+ resize(next);
74
+ }
75
+ }, children: _jsx("span", {}) })), _jsxs("div", { className: "flow-details-bar", children: [_jsx("button", { "aria-expanded": open, "aria-controls": id, onClick: () => {
76
+ onLayoutChange();
77
+ setOpen(!open);
78
+ }, children: open ? "Hide details" : "Show details" }), selection && _jsx("span", { title: selection, children: selection })] }), _jsx("div", { id: id, hidden: !open, className: "flow-details-body", role: "region", "aria-label": "Flow details", tabIndex: 0, style: { height: visibleHeight }, children: open && children })] }));
79
+ }
@@ -1,7 +1,8 @@
1
1
  import type { FlowDefinition, FlowRun } from "./flow-protocol.js";
2
2
  import type { Trace } from "./protocol.js";
3
- export declare const FlowOverview: import("react").MemoExoticComponent<({ definition, run, traces, }: {
3
+ export declare const FlowOverview: import("react").MemoExoticComponent<({ definition, run, traces, inline, }: {
4
4
  definition: FlowDefinition;
5
5
  run: FlowRun;
6
6
  traces: Trace[];
7
+ inline?: boolean;
7
8
  }) => import("react").JSX.Element>;