jev-devtools 0.4.0 → 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.
Files changed (37) hide show
  1. package/README.md +20 -13
  2. package/lib/flow/compare.d.ts +61 -0
  3. package/lib/flow/compare.js +336 -0
  4. package/lib/flow/index.d.ts +6 -1
  5. package/lib/flow/index.js +78 -16
  6. package/lib/flow/replay.d.ts +5 -0
  7. package/lib/flow/replay.js +55 -0
  8. package/lib/flow/runtime.d.ts +4 -0
  9. package/lib/flow/runtime.js +74 -0
  10. package/lib/flow/step-context.d.ts +7 -0
  11. package/lib/flow/step-context.js +3 -0
  12. package/lib/server/flows.d.ts +2 -2
  13. package/lib/server/flows.js +4 -6
  14. package/lib/server/index.js +16 -4
  15. package/lib/server/recorder.js +42 -6
  16. package/lib/src/ConnectedInspector.d.ts +1 -1
  17. package/lib/src/ConnectedInspector.js +14 -3
  18. package/lib/src/CopyFixture.d.ts +22 -0
  19. package/lib/src/CopyFixture.js +67 -0
  20. package/lib/src/FlowOverview.js +4 -2
  21. package/lib/src/JevDevtools.js +1 -1
  22. package/lib/src/JevDrawer.js +2 -4
  23. package/lib/src/display-redaction.d.ts +9 -0
  24. package/lib/src/display-redaction.js +116 -0
  25. package/lib/src/flow-fixture.d.ts +19 -0
  26. package/lib/src/flow-fixture.js +40 -0
  27. package/lib/src/flow-stream.js +9 -0
  28. package/lib/src/index.d.ts +5 -0
  29. package/lib/src/index.js +1 -0
  30. package/lib/src/inspector-styles.js +1 -1
  31. package/lib/src/inspector.css +207 -267
  32. package/package.json +2 -2
  33. package/skills/setup-jev-devtools/SKILL.md +8 -5
  34. package/skills/setup-jev-devtools/references/flows.md +15 -2
  35. package/skills/setup-jev-devtools/references/nextjs.md +1 -1
  36. package/skills/setup-jev-devtools/references/react-node.md +1 -2
  37. package/skills/setup-jev-devtools/references/verify.md +2 -4
package/README.md CHANGED
@@ -1,12 +1,14 @@
1
1
  # Jev DevTools
2
2
 
3
- [![npm version 0.4.0](https://img.shields.io/badge/npm-v0.4.0-E551BA?labelColor=0D0A0F&logo=npm)](https://www.npmjs.com/package/jev-devtools/v/0.4.0)
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
- **0.4.0 — pending publication.** Use a packed checkout until publication is confirmed in the changelog. **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
 
@@ -34,24 +36,23 @@ The [setup skill](https://github.com/diegoag64/jev-devtools/blob/main/packages/j
34
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
- To install this checkout before publication or try later local changes:
42
+ To try local changes before a future release:
41
43
 
42
44
  ```sh
43
45
  # From the jev-devtools repository root:
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.0.tgz
49
+ npm install /absolute/path/to/jev-devtools-0.5.0.tgz
48
50
  ```
49
51
 
50
- Mount once:
52
+ Mount once. The drawer loads its own styles; no CSS import is needed:
51
53
 
52
54
  ```tsx
53
55
  import { JevDevtools } from 'jev-devtools';
54
- import 'jev-devtools/styles.css';
55
56
 
56
57
  <JevDevtools enabled={import.meta.env.DEV} defaultOpen={false} />
57
58
  ```
@@ -75,15 +76,19 @@ Only calls through that client are captured. The React component subscribes via
75
76
 
76
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.
77
78
 
78
- 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.
79
82
 
80
- [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.
81
86
 
82
87
  MIT for project code. See bundled LICENSE and THIRD_PARTY_NOTICES.txt for original design attribution and TypeSafe branding.
83
88
 
84
89
  ## Connected Jev flows with `jev-devtools/flow`
85
90
 
86
- 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.
87
92
 
88
93
  On the server, reuse the instrumented `jev` client and `inspector` from the setup above:
89
94
 
@@ -112,9 +117,11 @@ const result = await review.run({ message: 'Please review my ticket.' }, {
112
117
  // Handle result.outcome in your app. The flow does not execute business actions.
113
118
  ```
114
119
 
115
- 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.
116
123
 
117
- [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)
118
125
 
119
126
  To have your coding agent add a flow after installation:
120
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
  } | {
@@ -17,10 +20,11 @@ type Ast = {
17
20
  op: "eq" | "lt" | "gt" | "gte" | "all" | "any" | "not";
18
21
  args: Ast[];
19
22
  };
23
+ declare const valueTypeBrand: unique symbol;
20
24
  /** Inspectable expression, not an application callback. Use get() for input fields. */
21
25
  export declare class FlowExpression<T> {
22
26
  readonly ast: Ast;
23
- private readonly valueType;
27
+ protected readonly [valueTypeBrand]: T;
24
28
  constructor(ast: Ast, owner?: object);
25
29
  get<K extends keyof T & (string | number)>(key: K): FlowExpression<T[K]>;
26
30
  eq(value: T | FlowExpression<T>): FlowExpression<boolean>;
@@ -73,6 +77,7 @@ export type FlowRequestFactory<I, Q extends Questions> = {
73
77
  export type FlowObserver = {
74
78
  startFlow(definition: FlowDefinition, run: FlowRun): {
75
79
  update(run: FlowRun): void;
80
+ /** Call through synchronously to establish recording context. The runner owns the result. */
76
81
  request<T>(link: {
77
82
  runId: string;
78
83
  stepId: string;