jev-devtools 0.4.1 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -12
- package/lib/flow/compare.d.ts +61 -0
- package/lib/flow/compare.js +336 -0
- package/lib/flow/index.d.ts +6 -1
- package/lib/flow/index.js +87 -21
- package/lib/flow/replay.d.ts +5 -0
- package/lib/flow/replay.js +55 -0
- package/lib/flow/runtime.d.ts +4 -0
- package/lib/flow/runtime.js +74 -0
- package/lib/flow/step-context.d.ts +7 -0
- package/lib/flow/step-context.js +3 -0
- package/lib/server/flows.d.ts +2 -2
- package/lib/server/flows.js +4 -6
- package/lib/server/index.js +16 -4
- package/lib/server/recorder.js +42 -6
- package/lib/src/ConnectedInspector.d.ts +1 -1
- package/lib/src/ConnectedInspector.js +14 -3
- package/lib/src/CopyFixture.d.ts +22 -0
- package/lib/src/CopyFixture.js +67 -0
- package/lib/src/FlowOverview.js +4 -2
- package/lib/src/JevDevtools.js +1 -1
- package/lib/src/display-redaction.d.ts +9 -0
- package/lib/src/display-redaction.js +116 -0
- package/lib/src/flow-fixture.d.ts +19 -0
- package/lib/src/flow-fixture.js +40 -0
- package/lib/src/flow-stream.js +9 -0
- package/lib/src/index.d.ts +5 -0
- package/lib/src/index.js +1 -0
- package/lib/src/inspector-styles.js +1 -1
- package/lib/src/inspector.css +76 -2
- package/package.json +2 -2
- package/skills/setup-jev-devtools/SKILL.md +21 -4
- package/skills/setup-jev-devtools/references/flows.md +17 -4
- package/skills/setup-jev-devtools/references/verify.md +1 -3
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) {
|
|
@@ -28,6 +32,7 @@ export class FlowExpression {
|
|
|
28
32
|
freezeAst(ast);
|
|
29
33
|
Object.freeze(this);
|
|
30
34
|
}
|
|
35
|
+
/** Select an own field from an object or array; primitive properties are unsupported. */
|
|
31
36
|
get(key) {
|
|
32
37
|
return expression({ op: "get", from: this.ast, key: String(key) }, [this]);
|
|
33
38
|
}
|
|
@@ -64,8 +69,10 @@ export const any = (...conditions) => operation("any", conditions);
|
|
|
64
69
|
export const not = (condition) => operation("not", [condition]);
|
|
65
70
|
// Nested programs share a reservation budget without exposing execution internals in the API.
|
|
66
71
|
const executionBudgets = new WeakMap();
|
|
67
|
-
|
|
72
|
+
const itemScopes = new WeakMap();
|
|
73
|
+
function withBudget(options, budget, scope) {
|
|
68
74
|
executionBudgets.set(options, budget);
|
|
75
|
+
itemScopes.set(options, scope);
|
|
69
76
|
return options;
|
|
70
77
|
}
|
|
71
78
|
function definitionSize(definition) {
|
|
@@ -83,7 +90,7 @@ const plain = (v) => !!v &&
|
|
|
83
90
|
typeof v === "object" &&
|
|
84
91
|
(Object.getPrototypeOf(v) === Object.prototype ||
|
|
85
92
|
Object.getPrototypeOf(v) === null);
|
|
86
|
-
function mapValue(value, ref, seen = new Set(), depth = 0) {
|
|
93
|
+
function mapValue(value, ref, rejectSymbolKeys = false, seen = new Set(), depth = 0) {
|
|
87
94
|
if (depth > 40)
|
|
88
95
|
throw new Error("Flow value nesting exceeds 40");
|
|
89
96
|
if (value instanceof FlowExpression)
|
|
@@ -92,14 +99,17 @@ function mapValue(value, ref, seen = new Set(), depth = 0) {
|
|
|
92
99
|
throw new Error("Flow values cannot contain callbacks");
|
|
93
100
|
if (!Array.isArray(value) && !plain(value))
|
|
94
101
|
return value; // Preserve SDK assets.
|
|
102
|
+
// Task templates must reject keys that copying would otherwise silently discard.
|
|
103
|
+
if (rejectSymbolKeys && Object.getOwnPropertySymbols(value).length)
|
|
104
|
+
throw new TypeError("Task data cannot contain symbol keys");
|
|
95
105
|
if (seen.has(value))
|
|
96
106
|
throw new Error("Cyclic flow value");
|
|
97
107
|
seen.add(value);
|
|
98
108
|
const result = Array.isArray(value)
|
|
99
|
-
? value.map((v) => mapValue(v, ref, seen, depth + 1))
|
|
109
|
+
? value.map((v) => mapValue(v, ref, rejectSymbolKeys, seen, depth + 1))
|
|
100
110
|
: Object.fromEntries(Object.entries(value).map(([k, v]) => [
|
|
101
111
|
k,
|
|
102
|
-
mapValue(v, ref, seen, depth + 1),
|
|
112
|
+
mapValue(v, ref, rejectSymbolKeys, seen, depth + 1),
|
|
103
113
|
]));
|
|
104
114
|
seen.delete(value);
|
|
105
115
|
return result;
|
|
@@ -374,7 +384,7 @@ export function defineJevFlow(name, build) {
|
|
|
374
384
|
task.timeoutMs < 1 ||
|
|
375
385
|
task.timeoutMs > 2_147_483_647))
|
|
376
386
|
throw new Error("Task timeoutMs must be a positive 32-bit integer");
|
|
377
|
-
const frozen = mapValue(task.input, (expr) => expr);
|
|
387
|
+
const frozen = mapValue(task.input, (expr) => expr, true);
|
|
378
388
|
const step = add(id, "task", frozen, display);
|
|
379
389
|
step.input = frozen;
|
|
380
390
|
step.task = task.run;
|
|
@@ -650,6 +660,11 @@ export function defineJevFlow(name, build) {
|
|
|
650
660
|
};
|
|
651
661
|
const started = performance.now(), values = new Map();
|
|
652
662
|
const budget = executionBudgets.get(options) ?? { used: nodes.length };
|
|
663
|
+
const scope = itemScopes.get(options) ?? {
|
|
664
|
+
runId: run.id,
|
|
665
|
+
flow: name,
|
|
666
|
+
prefix: "",
|
|
667
|
+
};
|
|
653
668
|
const reserve = (count) => {
|
|
654
669
|
if (budget.used + count > 1024)
|
|
655
670
|
throw new RangeError("Flow exceeds 1024 expanded steps");
|
|
@@ -662,16 +677,56 @@ export function defineJevFlow(name, build) {
|
|
|
662
677
|
catch {
|
|
663
678
|
/* Recording cannot stop execution. */
|
|
664
679
|
}
|
|
680
|
+
// Data objects are replaced, never mutated by execution. Keep detached
|
|
681
|
+
// copies stable so the recorder can reuse its captures on unrelated updates.
|
|
682
|
+
const observerData = new WeakMap();
|
|
683
|
+
const snapshotData = (value) => {
|
|
684
|
+
if (value && typeof value === "object" && observerData.has(value))
|
|
685
|
+
return observerData.get(value);
|
|
686
|
+
let snapshot;
|
|
687
|
+
try {
|
|
688
|
+
snapshot = observerSnapshot(value);
|
|
689
|
+
}
|
|
690
|
+
catch {
|
|
691
|
+
// Direct flow outputs may be non-JSON; only their capture is unavailable.
|
|
692
|
+
snapshot = "[capture unavailable]";
|
|
693
|
+
}
|
|
694
|
+
if (value && typeof value === "object")
|
|
695
|
+
observerData.set(value, snapshot);
|
|
696
|
+
return snapshot;
|
|
697
|
+
};
|
|
665
698
|
const emit = () => {
|
|
666
699
|
try {
|
|
667
700
|
if (observer) {
|
|
668
701
|
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
702
|
observer.update({
|
|
672
703
|
...structuredClone(metadata),
|
|
673
|
-
steps: Object.fromEntries(Object.entries(steps).map(([id, step]) => [
|
|
674
|
-
|
|
704
|
+
steps: Object.fromEntries(Object.entries(steps).map(([id, step]) => [
|
|
705
|
+
id,
|
|
706
|
+
{
|
|
707
|
+
...step,
|
|
708
|
+
...(step.data !== undefined
|
|
709
|
+
? { data: snapshotData(step.data) }
|
|
710
|
+
: {}),
|
|
711
|
+
...(step.loop ? { loop: { ...step.loop } } : {}),
|
|
712
|
+
...(step.captureWarnings
|
|
713
|
+
? { captureWarnings: [...step.captureWarnings] }
|
|
714
|
+
: {}),
|
|
715
|
+
...(step.collection
|
|
716
|
+
? {
|
|
717
|
+
collection: {
|
|
718
|
+
...step.collection,
|
|
719
|
+
// Child runs have already crossed this snapshot boundary.
|
|
720
|
+
// Copy mutable collection metadata, reuse detached children.
|
|
721
|
+
items: step.collection.items.map((item) => ({
|
|
722
|
+
...item,
|
|
723
|
+
})),
|
|
724
|
+
},
|
|
725
|
+
}
|
|
726
|
+
: {}),
|
|
727
|
+
},
|
|
728
|
+
])),
|
|
729
|
+
...(value !== undefined ? { value: snapshotData(value) } : {}),
|
|
675
730
|
});
|
|
676
731
|
}
|
|
677
732
|
}
|
|
@@ -728,27 +783,35 @@ export function defineJevFlow(name, build) {
|
|
|
728
783
|
return evaluate(e.ast, data, values);
|
|
729
784
|
});
|
|
730
785
|
abortCheck();
|
|
731
|
-
const call = () =>
|
|
732
|
-
|
|
733
|
-
|
|
786
|
+
const call = () => {
|
|
787
|
+
abortCheck();
|
|
788
|
+
return stepContext.run({
|
|
789
|
+
runId: scope.runId,
|
|
790
|
+
stepId: scope.prefix + step.id,
|
|
791
|
+
executionId: state.executionId,
|
|
792
|
+
flow: scope.flow,
|
|
793
|
+
}, () => options.client.systemOne(request, {
|
|
794
|
+
signal: controller.signal,
|
|
795
|
+
}));
|
|
796
|
+
};
|
|
734
797
|
const link = {
|
|
735
798
|
runId: run.id,
|
|
736
799
|
stepId: step.id,
|
|
737
800
|
executionId: state.executionId,
|
|
738
801
|
};
|
|
739
|
-
value = await runCallback(() => observer
|
|
740
|
-
? observer.request(link, (id) => {
|
|
802
|
+
value = await runCallback(() => observeRequest(call, observer
|
|
803
|
+
? (callOnce) => observer.request(link, (id) => {
|
|
741
804
|
if (controller.signal.aborted ||
|
|
742
805
|
state.status !== "running")
|
|
743
806
|
return;
|
|
744
807
|
state.traceId = id;
|
|
745
808
|
emit();
|
|
746
|
-
},
|
|
747
|
-
:
|
|
809
|
+
}, callOnce)
|
|
810
|
+
: undefined), controller.signal);
|
|
748
811
|
abortCheck();
|
|
749
812
|
}
|
|
750
813
|
else if (step.kind === "task") {
|
|
751
|
-
const input = jsonSnapshot(mapValue(step.input, (e) => evaluate(e.ast, data, values)));
|
|
814
|
+
const input = jsonSnapshot(mapValue(step.input, (e) => evaluate(e.ast, data, values), true));
|
|
752
815
|
state.data = { input };
|
|
753
816
|
emit();
|
|
754
817
|
value = jsonSnapshot(await runCallback((signal) => step.task(jsonSnapshot(input), {
|
|
@@ -828,7 +891,7 @@ export function defineJevFlow(name, build) {
|
|
|
828
891
|
};
|
|
829
892
|
},
|
|
830
893
|
},
|
|
831
|
-
}, budget));
|
|
894
|
+
}, budget, { ...scope, prefix: `${scope.prefix}${step.id}/${index}/` }));
|
|
832
895
|
abortCheck();
|
|
833
896
|
current = jsonSnapshot(result.value);
|
|
834
897
|
state.data = { input: initial, output: current };
|
|
@@ -897,7 +960,10 @@ export function defineJevFlow(name, build) {
|
|
|
897
960
|
};
|
|
898
961
|
},
|
|
899
962
|
},
|
|
900
|
-
}, budget
|
|
963
|
+
}, budget, {
|
|
964
|
+
...scope,
|
|
965
|
+
prefix: `${scope.prefix}${step.id}/${index}/`,
|
|
966
|
+
}));
|
|
901
967
|
results[index] = result.value;
|
|
902
968
|
}
|
|
903
969
|
catch (error) {
|
|
@@ -1004,7 +1070,7 @@ export function defineJevFlow(name, build) {
|
|
|
1004
1070
|
throw new Error("Flow must return a named output");
|
|
1005
1071
|
run.status = "success";
|
|
1006
1072
|
run.outcome = result.outcome;
|
|
1007
|
-
//
|
|
1073
|
+
// emit() detaches the observer's output; callers keep the actual typed value.
|
|
1008
1074
|
if (result.value !== undefined)
|
|
1009
1075
|
run.value = result.value;
|
|
1010
1076
|
return { runId: run.id, ...result };
|
|
@@ -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
|
+
}
|
package/lib/flow/runtime.d.ts
CHANGED
|
@@ -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>;
|
package/lib/flow/runtime.js
CHANGED
|
@@ -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>;
|
package/lib/server/flows.d.ts
CHANGED
|
@@ -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
|
}
|
package/lib/server/flows.js
CHANGED
|
@@ -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
|
-
|
|
180
|
-
|
|
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),
|
package/lib/server/index.js
CHANGED
|
@@ -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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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);
|
package/lib/server/recorder.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
288
|
+
t.result = result;
|
|
253
289
|
t.captureWarnings = [...warnings];
|
|
254
290
|
});
|
|
255
291
|
}, fail)
|
|
256
|
-
.catch((
|
|
257
|
-
|
|
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, {
|
|
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 {};
|