jev-devtools 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,5 @@
1
+ import { TypeSafeClient } from "@typesafe-ai/sdk";
2
+ import type { FlowFixture } from "../src/flow-fixture.js";
3
+ export type { FlowFixture } from "../src/flow-fixture.js";
4
+ /** Answers each jev step from fixture.steps[stepId]; no network or API key. Use only as a flow run's client. */
5
+ export declare function replayClient(fixture: FlowFixture): TypeSafeClient;
@@ -0,0 +1,55 @@
1
+ import { APIPromise, TypeSafeClient, } from "@typesafe-ai/sdk";
2
+ import { stepContext } from "./step-context.js";
3
+ const record = (v) => !!v && typeof v === "object" && !Array.isArray(v);
4
+ function replayError(fixture, questions) {
5
+ const step = stepContext.getStore();
6
+ if (!step)
7
+ return new Error("Replay client was called outside a flow run. Pass it as the client of flow.run().");
8
+ if (fixture.flow !== step.flow)
9
+ return new Error(`Fixture was recorded from flow "${fixture.flow}" but is replaying flow "${step.flow}".`);
10
+ if (!Object.hasOwn(fixture.steps, step.stepId))
11
+ return new Error(`Fixture has no response for step "${step.stepId}". The run took a route the fixture didn't record, or that step's capture was missing when the fixture was copied.`);
12
+ const response = fixture.steps[step.stepId];
13
+ const answers = record(response) && record(response.answers) ? response.answers : {};
14
+ const absent = Object.keys(record(questions) ? questions : {}).filter((name) => !Object.hasOwn(answers, name));
15
+ if (absent.length)
16
+ return new Error(`Fixture step "${step.stepId}" has no answer for ${absent.join(", ")}. Re-record the fixture.`);
17
+ return undefined;
18
+ }
19
+ // Checks run before the SDK so replay failures are not reported as connection errors.
20
+ class ReplayClient extends TypeSafeClient {
21
+ fixture;
22
+ constructor(fixture) {
23
+ super({
24
+ apiKey: "replay-fixture",
25
+ fetch: async () => {
26
+ const step = stepContext.getStore();
27
+ if (!step || !Object.hasOwn(fixture.steps, step.stepId))
28
+ throw new Error("Replay fetch outside a checked flow step");
29
+ return Response.json(fixture.steps[step.stepId]);
30
+ },
31
+ // Bodies are discarded, so skip compressing them.
32
+ compression: false,
33
+ logLevel: "off",
34
+ retry: { maxRetries: 0 },
35
+ });
36
+ this.fixture = fixture;
37
+ }
38
+ systemOne(request, options) {
39
+ const error = replayError(this.fixture, request.questions);
40
+ if (!error)
41
+ return super.systemOne(request, options);
42
+ // Same shape as the SDK's own early rejections.
43
+ const response = Promise.reject(error);
44
+ return new APIPromise(response, () => response);
45
+ }
46
+ }
47
+ /** Answers each jev step from fixture.steps[stepId]; no network or API key. Use only as a flow run's client. */
48
+ export function replayClient(fixture) {
49
+ if (fixture?.version !== 1 ||
50
+ typeof fixture.flow !== "string" ||
51
+ !record(fixture.steps))
52
+ throw new TypeError("Expected a version 1 flow fixture with flow and steps");
53
+ // A snapshot, so later edits to the caller's fixture don't change replay.
54
+ return new ReplayClient(structuredClone(fixture));
55
+ }
@@ -1,5 +1,9 @@
1
1
  import type { Json } from "../src/protocol.js";
2
2
  /** Detached JSON snapshot, without capture truncation or lossy JSON.stringify. */
3
3
  export declare function jsonSnapshot(value: unknown, seen?: Set<object>, depth?: number): Json;
4
+ /** Observer-only copy: preserve aliases inside the snapshot, never to execution data. */
5
+ export declare function observerSnapshot(value: unknown): Json;
6
+ /** Recording may establish synchronous context, but cannot replace or repeat execution. */
7
+ export declare function observeRequest<T>(callback: () => T, observe?: (call: () => T) => unknown): T;
4
8
  /** Cancellation ends awaiting, even when user code ignores the cooperative signal. */
5
9
  export declare function runCallback(callback: (signal: AbortSignal) => unknown, parent: AbortSignal, timeoutMs?: number): Promise<unknown>;
@@ -29,6 +29,80 @@ export function jsonSnapshot(value, seen = new Set(), depth = 0) {
29
29
  seen.delete(value);
30
30
  }
31
31
  }
32
+ /** Observer-only copy: preserve aliases inside the snapshot, never to execution data. */
33
+ export function observerSnapshot(value) {
34
+ const copies = new WeakMap();
35
+ const ancestors = new Set();
36
+ const visit = (input, depth) => {
37
+ if (depth > 40)
38
+ throw new TypeError("Observer data nesting exceeds 40");
39
+ if (input === null || typeof input !== "object")
40
+ return jsonSnapshot(input);
41
+ const previous = copies.get(input);
42
+ if (previous) {
43
+ // A shared subtree reached through a deeper path still obeys the depth limit.
44
+ if (depth + previous.height > 40)
45
+ throw new TypeError("Observer data nesting exceeds 40");
46
+ return previous.value;
47
+ }
48
+ if ((!Array.isArray(input) &&
49
+ Object.getPrototypeOf(input) !== Object.prototype &&
50
+ Object.getPrototypeOf(input) !== null) ||
51
+ Object.getOwnPropertySymbols(input).length)
52
+ throw new TypeError("Observer data must be JSON-compatible");
53
+ if (ancestors.has(input))
54
+ throw new TypeError("Observer data cannot contain cycles");
55
+ ancestors.add(input);
56
+ try {
57
+ let height = 0;
58
+ const child = (value) => {
59
+ const copy = visit(value, depth + 1);
60
+ const childHeight = value && typeof value === "object" ? copies.get(value).height : 0;
61
+ height = Math.max(height, childHeight + 1);
62
+ return copy;
63
+ };
64
+ const value = Array.isArray(input)
65
+ ? Array.from(input, child)
66
+ : Object.fromEntries(Object.entries(input).map(([key, value]) => [key, child(value)]));
67
+ const copy = { value, height };
68
+ copies.set(input, copy);
69
+ return value;
70
+ }
71
+ finally {
72
+ ancestors.delete(input);
73
+ }
74
+ };
75
+ return visit(value, 0);
76
+ }
77
+ /** Recording may establish synchronous context, but cannot replace or repeat execution. */
78
+ export function observeRequest(callback, observe) {
79
+ let called = false;
80
+ let result;
81
+ let failure;
82
+ const callOnce = () => {
83
+ if (!called) {
84
+ called = true;
85
+ try {
86
+ result = callback();
87
+ }
88
+ catch (error) {
89
+ failure = { error };
90
+ }
91
+ }
92
+ if (failure)
93
+ throw failure.error;
94
+ return result;
95
+ };
96
+ try {
97
+ // Observe rejected hook promises without awaiting them or changing the SDK result.
98
+ if (observe)
99
+ void Promise.resolve(observe(callOnce)).catch(() => { });
100
+ }
101
+ catch {
102
+ // A hook can fail before or after calling through. Reuse that invocation below.
103
+ }
104
+ return callOnce();
105
+ }
32
106
  /** Cancellation ends awaiting, even when user code ignores the cooperative signal. */
33
107
  export function runCallback(callback, parent, timeoutMs) {
34
108
  const controller = new AbortController();
@@ -0,0 +1,7 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import type { Trace } from "../src/protocol.js";
3
+ export type StepLink = NonNullable<Trace["flow"]> & {
4
+ flow: string;
5
+ };
6
+ /** The jev step whose SDK call is in progress, read by the replay client. */
7
+ export declare const stepContext: AsyncLocalStorage<StepLink>;
@@ -0,0 +1,3 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ /** The jev step whose SDK call is in progress, read by the replay client. */
3
+ export const stepContext = new AsyncLocalStorage();
@@ -6,7 +6,6 @@ export declare class FlowRecorder {
6
6
  private runs;
7
7
  private definitions;
8
8
  private sessions;
9
- private definitionRevisions;
10
9
  private revisions;
11
10
  private revision;
12
11
  private listeners;
@@ -21,8 +20,9 @@ export declare class FlowRecorder {
21
20
  private remove;
22
21
  private prune;
23
22
  clear(session: string): void;
24
- changes(session: string, cursor?: number): {
23
+ changes(session: string, cursor?: number, knownDefinitions?: ReadonlySet<string>): {
25
24
  revision: number;
25
+ retainedDefinitionIds: Set<string>;
26
26
  data: FlowUpdate;
27
27
  };
28
28
  }
@@ -5,7 +5,6 @@ export class FlowRecorder {
5
5
  runs = new Map();
6
6
  definitions = new Map();
7
7
  sessions = new Map();
8
- definitionRevisions = new Map();
9
8
  revisions = new Map();
10
9
  revision = 0;
11
10
  listeners = new Set();
@@ -49,7 +48,6 @@ export class FlowRecorder {
49
48
  };
50
49
  redactRules(stored);
51
50
  this.definitions.set(definition.id, stored);
52
- this.definitionRevisions.set(definition.id, ++this.revision);
53
51
  }
54
52
  this.runs.set(run.id, run);
55
53
  this.sessions.set(run.id, session);
@@ -155,7 +153,6 @@ export class FlowRecorder {
155
153
  for (const id of this.definitions.keys())
156
154
  if (!used.has(id)) {
157
155
  this.definitions.delete(id);
158
- this.definitionRevisions.delete(id);
159
156
  }
160
157
  }
161
158
  clear(session) {
@@ -166,18 +163,19 @@ export class FlowRecorder {
166
163
  this.revision++;
167
164
  this.notify(new Set([session]));
168
165
  }
169
- changes(session, cursor) {
166
+ changes(session, cursor, knownDefinitions = new Set()) {
170
167
  const current = [...this.runs.values()]
171
168
  .filter((r) => this.sessions.get(r.id) === session)
172
169
  .reverse();
173
170
  const used = new Set(current.map((r) => r.definitionId));
174
171
  return {
175
172
  revision: this.revision,
173
+ retainedDefinitionIds: used,
176
174
  data: structuredClone({
177
175
  version: 1,
178
176
  definitions: [...used]
179
- .filter((id) => cursor === undefined ||
180
- this.definitionRevisions.get(id) > cursor)
177
+ // Creation time says nothing about what this particular reader has.
178
+ .filter((id) => cursor === undefined || !knownDefinitions.has(id))
181
179
  .map((id) => this.definitions.get(id)),
182
180
  upserts: current.filter((r) => cursor === undefined || this.revisions.get(r.id) > cursor),
183
181
  order: current.map((r) => r.id),
@@ -113,14 +113,25 @@ export function createJevDevtools(options) {
113
113
  const body = new ReadableStream({
114
114
  start(controller) {
115
115
  let closed = false, dirty = true, cursor, timer;
116
+ let knownDefinitions = new Set();
116
117
  const flush = () => {
117
118
  timer = undefined;
118
119
  if (closed || !dirty || (controller.desiredSize ?? 0) <= 0)
119
120
  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`));
121
+ try {
122
+ const update = flows.changes(session, cursor, knownDefinitions);
123
+ controller.enqueue(encoder.encode(`event: flows\ndata: ${JSON.stringify(update.data)}\n\n`));
124
+ // Match the browser's pruning after this delivered order. A clear
125
+ // coalesced away under backpressure must not discard reader state.
126
+ knownDefinitions = update.retainedDefinitionIds;
127
+ cursor = update.revision;
128
+ dirty = false;
129
+ }
130
+ catch {
131
+ // A failed delivery must not advance the cursor or escape into
132
+ // application execution; reconnect starts with a full snapshot.
133
+ stop();
134
+ }
124
135
  };
125
136
  const wake = () => {
126
137
  if (!closed && dirty && !timer)
@@ -140,6 +151,7 @@ export function createJevDevtools(options) {
140
151
  if (closed)
141
152
  return;
142
153
  closed = true;
154
+ knownDefinitions.clear();
143
155
  unsubscribe();
144
156
  clearImmediate(timer);
145
157
  clearInterval(heartbeat);
@@ -1,7 +1,33 @@
1
1
  import { AsyncLocalStorage } from "node:async_hooks";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { Image } from "@typesafe-ai/sdk";
4
- const secretKey = /^(authorization|cookie|set-cookie|api[-_]?key|password|secret|access[-_]?token|refresh[-_]?token)$/i;
4
+ // Whole field names only: tokenCount, sessionId, apiKeyName, etc. stay useful.
5
+ // Keep the documented lists in docs/integration.md aligned with these policies.
6
+ const credentialKeys = new Set([
7
+ "authorization",
8
+ "proxyauthorization",
9
+ "cookie",
10
+ "cookies",
11
+ "setcookie",
12
+ "apikey",
13
+ "xapikey",
14
+ "password",
15
+ "passwd",
16
+ "passphrase",
17
+ "secret",
18
+ "clientsecret",
19
+ "token",
20
+ "accesstoken",
21
+ "refreshtoken",
22
+ "sessiontoken",
23
+ "authtoken",
24
+ "xauthtoken",
25
+ "xaccesstoken",
26
+ "idtoken",
27
+ "bearertoken",
28
+ "privatekey",
29
+ "secretkey",
30
+ ]);
5
31
  const safeMessage = (value) => value.replace(/Bearer\s+\S+/gi, "Bearer [redacted]").slice(0, 500);
6
32
  // Snapshot before the caller can mutate its request. Bounded and cycle-safe;
7
33
  // image handles become explicit previews/references instead of opaque SDK objects.
@@ -9,7 +35,8 @@ export function capture(value, warnings, redact) {
9
35
  const seen = new WeakSet();
10
36
  let budget = 96_000;
11
37
  const visit = (input, path, depth) => {
12
- if (secretKey.test(path.at(-1) || ""))
38
+ const key = (path.at(-1) ?? "").replace(/[-_]/g, "").toLowerCase();
39
+ if (credentialKeys.has(key))
13
40
  return "[redacted]";
14
41
  if (redact)
15
42
  input = redact(path, input);
@@ -243,18 +270,27 @@ export class JevRecorder {
243
270
  void promise
244
271
  .asResponse()
245
272
  .then(async (response) => {
246
- const data = await response.clone().json();
273
+ let result;
274
+ try {
275
+ const data = await response.clone().json();
276
+ result = capture(data, warnings, this.options.redact);
277
+ }
278
+ catch {
279
+ // Capture failures are not SDK failures, and redactor errors
280
+ // may contain the sensitive values they were meant to hide.
281
+ warnings.add("Response snapshot failed; result unavailable.");
282
+ }
247
283
  this.update(id, (t) => {
248
284
  t.status = "success";
249
285
  t.durationMs = performance.now() - started;
250
286
  t.requestId =
251
287
  response.headers.get("x-typesafe-request-id") ?? undefined;
252
- t.result = capture(data, warnings, this.options.redact);
288
+ t.result = result;
253
289
  t.captureWarnings = [...warnings];
254
290
  });
255
291
  }, fail)
256
- .catch((error) => {
257
- fail(error);
292
+ .catch(() => {
293
+ /* Recording errors must not affect inference or expose data. */
258
294
  });
259
295
  return promise;
260
296
  }
@@ -1,2 +1,2 @@
1
1
  import type { JevDevtoolsProps } from "./index.js";
2
- export default function ConnectedInspector({ endpoint, defaultOpen, }: Omit<JevDevtoolsProps, "enabled">): import("react").JSX.Element;
2
+ export default function ConnectedInspector({ endpoint, defaultOpen, redactAdditionalSensitiveData, }: Omit<JevDevtoolsProps, "enabled">): import("react").JSX.Element;
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- import { memo, useCallback, useEffect, useRef, useState } from "react";
2
+ import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
3
+ import { createDisplayRedactor } from "./display-redaction.js";
3
4
  import { JevPanel } from "./JevDevtools.js";
4
5
  import { JevDrawer } from "./JevDrawer.js";
5
6
  import { connectTraceStream } from "./trace-stream.js";
@@ -15,7 +16,7 @@ function endpointUrl(endpoint) {
15
16
  throw new Error("Use a same-origin inspector endpoint without a query or fragment.");
16
17
  return url.href.replace(/\/$/, "");
17
18
  }
18
- export default function ConnectedInspector({ endpoint = "/api/jev-devtools", defaultOpen = true, }) {
19
+ export default function ConnectedInspector({ endpoint = "/api/jev-devtools", defaultOpen = true, redactAdditionalSensitiveData = true, }) {
19
20
  const [open, setOpen] = useState(defaultOpen);
20
21
  const [visited, setVisited] = useState(defaultOpen);
21
22
  const visible = useRef(defaultOpen);
@@ -25,6 +26,16 @@ export default function ConnectedInspector({ endpoint = "/api/jev-devtools", def
25
26
  flows: emptyFlows,
26
27
  });
27
28
  const [snapshot, setSnapshot] = useState(latest.current);
29
+ const [redactor] = useState(createDisplayRedactor);
30
+ const traces = useMemo(() => redactAdditionalSensitiveData
31
+ ? snapshot.traces.map(redactor.trace)
32
+ : snapshot.traces, [snapshot.traces, redactor, redactAdditionalSensitiveData]);
33
+ const flows = useMemo(() => redactAdditionalSensitiveData
34
+ ? {
35
+ runs: snapshot.flows.runs.map(redactor.run),
36
+ definitions: snapshot.flows.definitions.map(redactor.definition),
37
+ }
38
+ : snapshot.flows, [snapshot.flows, redactor, redactAdditionalSensitiveData]);
28
39
  const toggle = (value) => {
29
40
  visible.current = value;
30
41
  setOpen(value);
@@ -91,5 +102,5 @@ export default function ConnectedInspector({ endpoint = "/api/jev-devtools", def
91
102
  if (!response.ok)
92
103
  throw new Error(`Cannot clear history (${response.status})`);
93
104
  }, [endpoint]);
94
- return (_jsx(JevDrawer, { open: open, onOpenChange: toggle, children: visited && _jsx(RetainedPanel, { ...snapshot, onClear: clear }) }));
105
+ return (_jsx(JevDrawer, { open: open, onOpenChange: toggle, children: visited && (_jsx(RetainedPanel, { traces: traces, flows: flows, connection: snapshot.connection, onClear: clear })) }));
95
106
  }
@@ -0,0 +1,22 @@
1
+ import type { FlowDefinition, FlowRun } from "./flow-protocol.js";
2
+ import type { Trace } from "./protocol.js";
3
+ import { type BuiltFlowFixture } from "./flow-fixture.js";
4
+ type CopyResult = {
5
+ built: BuiltFlowFixture;
6
+ json: string;
7
+ copied: boolean;
8
+ status: FlowRun["status"];
9
+ };
10
+ export type FixtureCopy = {
11
+ copy: () => void;
12
+ disabled: boolean;
13
+ result?: CopyResult;
14
+ };
15
+ export declare function useFixtureCopy(definition: FlowDefinition, run: FlowRun, traces: ReadonlyMap<string, Trace>): FixtureCopy;
16
+ export declare function CopyFixtureButton({ control }: {
17
+ control: FixtureCopy;
18
+ }): import("react").JSX.Element;
19
+ export declare function CopyFixtureReport({ control }: {
20
+ control: FixtureCopy;
21
+ }): import("react").JSX.Element | null;
22
+ export {};
@@ -0,0 +1,67 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useMemo, useRef, useState } from "react";
3
+ import { flowFixture, hasCompletedJev, } from "./flow-fixture.js";
4
+ const plural = (count, word) => `${count} ${word}${count === 1 ? "" : "s"}`;
5
+ function partial(status) {
6
+ return status === "error"
7
+ ? "Run failed. The fixture has completed Jev responses only."
8
+ : status === "cancelled"
9
+ ? "Run cancelled. The fixture has completed Jev responses only."
10
+ : undefined;
11
+ }
12
+ export function useFixtureCopy(definition, run, traces) {
13
+ const source = useMemo(() => ({ definition, run, traces }), [definition, run, traces]);
14
+ const [attempt, setAttempt] = useState();
15
+ const pending = useRef(undefined);
16
+ // Masking replaces the display projection. Hide its old JSON during render,
17
+ // including results from clipboard promises that settle after that change.
18
+ const current = attempt?.source === source ? attempt : undefined;
19
+ const completed = useMemo(() => hasCompletedJev(definition, run), [definition, run]);
20
+ const copy = async () => {
21
+ if (pending.current === source)
22
+ return;
23
+ pending.current = source;
24
+ setAttempt({ source });
25
+ const built = flowFixture(definition, run, traces);
26
+ const json = JSON.stringify(built.fixture, null, 2);
27
+ let copied = false;
28
+ try {
29
+ await navigator.clipboard.writeText(json);
30
+ copied = true;
31
+ }
32
+ catch {
33
+ // Clipboard access can be refused; show the JSON for manual copying instead.
34
+ }
35
+ setAttempt((latest) => latest?.source === source
36
+ ? { source, result: { built, json, copied, status: run.status } }
37
+ : latest);
38
+ if (pending.current === source)
39
+ pending.current = undefined;
40
+ };
41
+ return {
42
+ copy: () => void copy(),
43
+ disabled: (!!current && !current.result) || run.status === "pending" || !completed,
44
+ result: current?.result,
45
+ };
46
+ }
47
+ export function CopyFixtureButton({ control }) {
48
+ const [confirmation, setConfirmation] = useState();
49
+ useEffect(() => {
50
+ setConfirmation(control.result?.copied ? control.result : undefined);
51
+ if (!control.result?.copied)
52
+ return;
53
+ const timer = setTimeout(() => setConfirmation(undefined), 2000);
54
+ return () => clearTimeout(timer);
55
+ }, [control.result]);
56
+ const copied = !!control.result?.copied && confirmation === control.result;
57
+ return (_jsx("button", { className: "flow-fixture-button", "data-copied": copied || undefined, "aria-live": "polite", "aria-atomic": "true", disabled: control.disabled, onClick: control.copy, children: copied ? "Copied" : "Copy fixture" }));
58
+ }
59
+ export function CopyFixtureReport({ control }) {
60
+ const { result } = control;
61
+ const note = result && partial(result.status);
62
+ if (!result ||
63
+ (result.copied && !note && !result.built.missing.length && !result.built.warnings.length)) {
64
+ return null;
65
+ }
66
+ return (_jsxs("div", { className: "flow-fixture", children: [_jsxs("div", { role: "status", children: [!result.copied && (_jsx("p", { className: "muted", children: "Clipboard unavailable. Select the JSON below." })), result.built.missing.length > 0 && (_jsxs("p", { className: "warning", children: [plural(result.built.missing.length, "Jev response"), " missing from the fixture."] })), note && _jsx("p", { className: "warning", children: note }), result.built.warnings.length > 0 && (_jsxs("p", { className: "warning", children: ["Capture warnings: ", result.built.warnings.join(" ")] }))] }), result.built.missing.length > 0 && (_jsxs("details", { children: [_jsx("summary", { children: "Missing steps" }), _jsx("ul", { tabIndex: 0, "aria-label": "Steps missing from the fixture", children: result.built.missing.map((id) => (_jsx("li", { children: id }, id))) })] })), !result.copied && (_jsx("textarea", { "aria-label": "Fixture JSON", readOnly: true, value: result.json, onFocus: (e) => e.currentTarget.select() }))] }));
67
+ }
@@ -6,6 +6,7 @@ import { JsonBlock } from "./TraceContent.js";
6
6
  import { conditionInputs, conditionRequirement, } from "./condition-inputs.js";
7
7
  import { formatFlowCondition } from "./flow-condition.js";
8
8
  import { TraceDiagram } from "./TraceDiagram.js";
9
+ import { CopyFixtureButton, CopyFixtureReport, useFixtureCopy, } from "./CopyFixture.js";
9
10
  import { FlowDetailsPanel } from "./FlowDetailsPanel.js";
10
11
  import { DiagramContent } from "./DiagramContent.js";
11
12
  // Measure in canvas coordinates: screen rectangles also include the camera zoom.
@@ -277,6 +278,7 @@ export const FlowOverview = memo(function FlowOverview({ definition, run, traces
277
278
  collectionViews,
278
279
  itemSizes,
279
280
  ]);
281
+ const fixtureCopy = useFixtureCopy(definition, run, tracesById);
280
282
  const { viewport, canvas, zoom, zoomBy, fit, preserveCamera, focusArea } = useDiagramViewport(layout.width, layout.height, run.id, {
281
283
  x: layout.left,
282
284
  y: 0,
@@ -319,9 +321,9 @@ export const FlowOverview = memo(function FlowOverview({ definition, run, traces
319
321
  const trace = step?.traceId
320
322
  ? traces.find((t) => t.id === step.traceId)
321
323
  : undefined;
322
- return (_jsx("div", { className: `flow-overview-stack ${inline ? "flow-inline-overview" : ""}`, style: inline ? { width: layout.width } : undefined, children: _jsxs("section", { className: "request-detail whole-flow", "aria-label": inline ? "Collection item flow" : "Whole flow", children: [!inline && (_jsxs("header", { className: "detail-header", children: [_jsxs("div", { children: [_jsxs("span", { className: "eyebrow", children: ["Flow run \u00B7 ", run.status] }), _jsx("h2", { children: run.name })] }), _jsxs("div", { children: [run.outcome ? (_jsxs("strong", { children: ["Outcome: ", run.outcome] })) : (run.error ?? "Execution paths"), _jsx("small", { className: "muted", children: run.durationMs !== undefined
324
+ return (_jsx("div", { className: `flow-overview-stack ${inline ? "flow-inline-overview" : ""}`, style: inline ? { width: layout.width } : undefined, children: _jsxs("section", { className: "request-detail whole-flow", "aria-label": inline ? "Collection item flow" : "Whole flow", children: [!inline && (_jsxs("header", { className: "detail-header", children: [_jsxs("div", { children: [_jsxs("span", { className: "eyebrow", children: ["Flow run \u00B7 ", run.status] }), _jsx("h2", { children: run.name })] }), _jsxs("div", { className: "flow-run-summary", children: [run.outcome ? (_jsxs("strong", { children: ["Outcome: ", run.outcome] })) : (run.error ?? "Execution paths"), _jsx("small", { className: "muted", children: run.durationMs !== undefined
323
325
  ? ` · ${Math.round(run.durationMs)} ms`
324
- : "" })] })] })), _jsxs("div", { className: "lab-flow-toolbar", children: [!inline && (_jsxs("div", { children: [_jsx("button", { "aria-label": "Zoom flow out", onClick: () => zoomBy(1 / 1.2), children: "\u2212" }), _jsxs("output", { "aria-label": "Flow zoom", children: [zoom, "%"] }), _jsx("button", { "aria-label": "Zoom flow in", onClick: () => zoomBy(1.2), children: "+" }), _jsx("button", { onClick: fit, children: "Fit flow" }), _jsx("button", { disabled: !selected, onClick: () => selectStep(selected), children: "Focus step" })] })), _jsxs("label", { children: ["Step", _jsxs("select", { "aria-label": "Flow step", value: selected, onChange: (e) => selectStep(e.target.value), children: [_jsx("option", { value: "", children: "Select a step" }), definition.nodes.map((n) => (_jsxs("option", { value: n.id, children: [n.title ? `${n.title} (${n.id})` : n.id, " \u00B7", " ", run.steps[n.id]?.status ?? "waiting"] }, n.id)))] })] })] }), _jsxs("div", { className: "flow-workspace", children: [_jsx("div", { className: inline
326
+ : "" }), _jsx(CopyFixtureButton, { control: fixtureCopy })] })] })), !inline && _jsx(CopyFixtureReport, { control: fixtureCopy }), _jsxs("div", { className: "lab-flow-toolbar", children: [!inline && (_jsxs("div", { children: [_jsx("button", { "aria-label": "Zoom flow out", onClick: () => zoomBy(1 / 1.2), children: "\u2212" }), _jsxs("output", { "aria-label": "Flow zoom", children: [zoom, "%"] }), _jsx("button", { "aria-label": "Zoom flow in", onClick: () => zoomBy(1.2), children: "+" }), _jsx("button", { onClick: fit, children: "Fit flow" }), _jsx("button", { disabled: !selected, onClick: () => selectStep(selected), children: "Focus step" })] })), _jsxs("label", { children: ["Step", _jsxs("select", { "aria-label": "Flow step", value: selected, onChange: (e) => selectStep(e.target.value), children: [_jsx("option", { value: "", children: "Select a step" }), definition.nodes.map((n) => (_jsxs("option", { value: n.id, children: [n.title ? `${n.title} (${n.id})` : n.id, " \u00B7", " ", run.steps[n.id]?.status ?? "waiting"] }, n.id)))] })] })] }), _jsxs("div", { className: "flow-workspace", children: [_jsx("div", { className: inline
325
327
  ? "flow-inline-surface"
326
328
  : "flow-overview-viewport diagram-viewport", ref: inline ? undefined : viewport, style: inline ? { height: layout.height } : undefined, tabIndex: inline ? undefined : 0, "aria-label": inline ? "Item flow canvas" : "Flow canvas", role: "region", children: _jsxs("div", { ref: inline ? undefined : canvas, className: "flow-overview-canvas", style: {
327
329
  width: layout.width,
@@ -33,7 +33,7 @@ export const RequestDetail = memo(function RequestDetail({ trace, }) {
33
33
  startTransition(() => setTab(name));
34
34
  else
35
35
  setTab(name);
36
- }, children: name }, name))) }), _jsx("div", { className: `detail-body ${tab === "Flow" ? "detail-body-flow" : tab === "Questions" ? "detail-body-questions" : ""}`, children: _jsxs(Suspense, { fallback: _jsx("p", { className: "muted", role: "status", children: "Loading questions\u2026" }), children: [trace.error && (_jsxs("div", { role: "alert", className: "error-banner", children: [_jsx("strong", { children: trace.error.name }), " ", trace.error.message] })), trace.captureWarnings.map((w) => (_jsx("p", { className: "warning", children: w }, w))), tab === "Flow" && _jsx(TraceDiagram, { trace: trace }, trace.id), tab === "Questions" && (_jsx(Questions, { trace: trace }, trace.id)), tab === "Context" && (_jsxs(_Fragment, { children: [_jsx("p", { className: "muted", children: "Captured before dispatch. Known credential fields and email are redacted; captures are size limited." }), findImage(trace.request.state) && (_jsx("img", { className: "context-image", src: findImage(trace.request.state), alt: "Captured context" })), _jsx(JsonBlock, { value: trace.request.state }), trace.request.assets && (_jsx(JsonBlock, { value: trace.request.assets }))] })), tab === "Transport" && (_jsxs(_Fragment, { children: [_jsx("h3", { children: "HTTP attempts" }), _jsx("p", { className: "muted", children: "Attempt duration ends at response headers. Total duration includes SDK processing and retry waits." }), _jsxs("table", { children: [_jsx("thead", { children: _jsxs("tr", { children: [_jsx("th", { children: "Attempt" }), _jsx("th", { children: "Status" }), _jsx("th", { children: "Duration" })] }) }), _jsx("tbody", { children: trace.attempts.map((a) => (_jsxs("tr", { children: [_jsx("td", { children: a.number }), _jsx("td", { children: a.error || a.status || "Pending" }), _jsx("td", { children: ms(a.durationMs) })] }, a.number))) })] }), !trace.attempts.length && _jsx("p", { children: "No HTTP attempt recorded." })] })), tab === "Raw" && _jsx(JsonBlock, { value: trace })] }) })] })) : (_jsxs("div", { className: "empty-detail", children: [_jsx("span", { className: "empty-symbol", children: "\u21B3" }), _jsx("h2", { children: "Follow a Jev request" }), _jsxs("p", { children: ["Context, parallel questions, typed answers.", _jsx("br", {}), "Run a sample above to see the complete flow."] })] })) }));
36
+ }, children: name }, name))) }), _jsx("div", { className: `detail-body ${tab === "Flow" ? "detail-body-flow" : tab === "Questions" ? "detail-body-questions" : ""}`, children: _jsxs(Suspense, { fallback: _jsx("p", { className: "muted", role: "status", children: "Loading questions\u2026" }), children: [trace.error && (_jsxs("div", { role: "alert", className: "error-banner", children: [_jsx("strong", { children: trace.error.name }), " ", trace.error.message] })), trace.captureWarnings.map((w) => (_jsx("p", { className: "warning", children: w }, w))), tab === "Flow" && _jsx(TraceDiagram, { trace: trace }, trace.id), tab === "Questions" && (_jsx(Questions, { trace: trace }, trace.id)), tab === "Context" && (_jsxs(_Fragment, { children: [_jsx("p", { className: "muted", children: "Captured before dispatch without changing SDK data. Known credential fields remain redacted; additional display masking follows the component prop. Captures are size limited." }), findImage(trace.request.state) && (_jsx("img", { className: "context-image", src: findImage(trace.request.state), alt: "Captured context" })), _jsx(JsonBlock, { value: trace.request.state }), trace.request.assets && (_jsx(JsonBlock, { value: trace.request.assets }))] })), tab === "Transport" && (_jsxs(_Fragment, { children: [_jsx("h3", { children: "HTTP attempts" }), _jsx("p", { className: "muted", children: "Attempt duration ends at response headers. Total duration includes SDK processing and retry waits." }), _jsxs("table", { children: [_jsx("thead", { children: _jsxs("tr", { children: [_jsx("th", { children: "Attempt" }), _jsx("th", { children: "Status" }), _jsx("th", { children: "Duration" })] }) }), _jsx("tbody", { children: trace.attempts.map((a) => (_jsxs("tr", { children: [_jsx("td", { children: a.number }), _jsx("td", { children: a.error || a.status || "Pending" }), _jsx("td", { children: ms(a.durationMs) })] }, a.number))) })] }), !trace.attempts.length && _jsx("p", { children: "No HTTP attempt recorded." })] })), tab === "Raw" && _jsx(JsonBlock, { value: trace })] }) })] })) : (_jsxs("div", { className: "empty-detail", children: [_jsx("span", { className: "empty-symbol", children: "\u21B3" }), _jsx("h2", { children: "Follow a Jev request" }), _jsxs("p", { children: ["Context, parallel questions, typed answers.", _jsx("br", {}), "Run a sample above to see the complete flow."] })] })) }));
37
37
  });
38
38
  export function JevPanel({ traces, flows = emptyFlows, connection, onClear, }) {
39
39
  const [view, setView] = useState("requests");
@@ -0,0 +1,9 @@
1
+ import type { Trace } from "./protocol.js";
2
+ import type { FlowDefinition, FlowRun } from "./flow-protocol.js";
3
+ /** One projection cache per mounted inspector. Stream captures are immutable;
4
+ * unchanged rows and nested runs retain identity across unrelated updates. */
5
+ export declare function createDisplayRedactor(): {
6
+ trace: (value: Trace) => Trace;
7
+ run: (value: FlowRun) => FlowRun;
8
+ definition: (value: FlowDefinition) => FlowDefinition;
9
+ };