jev-devtools 0.5.0 → 0.5.2

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,10 +1,10 @@
1
1
  # Jev DevTools
2
2
 
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)
3
+ [![npm version 0.5.2](https://img.shields.io/badge/npm-v0.5.2-E551BA?labelColor=0D0A0F&logo=npm)](https://www.npmjs.com/package/jev-devtools/v/0.5.2)
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
- **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.
7
+ **Source version: 0.5.2 — pending publication.** npm currently provides 0.5.1. Check the [release history](https://github.com/diegoag64/jev-devtools/blob/main/CHANGELOG.md) for compatibility notes. To try this release candidate, 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
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
10
 
@@ -39,6 +39,8 @@ Install from npm:
39
39
  pnpm add jev-devtools @typesafe-ai/sdk
40
40
  ```
41
41
 
42
+ Use ESM imports, matching React/React DOM **18.x or 19.x**, Node **22.12+**, and TypeSafe SDK **0.7.x**. TypeScript consumers need **5.4+** and compatible React/Node types. React and React DOM are required package peers even for server-only installations. See [compatibility and all public imports](https://github.com/diegoag64/jev-devtools/blob/main/docs/integration.md#compatibility-and-public-imports); `/server` and `/flow` run only on Node.
43
+
42
44
  To try local changes before a future release:
43
45
 
44
46
  ```sh
@@ -46,25 +48,25 @@ To try local changes before a future release:
46
48
  npm ci
47
49
  npm run pack:devtools
48
50
  # From the consuming app:
49
- npm install /absolute/path/to/jev-devtools-0.5.0.tgz
51
+ npm install /absolute/path/to/jev-devtools-0.5.2.tgz
50
52
  ```
51
53
 
52
54
  Mount once. The drawer loads its own styles; no CSS import is needed:
53
55
 
54
56
  ```tsx
55
- import { JevDevtools } from 'jev-devtools';
57
+ import { JevDevtools } from "jev-devtools";
56
58
 
57
- <JevDevtools enabled={import.meta.env.DEV} defaultOpen={false} />
59
+ <JevDevtools enabled={import.meta.env.DEV} defaultOpen={false} />;
58
60
  ```
59
61
 
60
62
  Create one shared Node adapter and instrumented client:
61
63
 
62
64
  ```ts
63
- import { createJevDevtools } from 'jev-devtools/server';
65
+ import { createJevDevtools } from "jev-devtools/server";
64
66
 
65
67
  const inspector = createJevDevtools({
66
- enabled: process.env.NODE_ENV === 'development',
67
- origin: 'http://localhost:3000',
68
+ enabled: process.env.NODE_ENV === "development",
69
+ origin: "http://localhost:3000",
68
70
  });
69
71
  const jev = inspector.createClient({ apiKey: process.env.TYPESAFE_API_KEY });
70
72
  app.use(inspector.middleware); // Mount at root, before fallback handlers.
@@ -72,7 +74,7 @@ app.use(inspector.middleware); // Mount at root, before fallback handlers.
72
74
  // Call inspector.dispose() on shutdown.
73
75
  ```
74
76
 
75
- Only calls through that client are captured. The React component subscribes via same-origin SSE, receiving an initial snapshot followed by changed traces. Older client/server combinations remain compatible through full snapshots. Both sides default to disabled. API keys remain server-side. Browser code is SSR-safe. The drawer uses a Shadow DOM boundary and its own stylesheet so host page selectors cannot alter its internal layout. Existing stylesheet imports remain supported; no Tailwind or shadcn installation is required. Drag the Flow canvas to pan, scroll/pinch to zoom, or use Fit.
77
+ Only calls through that client are captured. The React component subscribes via same-origin SSE, receiving an initial snapshot followed by changed traces. Older client/server combinations remain compatible through full snapshots. Both sides default to disabled. API keys remain server-side. Browser code is SSR-safe. The drawer uses a Shadow DOM boundary and its own stylesheet so host page selectors cannot alter its internal layout. No Tailwind or shadcn installation is required. Drag the Flow canvas to pan, scroll/pinch to zoom, or use Fit.
76
78
 
77
79
  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.
78
80
 
@@ -93,27 +95,49 @@ Define calls, conditions, parallel checks, and named outcomes with `jev-devtools
93
95
  On the server, reuse the instrumented `jev` client and `inspector` from the setup above:
94
96
 
95
97
  ```ts
96
- import { defineJevFlow } from 'jev-devtools/flow';
97
-
98
- const review = defineJevFlow<{ message: string }>('review', (flow) => {
99
- const decision = flow.jev('readiness', {
100
- state: flow.input,
101
- questions: { ready: { type: 'noul', instructions: 'Is this ready for review?' } },
102
- }, { title: 'Assess readiness' });
103
-
104
- return flow.branch('route', {
105
- when: decision.answer('ready').noul.gte(0.9), // Your application's policy.
106
- then: (path) => path.output('ready', { probability: decision.answer('ready').noul }, {
107
- title: 'Ready for review', description: 'Return readiness to the application.',
108
- }),
109
- else: (path) => path.output('manual-review', undefined, { title: 'Needs manual review' }),
110
- }, { title: 'Require at least 90% readiness' });
98
+ import { defineJevFlow } from "jev-devtools/flow";
99
+
100
+ const review = defineJevFlow<{ message: string }>("review", (flow) => {
101
+ const decision = flow.jev(
102
+ "readiness",
103
+ {
104
+ state: flow.input,
105
+ questions: {
106
+ ready: { type: "noul", instructions: "Is this ready for review?" },
107
+ },
108
+ },
109
+ { title: "Assess readiness" },
110
+ );
111
+
112
+ return flow.branch(
113
+ "route",
114
+ {
115
+ when: decision.answer("ready").noul.gte(0.9), // Your application's policy.
116
+ then: (path) =>
117
+ path.output(
118
+ "ready",
119
+ { probability: decision.answer("ready").noul },
120
+ {
121
+ title: "Ready for review",
122
+ description: "Return readiness to the application.",
123
+ },
124
+ ),
125
+ else: (path) =>
126
+ path.output("manual-review", undefined, {
127
+ title: "Needs manual review",
128
+ }),
129
+ },
130
+ { title: "Require at least 90% readiness" },
131
+ );
111
132
  });
112
133
 
113
- const result = await review.run({ message: 'Please review my ticket.' }, {
114
- client: jev,
115
- devtools: inspector,
116
- });
134
+ const result = await review.run(
135
+ { message: "Please review my ticket." },
136
+ {
137
+ client: jev,
138
+ devtools: inspector,
139
+ },
140
+ );
117
141
  // Handle result.outcome in your app. The flow does not execute business actions.
118
142
  ```
119
143
 
@@ -26,7 +26,8 @@ export declare class FlowExpression<T> {
26
26
  readonly ast: Ast;
27
27
  protected readonly [valueTypeBrand]: T;
28
28
  constructor(ast: Ast, owner?: object);
29
- get<K extends keyof T & (string | number)>(key: K): FlowExpression<T[K]>;
29
+ /** Select an own field from an object or array; primitive properties are unsupported. */
30
+ get<K extends keyof T & (string | number)>(this: FlowExpression<object>, key: K): FlowExpression<T[K]>;
30
31
  eq(value: T | FlowExpression<T>): FlowExpression<boolean>;
31
32
  lt(this: FlowExpression<number>, value: number | FlowExpression<number>): FlowExpression<boolean>;
32
33
  gt(this: FlowExpression<number>, value: number | FlowExpression<number>): FlowExpression<boolean>;
@@ -52,7 +53,7 @@ type Request<Q extends Questions> = Omit<SystemOneRequest<Q>, "state"> & {
52
53
  state: unknown;
53
54
  };
54
55
  /** Resolve nested references before invoking a server callback. */
55
- export type Resolved<T> = T extends FlowExpression<infer V> ? V : T extends readonly unknown[] ? {
56
+ export type Resolved<T> = T extends FlowExpression<infer V> ? V : T extends string | number | boolean | null | undefined ? T : T extends readonly unknown[] ? {
56
57
  [K in keyof T]: Resolved<T[K]>;
57
58
  } : T extends object ? {
58
59
  [K in keyof T]: Resolved<T[K]>;
@@ -99,9 +100,11 @@ export interface FlowBuilder<T> {
99
100
  jev<const Q extends Questions>(id: string, request: Request<Q>, display?: FlowDisplay): AnswerRefs<Q>;
100
101
  jev<I, const Q extends Questions>(id: string, request: FlowRequestFactory<I, Q>, display?: FlowDisplay): AnswerRefs<Q>;
101
102
  task<I, O>(id: string, task: FlowTask<I, O>, display?: FlowDisplay): FlowExpression<Awaited<O>>;
102
- /** Execute a reusable item program with bounded concurrency; return results in input order. */
103
+ /** References and scalar arrays already carry their resolved item type. */
103
104
  map<I, C, O>(id: string, options: {
104
- items: FlowExpression<readonly I[]> | readonly I[];
105
+ items: FlowExpression<readonly I[]> | (readonly I[] & readonly (string | number | {
106
+ valueOf(): boolean;
107
+ } | null)[]);
105
108
  input: C;
106
109
  concurrency?: number;
107
110
  maxItems: number;
@@ -110,17 +113,43 @@ export interface FlowBuilder<T> {
110
113
  index: number;
111
114
  context: Resolved<C>;
112
115
  }>) => FlowExpression<O>, display?: FlowDisplay): FlowExpression<O[]>;
113
- /** Pre-test loop with JSON state, explicit context and a required iteration limit. */
116
+ /** Execute a reusable program over resolved items; return results in input order. */
117
+ map<I, C, O>(id: string, options: {
118
+ items: FlowExpression<readonly I[]> | readonly I[];
119
+ input: C;
120
+ concurrency?: number;
121
+ maxItems: number;
122
+ }, build: (item: FlowBuilder<{
123
+ item: Resolved<I>;
124
+ index: number;
125
+ context: Resolved<C>;
126
+ }>) => FlowExpression<O>, display?: FlowDisplay): FlowExpression<O[]>;
127
+ /** References and scalar initial values preserve their generic state type. */
114
128
  loop<S, C>(id: string, options: {
115
129
  initial: S | FlowExpression<S>;
116
130
  input: C;
117
131
  while: (state: FlowExpression<NoInfer<S>>) => FlowExpression<boolean>;
118
132
  maxIterations: number;
133
+ } & {
134
+ initial: FlowExpression<S> | string | number | {
135
+ valueOf(): boolean;
136
+ } | null;
119
137
  }, build: (iteration: FlowBuilder<{
120
138
  state: S;
121
139
  index: number;
122
140
  context: Resolved<C>;
123
141
  }>) => FlowExpression<NoInfer<S>>, display?: FlowDisplay): FlowExpression<S>;
142
+ /** Pre-test loop over resolved JSON state, with explicit context and an iteration limit. */
143
+ loop<S, C>(id: string, options: {
144
+ initial: S | FlowExpression<S>;
145
+ input: C;
146
+ while: (state: FlowExpression<NoInfer<Resolved<S>>>) => FlowExpression<boolean>;
147
+ maxIterations: number;
148
+ }, build: (iteration: FlowBuilder<{
149
+ state: Resolved<S>;
150
+ index: number;
151
+ context: Resolved<C>;
152
+ }>) => FlowExpression<NoInfer<Resolved<S>>>, display?: FlowDisplay): FlowExpression<Resolved<S>>;
124
153
  /** Execute one value-returning path, then continue with its typed result. */
125
154
  if<A, B>(id: string, paths: {
126
155
  when: FlowExpression<boolean>;
package/lib/flow/index.js CHANGED
@@ -6,21 +6,38 @@ export { flowFixture } from "../src/flow-fixture.js";
6
6
  export { compareWithFixture, formatComparison, } from "./compare.js";
7
7
  const owners = new WeakMap();
8
8
  const builderIds = new WeakMap();
9
+ const astBudgets = new WeakMap();
9
10
  function freezeAst(ast, depth = 0) {
10
11
  if (depth > 80)
11
12
  throw new Error("Expression nesting exceeds 80");
12
- if (Object.isFrozen(ast))
13
- return;
13
+ const cached = astBudgets.get(ast);
14
+ if (cached) {
15
+ if (depth + cached.depth > 80)
16
+ throw new Error("Expression nesting exceeds 80");
17
+ return cached;
18
+ }
19
+ const budget = { depth: 0, nodes: 1 };
20
+ const include = (child) => {
21
+ const childBudget = freezeAst(child, depth + 1);
22
+ budget.depth = Math.max(budget.depth, childBudget.depth + 1);
23
+ // Visitors expand each occurrence, even when multiple operands share a child.
24
+ budget.nodes += childBudget.nodes;
25
+ if (budget.nodes > 4096)
26
+ throw new Error("Expression size exceeds 4096 expanded nodes");
27
+ };
14
28
  if (ast.op === "get")
15
- freezeAst(ast.from, depth + 1);
29
+ include(ast.from);
16
30
  if ("args" in ast) {
17
31
  if (ast.args.length > 256)
18
32
  throw new Error("Too many condition operands");
19
33
  for (const child of ast.args)
20
- freezeAst(child, depth + 1);
34
+ include(child);
21
35
  Object.freeze(ast.args);
22
36
  }
23
37
  Object.freeze(ast);
38
+ // Frozen caller-supplied ASTs still need validation; only our cache is trusted.
39
+ astBudgets.set(ast, budget);
40
+ return budget;
24
41
  }
25
42
  /** Inspectable expression, not an application callback. Use get() for input fields. */
26
43
  export class FlowExpression {
@@ -32,6 +49,7 @@ export class FlowExpression {
32
49
  freezeAst(ast);
33
50
  Object.freeze(this);
34
51
  }
52
+ /** Select an own field from an object or array; primitive properties are unsupported. */
35
53
  get(key) {
36
54
  return expression({ op: "get", from: this.ast, key: String(key) }, [this]);
37
55
  }
@@ -89,7 +107,7 @@ const plain = (v) => !!v &&
89
107
  typeof v === "object" &&
90
108
  (Object.getPrototypeOf(v) === Object.prototype ||
91
109
  Object.getPrototypeOf(v) === null);
92
- function mapValue(value, ref, seen = new Set(), depth = 0) {
110
+ function mapValue(value, ref, rejectSymbolKeys = false, seen = new Set(), depth = 0) {
93
111
  if (depth > 40)
94
112
  throw new Error("Flow value nesting exceeds 40");
95
113
  if (value instanceof FlowExpression)
@@ -98,14 +116,17 @@ function mapValue(value, ref, seen = new Set(), depth = 0) {
98
116
  throw new Error("Flow values cannot contain callbacks");
99
117
  if (!Array.isArray(value) && !plain(value))
100
118
  return value; // Preserve SDK assets.
119
+ // Task templates must reject keys that copying would otherwise silently discard.
120
+ if (rejectSymbolKeys && Object.getOwnPropertySymbols(value).length)
121
+ throw new TypeError("Task data cannot contain symbol keys");
101
122
  if (seen.has(value))
102
123
  throw new Error("Cyclic flow value");
103
124
  seen.add(value);
104
125
  const result = Array.isArray(value)
105
- ? value.map((v) => mapValue(v, ref, seen, depth + 1))
126
+ ? value.map((v) => mapValue(v, ref, rejectSymbolKeys, seen, depth + 1))
106
127
  : Object.fromEntries(Object.entries(value).map(([k, v]) => [
107
128
  k,
108
- mapValue(v, ref, seen, depth + 1),
129
+ mapValue(v, ref, rejectSymbolKeys, seen, depth + 1),
109
130
  ]));
110
131
  seen.delete(value);
111
132
  return result;
@@ -119,21 +140,47 @@ const inspectAst = (ast, ref) => {
119
140
  ast.args.forEach((a) => inspectAst(a, ref));
120
141
  };
121
142
  function describe(ast) {
122
- if (ast.op === "input")
123
- return "input";
124
- if (ast.op === "ref")
125
- return ast.id;
126
- if (ast.op === "get")
127
- return `${describe(ast.from)}.${ast.key}`;
128
- if (ast.op === "literal")
129
- return "[literal]";
130
- return `${ast.op}(${ast.args.map(describe).join(", ")})`;
143
+ const limit = 4096;
144
+ const chunks = [];
145
+ let remaining = limit;
146
+ const append = (text) => {
147
+ chunks.push(text.slice(0, remaining));
148
+ const fits = text.length <= remaining;
149
+ remaining -= Math.min(text.length, remaining);
150
+ return fits;
151
+ };
152
+ const visit = (node) => {
153
+ if (node.op === "input")
154
+ return append("input");
155
+ if (node.op === "ref")
156
+ return append(node.id);
157
+ if (node.op === "get")
158
+ return visit(node.from) && append(".") && append(node.key);
159
+ if (node.op === "literal")
160
+ return append("[literal]");
161
+ if (!append(node.op) || !append("("))
162
+ return false;
163
+ for (let i = 0; i < node.args.length; i++) {
164
+ if (i > 0 && !append(", "))
165
+ return false;
166
+ if (!visit(node.args[i]))
167
+ return false;
168
+ }
169
+ return append(")");
170
+ };
171
+ const complete = visit(ast);
172
+ const text = chunks.join("");
173
+ const marker = "[condition truncated]";
174
+ return complete ? text : text.slice(0, limit - marker.length) + marker;
131
175
  }
132
176
  function conditionRule(ast, requests, tasks) {
133
177
  let remaining = 128;
178
+ const warnings = new Set();
134
179
  const visit = (node) => {
135
- if (--remaining < 0)
180
+ if (--remaining < 0) {
181
+ warnings.add("Rule projection limit reached.");
136
182
  return { op: "literal", value: "[rule truncated]" };
183
+ }
137
184
  if (node.op === "input" || node.op === "ref" || node.op === "get") {
138
185
  const path = [];
139
186
  let base = node;
@@ -175,16 +222,20 @@ function conditionRule(ast, requests, tasks) {
175
222
  : {}),
176
223
  };
177
224
  }
178
- if (node.op === "literal")
225
+ if (node.op === "literal") {
226
+ const scalar = node.value === null ||
227
+ typeof node.value === "string" ||
228
+ typeof node.value === "boolean" ||
229
+ (typeof node.value === "number" && Number.isFinite(node.value));
230
+ if (!scalar)
231
+ warnings.add("Rule literal capture unavailable.");
179
232
  return {
180
233
  op: "literal",
181
- value: node.value === null ||
182
- typeof node.value === "string" ||
183
- typeof node.value === "boolean" ||
184
- (typeof node.value === "number" && Number.isFinite(node.value))
234
+ value: scalar
185
235
  ? node.value
186
236
  : "[value unavailable]",
187
237
  };
238
+ }
188
239
  const args = [];
189
240
  for (const arg of node.args) {
190
241
  args.push(visit(arg));
@@ -193,7 +244,8 @@ function conditionRule(ast, requests, tasks) {
193
244
  }
194
245
  return { op: node.op, args };
195
246
  };
196
- return visit(ast);
247
+ const rule = visit(ast);
248
+ return { conditionRule: rule, conditionCaptureWarnings: [...warnings] };
197
249
  }
198
250
  function evaluate(ast, input, values) {
199
251
  if (ast.op === "input")
@@ -380,7 +432,7 @@ export function defineJevFlow(name, build) {
380
432
  task.timeoutMs < 1 ||
381
433
  task.timeoutMs > 2_147_483_647))
382
434
  throw new Error("Task timeoutMs must be a positive 32-bit integer");
383
- const frozen = mapValue(task.input, (expr) => expr);
435
+ const frozen = mapValue(task.input, (expr) => expr, true);
384
436
  const step = add(id, "task", frozen, display);
385
437
  step.input = frozen;
386
438
  step.task = task.run;
@@ -488,7 +540,7 @@ export function defineJevFlow(name, build) {
488
540
  return { ...ast, args: ast.args.map(referenceState) };
489
541
  return ast;
490
542
  };
491
- node.conditionRule = conditionRule(referenceState(when.ast), new Set(), new Set([id]));
543
+ Object.assign(node, conditionRule(referenceState(when.ast), new Set(), new Set([id])));
492
544
  return new FlowExpression({ op: "ref", id }, owner);
493
545
  },
494
546
  if(id, paths, display) {
@@ -498,9 +550,11 @@ export function defineJevFlow(name, build) {
498
550
  step.when = paths.when;
499
551
  const node = nodes.find((n) => n.id === id);
500
552
  node.condition = describe(paths.when.ast);
501
- node.conditionRule = conditionRule(paths.when.ast, new Set(nodes.filter((n) => n.kind === "jev").map((n) => n.id)), new Set(nodes
502
- .filter((n) => n.kind === "task" || n.kind === "merge" || n.kind === "loop")
503
- .map((n) => n.id)));
553
+ Object.assign(node, conditionRule(paths.when.ast, new Set(nodes.filter((n) => n.kind === "jev").map((n) => n.id)), new Set(nodes
554
+ .filter((n) => n.kind === "task" ||
555
+ n.kind === "merge" ||
556
+ n.kind === "loop")
557
+ .map((n) => n.id))));
504
558
  step.then = block(paths.then, new Set(available), [...path, `${id}:then`], [id], depth + 1, false, true);
505
559
  step.else = block(paths.else, new Set(available), [...path, `${id}:else`], [id], depth + 1, false, true);
506
560
  // A separate merge keeps the diagram acyclic and gives downstream refs a
@@ -530,9 +584,11 @@ export function defineJevFlow(name, build) {
530
584
  throw new Error("branch requires a condition expression");
531
585
  const node = nodes.find((n) => n.id === id);
532
586
  node.condition = describe(paths.when.ast);
533
- node.conditionRule = conditionRule(paths.when.ast, new Set(nodes.filter((n) => n.kind === "jev").map((n) => n.id)), new Set(nodes
534
- .filter((n) => n.kind === "task" || n.kind === "merge" || n.kind === "loop")
535
- .map((n) => n.id)));
587
+ Object.assign(node, conditionRule(paths.when.ast, new Set(nodes.filter((n) => n.kind === "jev").map((n) => n.id)), new Set(nodes
588
+ .filter((n) => n.kind === "task" ||
589
+ n.kind === "merge" ||
590
+ n.kind === "loop")
591
+ .map((n) => n.id))));
536
592
  step.then = block(paths.then, new Set(available), [...path, `${id}:then`], [id], depth + 1, true);
537
593
  step.else = block(paths.else, new Set(available), [...path, `${id}:else`], [id], depth + 1, true);
538
594
  terminal = true;
@@ -620,6 +676,7 @@ export function defineJevFlow(name, build) {
620
676
  if (node.lanes)
621
677
  Object.freeze(node.lanes);
622
678
  freezeRule(node.conditionRule);
679
+ freezeRule(node.conditionCaptureWarnings);
623
680
  freezeRule(node.itemDefinition);
624
681
  Object.freeze(node);
625
682
  }
@@ -680,49 +737,73 @@ export function defineJevFlow(name, build) {
680
737
  if (value && typeof value === "object" && observerData.has(value))
681
738
  return observerData.get(value);
682
739
  let snapshot;
740
+ const warnings = [];
683
741
  try {
684
742
  snapshot = observerSnapshot(value);
685
743
  }
686
744
  catch {
687
745
  // Direct flow outputs may be non-JSON; only their capture is unavailable.
688
746
  snapshot = "[capture unavailable]";
747
+ warnings.push("Observer capture unavailable.");
689
748
  }
749
+ const captured = { data: snapshot, warnings };
690
750
  if (value && typeof value === "object")
691
- observerData.set(value, snapshot);
692
- return snapshot;
751
+ observerData.set(value, captured);
752
+ return captured;
693
753
  };
694
754
  const emit = () => {
695
755
  try {
696
756
  if (observer) {
697
757
  const { value, steps, ...metadata } = run;
758
+ const outcome = value !== undefined ? snapshotData(value) : undefined;
698
759
  observer.update({
699
760
  ...structuredClone(metadata),
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) } : {}),
761
+ steps: Object.fromEntries(Object.entries(steps).map(([id, step]) => {
762
+ const data = step.data !== undefined
763
+ ? snapshotData(step.data)
764
+ : undefined;
765
+ return [
766
+ id,
767
+ {
768
+ ...step,
769
+ ...(data ? { data: data.data } : {}),
770
+ ...(step.loop ? { loop: { ...step.loop } } : {}),
771
+ ...(data || step.captureWarnings
772
+ ? {
773
+ captureWarnings: [
774
+ ...new Set([
775
+ ...(step.captureWarnings ?? []),
776
+ ...(data?.warnings ?? []),
777
+ ]),
778
+ ],
779
+ }
780
+ : {}),
781
+ ...(step.collection
782
+ ? {
783
+ collection: {
784
+ ...step.collection,
785
+ // Child runs have already crossed this snapshot boundary.
786
+ // Copy mutable collection metadata, reuse detached children.
787
+ items: step.collection.items.map((item) => ({
788
+ ...item,
789
+ })),
790
+ },
791
+ }
792
+ : {}),
793
+ },
794
+ ];
795
+ })),
796
+ ...(outcome
797
+ ? {
798
+ value: outcome.data,
799
+ captureWarnings: [
800
+ ...new Set([
801
+ ...(run.captureWarnings ?? []),
802
+ ...outcome.warnings,
803
+ ]),
804
+ ],
805
+ }
806
+ : {}),
726
807
  });
727
808
  }
728
809
  }
@@ -807,7 +888,7 @@ export function defineJevFlow(name, build) {
807
888
  abortCheck();
808
889
  }
809
890
  else if (step.kind === "task") {
810
- const input = jsonSnapshot(mapValue(step.input, (e) => evaluate(e.ast, data, values)));
891
+ const input = jsonSnapshot(mapValue(step.input, (e) => evaluate(e.ast, data, values), true));
811
892
  state.data = { input };
812
893
  emit();
813
894
  value = jsonSnapshot(await runCallback((signal) => step.task(jsonSnapshot(input), {
@@ -1000,6 +1081,7 @@ export function defineJevFlow(name, build) {
1000
1081
  }
1001
1082
  catch {
1002
1083
  state.data = "Selected value could not be captured as JSON.";
1084
+ state.captureWarnings = ["Selected value capture unavailable."];
1003
1085
  }
1004
1086
  }
1005
1087
  else if (step.kind === "branch" || step.kind === "if") {
@@ -36,14 +36,17 @@ export class FlowRecorder {
36
36
  redactRules(node.itemDefinition, [...prefix, node.id, "items"]);
37
37
  if (node.conditionRule === undefined)
38
38
  continue;
39
+ const warnings = new Set(node.conditionCaptureWarnings);
39
40
  try {
40
- node.conditionRule = capture(node.conditionRule, new Set(), this.options.redact
41
+ node.conditionRule = capture(node.conditionRule, warnings, this.options.redact
41
42
  ? (path, value) => this.options.redact(["conditions", ...prefix, node.id, ...path], value)
42
43
  : undefined);
43
44
  }
44
45
  catch {
45
46
  node.conditionRule = "[rule capture unavailable]";
47
+ warnings.add("Rule capture unavailable.");
46
48
  }
49
+ node.conditionCaptureWarnings = [...warnings];
47
50
  }
48
51
  };
49
52
  redactRules(stored);
@@ -117,20 +120,28 @@ export class FlowRecorder {
117
120
  {
118
121
  ...step,
119
122
  data: captured.data,
120
- captureWarnings: captured.warnings,
123
+ captureWarnings: [
124
+ ...new Set([
125
+ ...(step.captureWarnings ?? []),
126
+ ...captured.warnings,
127
+ ]),
128
+ ],
121
129
  },
122
130
  ];
123
131
  })),
124
132
  };
125
133
  if ("value" in stored) {
134
+ const warnings = new Set(run.captureWarnings);
126
135
  try {
127
- stored.value = capture(stored.value, new Set(), this.options.redact
136
+ stored.value = capture(stored.value, warnings, this.options.redact
128
137
  ? (path, value) => this.options.redact([...prefix, ...path], value)
129
138
  : undefined);
130
139
  }
131
140
  catch {
132
141
  stored.value = "[capture unavailable]";
142
+ warnings.add("Outcome capture unavailable.");
133
143
  }
144
+ stored.captureWarnings = [...warnings];
134
145
  }
135
146
  if (prefix.length)
136
147
  this.itemCaptures.set(run, stored);
@@ -36,10 +36,16 @@ export function capture(value, warnings, redact) {
36
36
  let budget = 96_000;
37
37
  const visit = (input, path, depth) => {
38
38
  const key = (path.at(-1) ?? "").replace(/[-_]/g, "").toLowerCase();
39
- if (credentialKeys.has(key))
39
+ if (credentialKeys.has(key)) {
40
+ warnings.add("Capture contains redacted values.");
40
41
  return "[redacted]";
41
- if (redact)
42
- input = redact(path, input);
42
+ }
43
+ if (redact) {
44
+ const redacted = redact(path, input);
45
+ if (!Object.is(redacted, input))
46
+ warnings.add("Capture contains redacted values.");
47
+ input = redacted;
48
+ }
43
49
  budget -= 8 + (path.at(-1)?.length ?? 0);
44
50
  if (budget <= 0 || depth > 12) {
45
51
  warnings.add("Capture size/depth limit reached.");