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.
package/README.md CHANGED
@@ -1,12 +1,14 @@
1
1
  # Jev DevTools
2
2
 
3
- [![npm version 0.4.1](https://img.shields.io/badge/npm-v0.4.1-E551BA?labelColor=0D0A0F&logo=npm)](https://www.npmjs.com/package/jev-devtools/v/0.4.1)
3
+ [![npm version 0.5.0](https://img.shields.io/badge/npm-v0.5.0-E551BA?labelColor=0D0A0F&logo=npm)](https://www.npmjs.com/package/jev-devtools/v/0.5.0)
4
4
 
5
5
  A React drawer and Node adapter for inspecting TypeSafe Jev requests with animated diagrams. Includes request history, context, questions, typed answers, transport attempts, and raw snapshots.
6
6
 
7
- **Version 0.4.1 is prepared for release; publication is pending.** Check the release history for verified publication status. **Experimental (pre-1.0).** React 18/19 and Node 22.12+ are supported. APIs may change before 1.0. Verification uses sample responses through the real TypeSafe SDK, not live inference.
7
+ **Source version: 0.5.0.** Check the [release history](https://github.com/diegoag64/jev-devtools/blob/main/CHANGELOG.md) for npm availability. If trying this source before publication, install the local archive below. **Experimental (pre-1.0).** React 18/19 and Node 22.12+ are supported. APIs may change before 1.0. Verification uses sample responses through the real TypeSafe SDK, not live inference.
8
8
 
9
- [Changelog and release history](https://github.com/diegoag64/jev-devtools/blob/main/CHANGELOG.md) — published versions and unreleased work.
9
+ [Website](https://jevtools.dev/) · [Documentation](https://docs.jevtools.dev/) · [Quick start](https://docs.jevtools.dev/getting-started/) · [API reference](https://docs.jevtools.dev/api/reference/) · [Release history](https://docs.jevtools.dev/changelog/)
10
+
11
+ Hosted docs update separately; use the installed declarations and bundled skill for your exact version, and the [repository changelog](https://github.com/diegoag64/jev-devtools/blob/main/CHANGELOG.md) for release-candidate status.
10
12
 
11
13
  ## Demo
12
14
 
@@ -31,10 +33,10 @@ The [setup skill](https://github.com/diegoag64/jev-devtools/blob/main/packages/j
31
33
 
32
34
  ## Install
33
35
 
34
- The npm command installs the latest published release (0.4.0 at preparation time):
36
+ Install from npm:
35
37
 
36
38
  ```sh
37
- npm install jev-devtools @typesafe-ai/sdk
39
+ pnpm add jev-devtools @typesafe-ai/sdk
38
40
  ```
39
41
 
40
42
  To try local changes before a future release:
@@ -44,7 +46,7 @@ To try local changes before a future release:
44
46
  npm ci
45
47
  npm run pack:devtools
46
48
  # From the consuming app:
47
- npm install /absolute/path/to/jev-devtools-0.4.1.tgz
49
+ npm install /absolute/path/to/jev-devtools-0.5.0.tgz
48
50
  ```
49
51
 
50
52
  Mount once. The drawer loads its own styles; no CSS import is needed:
@@ -74,15 +76,19 @@ Only calls through that client are captured. The React component subscribes via
74
76
 
75
77
  History is bounded to 50 calls in memory by default. API routes and inspector endpoints must share an adapter instance; distributed processes and edge runtimes are not supported. Without `authorize`, the middleware accepts only loopback connections; Web Request handlers can't see the connection's address, so run those servers on `127.0.0.1`. Captures include bounded/redacted context and answers, not hidden model reasoning.
76
78
 
77
- For Next.js App Router, use `getJevDevtools(appKey, optionsFactory)` from `/server` to share an adapter across server reloads, expose `handleNodeRoute` through an optional catch-all Route Handler, and mount the root component in a client boundary. See the [Next.js guide](https://github.com/diegoag64/jev-devtools/blob/main/docs/nextjs.md) and runnable example. No Next dependency is required by this package.
79
+ Known credential fields are always redacted from DevTools captures; SDK and runtime data remain unchanged. The optional `<JevDevtools redactAdditionalSensitiveData={false} />` prop controls **display-only** masking of supported email, phone, postal address, SSN and card-number fields. It defaults to `true` and updates existing views immediately. These additional values still exist in capture/stream/browser memory while masked. Custom server redaction remains active, and neither credentials nor custom-redacted values can be revealed. Headers/options are not captured. See the [exact policy and limitations](https://github.com/diegoag64/jev-devtools/blob/main/docs/integration.md#redaction-policy); arbitrary secrets in prose and unusual field names still need application-specific redaction.
80
+
81
+ For Next.js App Router, use `getJevDevtools(appKey, optionsFactory)` from `/server` to share an adapter across server reloads, expose `handleNodeRoute` through an optional catch-all Route Handler, and mount the root component in a client boundary. See the [Next.js guide](https://docs.jevtools.dev/guides/nextjs/) and runnable example. No Next dependency is required by this package.
78
82
 
79
- [Full integration guide](https://github.com/diegoag64/jev-devtools/blob/main/docs/integration.md) · [Repository and playground](https://github.com/diegoag64/jev-devtools)
83
+ [Quick start](https://docs.jevtools.dev/getting-started/) · [Detailed integration and access controls](https://github.com/diegoag64/jev-devtools/blob/main/docs/integration.md) · [Repository and playground](https://github.com/diegoag64/jev-devtools)
84
+
85
+ Jev DevTools is an independent, open-source community project. It is not affiliated with TypeSafe AI.
80
86
 
81
87
  MIT for project code. See bundled LICENSE and THIRD_PARTY_NOTICES.txt for original design attribution and TypeSafe branding.
82
88
 
83
89
  ## Connected Jev flows with `jev-devtools/flow`
84
90
 
85
- Define calls, conditions, parallel checks, and named outcomes with `jev-devtools/flow`. **0.4.0 adds** server function steps (`task`), runtime request factories, bounded collections (`map`), returning conditionals (`if`), stateful iteration (`loop`), and strict numeric `.gt()` comparisons. The drawer connects request diagrams, function results, selected paths, collection items, and loop iterations on one canvas. Existing single-request integrations remain supported.
91
+ Define calls, conditions, parallel checks, and named outcomes with `jev-devtools/flow`. The builder supports server function steps (`task`), runtime request factories, bounded collections (`map`), returning conditionals (`if`), stateful iteration (`loop`), and strict numeric `.gt()` comparisons. The drawer connects request diagrams, function results, selected paths, collection items, and loop iterations on one canvas. Existing single-request integrations remain supported.
86
92
 
87
93
  On the server, reuse the instrumented `jev` client and `inspector` from the setup above:
88
94
 
@@ -111,9 +117,11 @@ const result = await review.run({ message: 'Please review my ticket.' }, {
111
117
  // Handle result.outcome in your app. The flow does not execute business actions.
112
118
  ```
113
119
 
114
- Define once in a Node module and run per request. Use `flow.parallel` for concurrent named lanes; downstream calls wait for every lane to succeed. `flow.branch` is terminal in its block; use `flow.if` when selected values feed shared downstream work. Tasks execute application code on Node with JSON-compatible inputs/results; maps and loops have explicit bounds. Runs have SDK retries and cooperative cancellation, with bounded in-memory inspector history. They do not provide durable or distributed execution. Keep the 0.4.0 server and drawer together to inspect its new step kinds.
120
+ Define once in a Node module and run per request. Use `flow.parallel` for concurrent named lanes; downstream calls wait for every lane to succeed. `flow.branch` is terminal in its block; use `flow.if` when selected values feed shared downstream work. Tasks execute application code on Node with JSON-compatible inputs/results; maps and loops have explicit bounds. Runs have SDK retries and cooperative cancellation, with bounded in-memory inspector history. They do not provide durable or distributed execution. Keep the server and drawer on the same package version to inspect every supported step kind.
121
+
122
+ **Added in 0.5.0.** **Copy fixture** in the drawer saves a run as JSON, and `replayClient(fixture)` from `jev-devtools/flow` replays its Jev calls in tests with no network access or API key; tasks run again. `compareWithFixture` from `jev-devtools/flow` compares a live run's answers, routing and item steps with a saved fixture.
115
123
 
116
- [Full flow guide](https://github.com/diegoag64/jev-devtools/blob/main/docs/flows.md) · [AI flow recipe](https://github.com/diegoag64/jev-devtools/blob/main/packages/jev-devtools/skills/setup-jev-devtools/references/flows.md)
124
+ [Beginner flow guide](https://docs.jevtools.dev/guides/flow-basics/) · [Flow API reference](https://docs.jevtools.dev/api/flow/) · [Detailed flow guide](https://github.com/diegoag64/jev-devtools/blob/main/docs/flows.md) · [AI flow recipe](https://github.com/diegoag64/jev-devtools/blob/main/packages/jev-devtools/skills/setup-jev-devtools/references/flows.md)
117
125
 
118
126
  To have your coding agent add a flow after installation:
119
127
 
@@ -0,0 +1,61 @@
1
+ import type { FlowDefinition } from "../src/flow-protocol.js";
2
+ import type { FlowFixture } from "../src/flow-fixture.js";
3
+ import type { FlowOutcome, FlowRunOptions } from "./index.js";
4
+ type AnswerValue = string | number | boolean | null;
5
+ /** One scalar answer field of a jev step both sides ran; delta is set when both sides and their difference are finite. Answer drift never sets FixtureComparison.changed on its own. */
6
+ export type AnswerChange = {
7
+ stepId: string;
8
+ question: string;
9
+ field: string;
10
+ recorded?: AnswerValue;
11
+ live?: AnswerValue;
12
+ delta?: number;
13
+ changed: boolean;
14
+ };
15
+ export type FixtureComparison = {
16
+ /** Outcome, value or a decision differs, or a jev step ran on only one side. Answer drift alone does not set it. */
17
+ changed: boolean;
18
+ outcome: {
19
+ recorded: string;
20
+ live: string;
21
+ changed: boolean;
22
+ };
23
+ value: {
24
+ recorded?: unknown;
25
+ live?: unknown;
26
+ changed: boolean;
27
+ };
28
+ decisions: {
29
+ stepId: string;
30
+ recorded?: boolean;
31
+ live?: boolean;
32
+ changed: boolean;
33
+ }[];
34
+ answers: AnswerChange[];
35
+ /** Jev step ids; map and loop items use `${parentId}/${index}/${innerId}`. */
36
+ steps: {
37
+ onlyRecorded: string[];
38
+ onlyLive: string[];
39
+ };
40
+ /** The live run's response bodies, unredacted and untruncated. */
41
+ liveFixture: FlowFixture;
42
+ };
43
+ /** Any flow from defineJevFlow, typed by its input. */
44
+ export type ComparableFlow<T> = {
45
+ definition: FlowDefinition;
46
+ run(input: T, options: FlowRunOptions): Promise<FlowOutcome & {
47
+ runId: string;
48
+ }>;
49
+ };
50
+ /**
51
+ * Replays `fixture` offline and runs the flow with `options.client` on the same input, then
52
+ * reports what changed. Only the live run reaches `options.devtools`. Tasks run on both
53
+ * sides, the recorded side on the replayed answers. Rejects when the
54
+ * fixture cannot replay this input, or with the live run's own error.
55
+ */
56
+ export declare function compareWithFixture<T>(flow: ComparableFlow<T>, input: T, fixture: FlowFixture, options: FlowRunOptions & {
57
+ tolerance?: number;
58
+ }): Promise<FixtureComparison>;
59
+ /** One plain line per change, or "no changes from fixture". */
60
+ export declare function formatComparison(comparison: FixtureComparison): string;
61
+ export {};
@@ -0,0 +1,336 @@
1
+ import { isDeepStrictEqual } from "node:util";
2
+ import { replayClient } from "./replay.js";
3
+ import { observeRequest } from "./runtime.js";
4
+ const record = (v) => !!v && typeof v === "object" && !Array.isArray(v);
5
+ async function observe(flow, input, options) {
6
+ const bodies = new Map();
7
+ const captures = [];
8
+ let latest;
9
+ const user = options.devtools;
10
+ const devtools = {
11
+ startFlow(definition, run) {
12
+ latest = run;
13
+ let inner;
14
+ try {
15
+ inner = user?.startFlow(structuredClone(definition), structuredClone(run));
16
+ }
17
+ catch {
18
+ /* Recording cannot stop execution. */
19
+ }
20
+ return {
21
+ update(next) {
22
+ latest = next;
23
+ try {
24
+ // Recording consumers cannot alter the comparison's private snapshot.
25
+ inner?.update(structuredClone(next));
26
+ }
27
+ catch {
28
+ /* Best effort capture. */
29
+ }
30
+ },
31
+ request(link, onTrace, callback) {
32
+ const stepId = link.stepId;
33
+ // Capture the runner's result even if recording skips, throws or substitutes it.
34
+ const result = observeRequest(callback, inner
35
+ ? (callOnce) => inner.request({ ...link }, onTrace, callOnce)
36
+ : undefined);
37
+ // A separate subscriber; the runner awaits the original result unchanged.
38
+ const capture = Promise.resolve(result).then(
39
+ // Observers may retain the result and mutate it on later run updates.
40
+ (body) => bodies.set(stepId, structuredClone(body)), () => undefined);
41
+ // Handle snapshot failures now, then surface them after successful execution.
42
+ void capture.catch(() => { });
43
+ captures.push(capture);
44
+ return result;
45
+ },
46
+ };
47
+ },
48
+ };
49
+ const result = await flow.run(input, { ...options, devtools });
50
+ await Promise.all(captures);
51
+ return {
52
+ outcome: result.outcome,
53
+ ...(result.value !== undefined ? { value: result.value } : {}),
54
+ ...(latest ? { run: latest } : {}),
55
+ bodies,
56
+ };
57
+ }
58
+ /**
59
+ * Visits both runs' nodes in flowFixture's order: definition order, and at a map or loop
60
+ * its items by ascending index, each item walked whole before the next.
61
+ */
62
+ function walk(definition, recorded, live, visit, prefix = "") {
63
+ const items = (step) => new Map((step?.collection?.items ?? []).flatMap((item) => item.run ? [[item.index, item.run]] : []));
64
+ for (const node of definition.nodes) {
65
+ const r = recorded?.steps[node.id], l = live?.steps[node.id];
66
+ if (node.itemDefinition) {
67
+ const before = items(r), after = items(l);
68
+ // Items complete in any order, so walk them by index.
69
+ for (const index of union(before.keys(), after.keys()).sort((a, b) => a - b))
70
+ walk(node.itemDefinition, before.get(index), after.get(index), visit, `${prefix}${node.id}/${index}/`);
71
+ }
72
+ visit(node, prefix + node.id, r, l);
73
+ }
74
+ }
75
+ /** Scalar leaves of an answer, by path segments, without the type discriminator. */
76
+ function leaves(value, path = [], out = []) {
77
+ if (!value || typeof value !== "object")
78
+ return out;
79
+ for (const [key, child] of Object.entries(value)) {
80
+ if (!path.length && key === "type")
81
+ continue;
82
+ if (child && typeof child === "object")
83
+ leaves(child, [...path, key], out);
84
+ else if (child === null ||
85
+ typeof child === "string" ||
86
+ typeof child === "number" ||
87
+ typeof child === "boolean")
88
+ out.push({ path: [...path, key], value: child });
89
+ }
90
+ return out;
91
+ }
92
+ /** Dotted for plain keys; keys with dots, brackets or quotes are bracketed so paths stay distinct. */
93
+ const fieldName = (path) => path.reduce((name, key) => key && !/[.[\]"]/.test(key)
94
+ ? name + (name ? "." : "") + key
95
+ : `${name}[${JSON.stringify(key)}]`, "");
96
+ const byPath = (leaves) => new Map(leaves.map((leaf) => [JSON.stringify(leaf.path), leaf]));
97
+ const answersOf = (body) => record(body) && record(body.answers) ? body.answers : {};
98
+ /** Recorded keys first, then keys only the live side has. */
99
+ const union = (recorded, live) => [
100
+ ...new Set([...recorded, ...live]),
101
+ ];
102
+ function compareAnswers(stepId, recordedBody, liveBody, tolerance) {
103
+ const recorded = answersOf(recordedBody), live = answersOf(liveBody), changes = [];
104
+ for (const question of union(Object.keys(recorded), Object.keys(live))) {
105
+ const before = byPath(leaves(recorded[question])), after = byPath(leaves(live[question]));
106
+ for (const key of union(before.keys(), after.keys())) {
107
+ const r = before.get(key)?.value, l = after.get(key)?.value;
108
+ const leaf = before.get(key) ?? after.get(key);
109
+ if (!leaf)
110
+ continue; // Every key comes from one of the two maps.
111
+ const change = {
112
+ stepId,
113
+ question,
114
+ field: fieldName(leaf.path),
115
+ changed: false,
116
+ };
117
+ if (before.has(key))
118
+ change.recorded = r;
119
+ if (after.has(key))
120
+ change.live = l;
121
+ if (typeof r === "number" && typeof l === "number") {
122
+ const delta = l - r;
123
+ if (!Number.isFinite(r) || !Number.isFinite(l))
124
+ change.changed = !Object.is(r, l);
125
+ else if (!Number.isFinite(delta))
126
+ change.changed = true; // Overflow: never read as unchanged.
127
+ else {
128
+ change.delta = delta;
129
+ // Allow float noise at the boundary (0.4 - 0.3 is 0.10000000000000003).
130
+ change.changed =
131
+ Math.abs(delta) > tolerance + tolerance * 8 * Number.EPSILON;
132
+ }
133
+ }
134
+ else
135
+ change.changed = before.has(key) !== after.has(key) || r !== l;
136
+ changes.push(change);
137
+ }
138
+ }
139
+ return changes;
140
+ }
141
+ /**
142
+ * Replays `fixture` offline and runs the flow with `options.client` on the same input, then
143
+ * reports what changed. Only the live run reaches `options.devtools`. Tasks run on both
144
+ * sides, the recorded side on the replayed answers. Rejects when the
145
+ * fixture cannot replay this input, or with the live run's own error.
146
+ */
147
+ export async function compareWithFixture(flow, input, fixture, options) {
148
+ const { tolerance = 0, ...runOptions } = options;
149
+ if (!Number.isFinite(tolerance) || tolerance < 0)
150
+ throw new TypeError("tolerance must be a non-negative finite number");
151
+ // Outside the try, so an invalid fixture rejects with replayClient's own TypeError.
152
+ const client = replayClient(fixture);
153
+ let recorded;
154
+ try {
155
+ recorded = await observe(flow, input, {
156
+ client,
157
+ ...(runOptions.signal ? { signal: runOptions.signal } : {}),
158
+ });
159
+ }
160
+ catch (error) {
161
+ if (error instanceof Error &&
162
+ ["AbortError", "APIUserAbortError"].includes(error.name))
163
+ throw error;
164
+ throw new Error(`Fixture replay failed: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
165
+ }
166
+ const live = await observe(flow, input, runOptions);
167
+ const decisions = [], answers = [], onlyRecorded = [], onlyLive = [], steps = {};
168
+ walk(flow.definition, recorded.run, live.run, (node, id, rs, ls) => {
169
+ // Merges copy their if()'s decision and loops keep their last while(); neither counts.
170
+ if (node.kind === "branch" || node.kind === "if") {
171
+ const r = rs?.decision, l = ls?.decision;
172
+ if (r !== undefined || l !== undefined)
173
+ decisions.push({
174
+ stepId: id,
175
+ ...(r !== undefined ? { recorded: r } : {}),
176
+ ...(l !== undefined ? { live: l } : {}),
177
+ changed: r !== l,
178
+ });
179
+ return;
180
+ }
181
+ if (node.kind !== "jev")
182
+ return;
183
+ // Item status is under the bare id; bodies arrive under the composed id.
184
+ const inRecorded = rs?.status === "completed" && recorded.bodies.has(id), inLive = ls?.status === "completed" && live.bodies.has(id);
185
+ if (inLive)
186
+ steps[id] = structuredClone(live.bodies.get(id));
187
+ if (inRecorded && inLive)
188
+ answers.push(...compareAnswers(id, recorded.bodies.get(id), live.bodies.get(id), tolerance));
189
+ else if (inRecorded)
190
+ onlyRecorded.push(id);
191
+ else if (inLive)
192
+ onlyLive.push(id);
193
+ });
194
+ const outcome = {
195
+ recorded: recorded.outcome,
196
+ live: live.outcome,
197
+ changed: recorded.outcome !== live.outcome,
198
+ };
199
+ const value = {
200
+ ...(recorded.value !== undefined ? { recorded: recorded.value } : {}),
201
+ ...(live.value !== undefined ? { live: live.value } : {}),
202
+ changed: !isDeepStrictEqual(recorded.value, live.value),
203
+ };
204
+ return {
205
+ changed: outcome.changed ||
206
+ value.changed ||
207
+ decisions.some((d) => d.changed) ||
208
+ onlyRecorded.length > 0 ||
209
+ onlyLive.length > 0,
210
+ outcome,
211
+ value,
212
+ decisions,
213
+ answers,
214
+ steps: { onlyRecorded, onlyLive },
215
+ liveFixture: { version: 1, flow: flow.definition.name, steps },
216
+ };
217
+ }
218
+ /** JSON for ordinary values; never throws on BigInt, cycles, functions or symbols. */
219
+ function json(v) {
220
+ if (v === undefined)
221
+ return "(none)";
222
+ try {
223
+ const text = JSON.stringify(v);
224
+ if (text !== undefined)
225
+ return text;
226
+ }
227
+ catch {
228
+ /* Not plain JSON; render it below. */
229
+ }
230
+ try {
231
+ return loose(v, new Set()) ?? `[${typeof v}]`;
232
+ }
233
+ catch {
234
+ return "[unserializable]";
235
+ }
236
+ }
237
+ /** JSON.stringify's layout, with BigInt as 1n, cycles as [cycle] and other values by type. */
238
+ function loose(v, ancestors) {
239
+ if (typeof v === "bigint")
240
+ return `${v}n`;
241
+ if (typeof v === "function" || typeof v === "symbol")
242
+ return `[${typeof v}]`;
243
+ if (v === undefined)
244
+ return undefined;
245
+ if (v === null || typeof v !== "object")
246
+ return JSON.stringify(v);
247
+ if (ancestors.has(v))
248
+ return "[cycle]";
249
+ const toJSON = v.toJSON;
250
+ if (typeof toJSON === "function")
251
+ return loose(toJSON.call(v), ancestors);
252
+ ancestors.add(v);
253
+ const text = Array.isArray(v)
254
+ ? `[${v.map((item) => loose(item, ancestors) ?? "null").join(",")}]`
255
+ : `{${Object.entries(v)
256
+ .flatMap(([key, item]) => {
257
+ const shown = loose(item, ancestors);
258
+ return shown === undefined ? [] : [`${JSON.stringify(key)}:${shown}`];
259
+ })
260
+ .join(",")}}`;
261
+ ancestors.delete(v);
262
+ return text;
263
+ }
264
+ /** Report text beyond this many characters is cut; the comparison keeps values in full. */
265
+ const LIMIT = 160;
266
+ /** Up to LIMIT characters from start, marking cut ends; never splits a surrogate pair. */
267
+ function cut(text, start) {
268
+ if (start > 0 && /[\uDC00-\uDFFF]/.test(text[start]))
269
+ start--;
270
+ if (start === 0 && text.length <= LIMIT)
271
+ return text;
272
+ let end = Math.min(start + LIMIT, text.length);
273
+ if (end < text.length && /[\uD800-\uDBFF]/.test(text[end - 1]))
274
+ end--;
275
+ return `${start ? "..." : ""}${text.slice(start, end)}${end < text.length ? "..." : ""} (${text.length} chars)`;
276
+ }
277
+ /** Cuts both sides alike; when the first difference is past the cut, both show a window around it. */
278
+ function clipPair(a, b) {
279
+ let at = 0;
280
+ while (at < a.length && at < b.length && a[at] === b[at])
281
+ at++;
282
+ const start = a !== b && a.length > LIMIT && b.length > LIMIT && at >= LIMIT - 1
283
+ ? at - 40
284
+ : 0;
285
+ return [cut(a, start), cut(b, start)];
286
+ }
287
+ const route = (v) => v === undefined ? "(not run)" : v ? "then" : "else";
288
+ const scalar = (v, digits) => v === undefined
289
+ ? "(none)"
290
+ : typeof v === "number" && digits !== undefined
291
+ ? v.toFixed(digits)
292
+ : String(v);
293
+ /** Two decimals, more when two different numbers would print the same, else exact. */
294
+ function precision(a, b) {
295
+ if (typeof a !== "number" || typeof b !== "number" || a === b)
296
+ return 2;
297
+ for (let digits = 2; digits <= 20; digits++)
298
+ if (a.toFixed(digits) !== b.toFixed(digits))
299
+ return digits;
300
+ return undefined;
301
+ }
302
+ function signed(delta, digits) {
303
+ const sign = delta < 0 ? "-" : "+";
304
+ if (digits !== undefined)
305
+ for (; digits <= 20; digits++)
306
+ if (delta === 0 || Number(delta.toFixed(digits)) !== 0)
307
+ return sign + Math.abs(delta).toFixed(digits);
308
+ return sign + String(Math.abs(delta));
309
+ }
310
+ /** One plain line per change, or "no changes from fixture". */
311
+ export function formatComparison(comparison) {
312
+ const lines = [];
313
+ const { outcome, value, decisions, steps, answers } = comparison;
314
+ if (outcome.changed)
315
+ lines.push(`outcome: ${outcome.recorded} -> ${outcome.live}`);
316
+ if (value.changed) {
317
+ const [recorded, live] = clipPair(json(value.recorded), json(value.live));
318
+ lines.push(`value: ${recorded} -> ${live}`);
319
+ }
320
+ for (const d of decisions)
321
+ if (d.changed)
322
+ lines.push(`decision ${d.stepId}: ${route(d.recorded)} -> ${route(d.live)}`);
323
+ for (const id of steps.onlyRecorded)
324
+ lines.push(`step ${id}: recorded only`);
325
+ for (const id of steps.onlyLive)
326
+ lines.push(`step ${id}: live only`);
327
+ for (const a of answers) {
328
+ if (!a.changed)
329
+ continue;
330
+ const digits = precision(a.recorded, a.live);
331
+ const [recorded, live] = clipPair(scalar(a.recorded, digits), scalar(a.live, digits));
332
+ lines.push(`${fieldName([a.stepId, a.question])}${a.field.startsWith("[") ? "" : "."}${a.field}: ${recorded} -> ${live}` +
333
+ (a.delta !== undefined ? ` (${signed(a.delta, digits)})` : ""));
334
+ }
335
+ return lines.length ? lines.join("\n") : "no changes from fixture";
336
+ }
@@ -1,6 +1,9 @@
1
1
  import type { Questions, ResultFor, SystemOneRequest, TypeSafeClient } from "@typesafe-ai/sdk";
2
2
  import type { FlowDefinition, FlowDisplay, FlowRun } from "../src/flow-protocol.js";
3
3
  export type { FlowDisplay, FlowDefinition, FlowRun, FlowStep, } from "../src/flow-protocol.js";
4
+ export { replayClient, type FlowFixture } from "./replay.js";
5
+ export { flowFixture, type BuiltFlowFixture } from "../src/flow-fixture.js";
6
+ export { compareWithFixture, formatComparison, type AnswerChange, type ComparableFlow, type FixtureComparison, } from "./compare.js";
4
7
  type Ast = {
5
8
  op: "input";
6
9
  } | {
@@ -74,6 +77,7 @@ export type FlowRequestFactory<I, Q extends Questions> = {
74
77
  export type FlowObserver = {
75
78
  startFlow(definition: FlowDefinition, run: FlowRun): {
76
79
  update(run: FlowRun): void;
80
+ /** Call through synchronously to establish recording context. The runner owns the result. */
77
81
  request<T>(link: {
78
82
  runId: string;
79
83
  stepId: string;
package/lib/flow/index.js CHANGED
@@ -1,5 +1,9 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { jsonSnapshot, runCallback } from "./runtime.js";
2
+ import { jsonSnapshot, observerSnapshot, observeRequest, runCallback, } from "./runtime.js";
3
+ import { stepContext } from "./step-context.js";
4
+ export { replayClient } from "./replay.js";
5
+ export { flowFixture } from "../src/flow-fixture.js";
6
+ export { compareWithFixture, formatComparison, } from "./compare.js";
3
7
  const owners = new WeakMap();
4
8
  const builderIds = new WeakMap();
5
9
  function freezeAst(ast, depth = 0) {
@@ -64,8 +68,10 @@ export const any = (...conditions) => operation("any", conditions);
64
68
  export const not = (condition) => operation("not", [condition]);
65
69
  // Nested programs share a reservation budget without exposing execution internals in the API.
66
70
  const executionBudgets = new WeakMap();
67
- function withBudget(options, budget) {
71
+ const itemScopes = new WeakMap();
72
+ function withBudget(options, budget, scope) {
68
73
  executionBudgets.set(options, budget);
74
+ itemScopes.set(options, scope);
69
75
  return options;
70
76
  }
71
77
  function definitionSize(definition) {
@@ -650,6 +656,11 @@ export function defineJevFlow(name, build) {
650
656
  };
651
657
  const started = performance.now(), values = new Map();
652
658
  const budget = executionBudgets.get(options) ?? { used: nodes.length };
659
+ const scope = itemScopes.get(options) ?? {
660
+ runId: run.id,
661
+ flow: name,
662
+ prefix: "",
663
+ };
653
664
  const reserve = (count) => {
654
665
  if (budget.used + count > 1024)
655
666
  throw new RangeError("Flow exceeds 1024 expanded steps");
@@ -662,16 +673,56 @@ export function defineJevFlow(name, build) {
662
673
  catch {
663
674
  /* Recording cannot stop execution. */
664
675
  }
676
+ // Data objects are replaced, never mutated by execution. Keep detached
677
+ // copies stable so the recorder can reuse its captures on unrelated updates.
678
+ const observerData = new WeakMap();
679
+ const snapshotData = (value) => {
680
+ if (value && typeof value === "object" && observerData.has(value))
681
+ return observerData.get(value);
682
+ let snapshot;
683
+ try {
684
+ snapshot = observerSnapshot(value);
685
+ }
686
+ catch {
687
+ // Direct flow outputs may be non-JSON; only their capture is unavailable.
688
+ snapshot = "[capture unavailable]";
689
+ }
690
+ if (value && typeof value === "object")
691
+ observerData.set(value, snapshot);
692
+ return snapshot;
693
+ };
665
694
  const emit = () => {
666
695
  try {
667
696
  if (observer) {
668
697
  const { value, steps, ...metadata } = run;
669
- // Stable data snapshots are captured once by the recorder, not
670
- // cloned/redacted again on every unrelated step update.
671
698
  observer.update({
672
699
  ...structuredClone(metadata),
673
- steps: Object.fromEntries(Object.entries(steps).map(([id, step]) => [id, { ...step }])),
674
- ...(value !== undefined ? { value } : {}),
700
+ steps: Object.fromEntries(Object.entries(steps).map(([id, step]) => [
701
+ id,
702
+ {
703
+ ...step,
704
+ ...(step.data !== undefined
705
+ ? { data: snapshotData(step.data) }
706
+ : {}),
707
+ ...(step.loop ? { loop: { ...step.loop } } : {}),
708
+ ...(step.captureWarnings
709
+ ? { captureWarnings: [...step.captureWarnings] }
710
+ : {}),
711
+ ...(step.collection
712
+ ? {
713
+ collection: {
714
+ ...step.collection,
715
+ // Child runs have already crossed this snapshot boundary.
716
+ // Copy mutable collection metadata, reuse detached children.
717
+ items: step.collection.items.map((item) => ({
718
+ ...item,
719
+ })),
720
+ },
721
+ }
722
+ : {}),
723
+ },
724
+ ])),
725
+ ...(value !== undefined ? { value: snapshotData(value) } : {}),
675
726
  });
676
727
  }
677
728
  }
@@ -728,23 +779,31 @@ export function defineJevFlow(name, build) {
728
779
  return evaluate(e.ast, data, values);
729
780
  });
730
781
  abortCheck();
731
- const call = () => options.client.systemOne(request, {
732
- signal: controller.signal,
733
- });
782
+ const call = () => {
783
+ abortCheck();
784
+ return stepContext.run({
785
+ runId: scope.runId,
786
+ stepId: scope.prefix + step.id,
787
+ executionId: state.executionId,
788
+ flow: scope.flow,
789
+ }, () => options.client.systemOne(request, {
790
+ signal: controller.signal,
791
+ }));
792
+ };
734
793
  const link = {
735
794
  runId: run.id,
736
795
  stepId: step.id,
737
796
  executionId: state.executionId,
738
797
  };
739
- value = await runCallback(() => observer
740
- ? observer.request(link, (id) => {
798
+ value = await runCallback(() => observeRequest(call, observer
799
+ ? (callOnce) => observer.request(link, (id) => {
741
800
  if (controller.signal.aborted ||
742
801
  state.status !== "running")
743
802
  return;
744
803
  state.traceId = id;
745
804
  emit();
746
- }, call)
747
- : call(), controller.signal);
805
+ }, callOnce)
806
+ : undefined), controller.signal);
748
807
  abortCheck();
749
808
  }
750
809
  else if (step.kind === "task") {
@@ -828,7 +887,7 @@ export function defineJevFlow(name, build) {
828
887
  };
829
888
  },
830
889
  },
831
- }, budget));
890
+ }, budget, { ...scope, prefix: `${scope.prefix}${step.id}/${index}/` }));
832
891
  abortCheck();
833
892
  current = jsonSnapshot(result.value);
834
893
  state.data = { input: initial, output: current };
@@ -897,7 +956,10 @@ export function defineJevFlow(name, build) {
897
956
  };
898
957
  },
899
958
  },
900
- }, budget));
959
+ }, budget, {
960
+ ...scope,
961
+ prefix: `${scope.prefix}${step.id}/${index}/`,
962
+ }));
901
963
  results[index] = result.value;
902
964
  }
903
965
  catch (error) {
@@ -1004,7 +1066,7 @@ export function defineJevFlow(name, build) {
1004
1066
  throw new Error("Flow must return a named output");
1005
1067
  run.status = "success";
1006
1068
  run.outcome = result.outcome;
1007
- // Only the recorder snapshots output data; runtime keeps the actual typed value.
1069
+ // emit() detaches the observer's output; callers keep the actual typed value.
1008
1070
  if (result.value !== undefined)
1009
1071
  run.value = result.value;
1010
1072
  return { runId: run.id, ...result };