jev-devtools 0.2.0 → 0.4.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 +50 -6
- package/lib/flow/index.d.ts +143 -0
- package/lib/flow/index.js +1029 -0
- package/lib/flow/runtime.d.ts +5 -0
- package/lib/flow/runtime.js +81 -0
- package/lib/server/flows.d.ts +28 -0
- package/lib/server/flows.js +187 -0
- package/lib/server/index.d.ts +14 -1
- package/lib/server/index.js +100 -1
- package/lib/server/recorder.d.ts +4 -1
- package/lib/server/recorder.js +19 -3
- package/lib/src/ChoiceProbabilities.js +1 -1
- package/lib/src/ConnectedInspector.js +13 -4
- package/lib/src/DiagramContent.d.ts +6 -0
- package/lib/src/DiagramContent.js +32 -0
- package/lib/src/FlowDetailsPanel.d.ts +8 -0
- package/lib/src/FlowDetailsPanel.js +79 -0
- package/lib/src/FlowOverview.d.ts +8 -0
- package/lib/src/FlowOverview.js +440 -0
- package/lib/src/JevDevtools.d.ts +6 -1
- package/lib/src/JevDevtools.js +18 -4
- package/lib/src/TraceDiagram.d.ts +4 -1
- package/lib/src/TraceDiagram.js +30 -12
- package/lib/src/condition-inputs.d.ts +9 -0
- package/lib/src/condition-inputs.js +109 -0
- package/lib/src/flow-condition.d.ts +4 -0
- package/lib/src/flow-condition.js +48 -0
- package/lib/src/flow-model.d.ts +7 -2
- package/lib/src/flow-model.js +2 -11
- package/lib/src/flow-protocol.d.ts +93 -0
- package/lib/src/flow-protocol.js +1 -0
- package/lib/src/flow-stream.d.ts +8 -0
- package/lib/src/flow-stream.js +80 -0
- package/lib/src/inspector-styles.js +1 -1
- package/lib/src/inspector.css +746 -8
- package/lib/src/protocol.d.ts +5 -0
- package/lib/src/useDiagramViewport.d.ts +5 -1
- package/lib/src/useDiagramViewport.js +31 -9
- package/package.json +11 -4
- package/skills/setup-jev-devtools/SKILL.md +8 -4
- package/skills/setup-jev-devtools/references/flows.md +227 -0
- package/skills/setup-jev-devtools/references/nextjs.md +3 -1
- package/skills/setup-jev-devtools/references/react-node.md +2 -2
- package/skills/setup-jev-devtools/references/verify.md +1 -1
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { Json } from "../src/protocol.js";
|
|
2
|
+
/** Detached JSON snapshot, without capture truncation or lossy JSON.stringify. */
|
|
3
|
+
export declare function jsonSnapshot(value: unknown, seen?: Set<object>, depth?: number): Json;
|
|
4
|
+
/** Cancellation ends awaiting, even when user code ignores the cooperative signal. */
|
|
5
|
+
export declare function runCallback(callback: (signal: AbortSignal) => unknown, parent: AbortSignal, timeoutMs?: number): Promise<unknown>;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/** Detached JSON snapshot, without capture truncation or lossy JSON.stringify. */
|
|
2
|
+
export function jsonSnapshot(value, seen = new Set(), depth = 0) {
|
|
3
|
+
if (depth > 40)
|
|
4
|
+
throw new TypeError("Task data nesting exceeds 40");
|
|
5
|
+
if (value === null || typeof value === "string" || typeof value === "boolean")
|
|
6
|
+
return value;
|
|
7
|
+
if (typeof value === "number" && Number.isFinite(value))
|
|
8
|
+
return value;
|
|
9
|
+
if (!value ||
|
|
10
|
+
typeof value !== "object" ||
|
|
11
|
+
(!Array.isArray(value) &&
|
|
12
|
+
Object.getPrototypeOf(value) !== Object.prototype &&
|
|
13
|
+
Object.getPrototypeOf(value) !== null))
|
|
14
|
+
throw new TypeError("Task inputs and outputs must be JSON-compatible");
|
|
15
|
+
if (seen.has(value))
|
|
16
|
+
throw new TypeError("Task data cannot contain cycles");
|
|
17
|
+
if (Object.getOwnPropertySymbols(value).length)
|
|
18
|
+
throw new TypeError("Task data cannot contain symbol keys");
|
|
19
|
+
seen.add(value);
|
|
20
|
+
try {
|
|
21
|
+
if (Array.isArray(value))
|
|
22
|
+
return Array.from(value, (v) => jsonSnapshot(v, seen, depth + 1));
|
|
23
|
+
return Object.fromEntries(Object.entries(value).map(([k, v]) => [
|
|
24
|
+
k,
|
|
25
|
+
jsonSnapshot(v, seen, depth + 1),
|
|
26
|
+
]));
|
|
27
|
+
}
|
|
28
|
+
finally {
|
|
29
|
+
seen.delete(value);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/** Cancellation ends awaiting, even when user code ignores the cooperative signal. */
|
|
33
|
+
export function runCallback(callback, parent, timeoutMs) {
|
|
34
|
+
const controller = new AbortController();
|
|
35
|
+
const started = performance.now();
|
|
36
|
+
return new Promise((resolve, reject) => {
|
|
37
|
+
let timer;
|
|
38
|
+
let settled = false;
|
|
39
|
+
const cleanup = () => {
|
|
40
|
+
clearTimeout(timer);
|
|
41
|
+
parent.removeEventListener("abort", cancel);
|
|
42
|
+
};
|
|
43
|
+
const finish = (error, value, failed = false) => {
|
|
44
|
+
if (settled)
|
|
45
|
+
return;
|
|
46
|
+
settled = true;
|
|
47
|
+
cleanup();
|
|
48
|
+
if (failed)
|
|
49
|
+
reject(error);
|
|
50
|
+
else
|
|
51
|
+
resolve(value);
|
|
52
|
+
};
|
|
53
|
+
const stop = (error) => {
|
|
54
|
+
controller.abort(error);
|
|
55
|
+
finish(error, undefined, true);
|
|
56
|
+
};
|
|
57
|
+
const cancel = () => stop(new DOMException("Flow cancelled", "AbortError"));
|
|
58
|
+
if (parent.aborted) {
|
|
59
|
+
cancel();
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
parent.addEventListener("abort", cancel, { once: true });
|
|
63
|
+
if (timeoutMs !== undefined)
|
|
64
|
+
timer = setTimeout(() => stop(new DOMException("Task timed out", "TimeoutError")), timeoutMs);
|
|
65
|
+
Promise.resolve()
|
|
66
|
+
.then(() => {
|
|
67
|
+
if (controller.signal.aborted)
|
|
68
|
+
throw controller.signal.reason;
|
|
69
|
+
return callback(controller.signal);
|
|
70
|
+
})
|
|
71
|
+
.then((value) => {
|
|
72
|
+
// CPU-bound callbacks cannot be interrupted, but their late result must
|
|
73
|
+
// still not advance the flow after the configured deadline.
|
|
74
|
+
if (timeoutMs !== undefined &&
|
|
75
|
+
performance.now() - started >= timeoutMs)
|
|
76
|
+
stop(new DOMException("Task timed out", "TimeoutError"));
|
|
77
|
+
else
|
|
78
|
+
finish(undefined, value);
|
|
79
|
+
}, (error) => finish(error, undefined, true));
|
|
80
|
+
});
|
|
81
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { FlowDefinition, FlowRun, FlowUpdate } from "../src/flow-protocol.js";
|
|
2
|
+
import { type CaptureOptions } from "./recorder.js";
|
|
3
|
+
/** Independent bounded run store. Definitions are retained only while referenced. */
|
|
4
|
+
export declare class FlowRecorder {
|
|
5
|
+
private options;
|
|
6
|
+
private runs;
|
|
7
|
+
private definitions;
|
|
8
|
+
private sessions;
|
|
9
|
+
private definitionRevisions;
|
|
10
|
+
private revisions;
|
|
11
|
+
private revision;
|
|
12
|
+
private listeners;
|
|
13
|
+
private itemCaptures;
|
|
14
|
+
private taskCaptures;
|
|
15
|
+
constructor(options: CaptureOptions);
|
|
16
|
+
subscribe(fn: (sessions: Set<string>) => void): () => void;
|
|
17
|
+
private notify;
|
|
18
|
+
start(definition: FlowDefinition, run: FlowRun, session: string): void;
|
|
19
|
+
private captureRun;
|
|
20
|
+
update(run: FlowRun): void;
|
|
21
|
+
private remove;
|
|
22
|
+
private prune;
|
|
23
|
+
clear(session: string): void;
|
|
24
|
+
changes(session: string, cursor?: number): {
|
|
25
|
+
revision: number;
|
|
26
|
+
data: FlowUpdate;
|
|
27
|
+
};
|
|
28
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { capture } from "./recorder.js";
|
|
2
|
+
/** Independent bounded run store. Definitions are retained only while referenced. */
|
|
3
|
+
export class FlowRecorder {
|
|
4
|
+
options;
|
|
5
|
+
runs = new Map();
|
|
6
|
+
definitions = new Map();
|
|
7
|
+
sessions = new Map();
|
|
8
|
+
definitionRevisions = new Map();
|
|
9
|
+
revisions = new Map();
|
|
10
|
+
revision = 0;
|
|
11
|
+
listeners = new Set();
|
|
12
|
+
itemCaptures = new WeakMap();
|
|
13
|
+
taskCaptures = new WeakMap();
|
|
14
|
+
constructor(options) {
|
|
15
|
+
this.options = options;
|
|
16
|
+
}
|
|
17
|
+
subscribe(fn) {
|
|
18
|
+
this.listeners.add(fn);
|
|
19
|
+
return () => {
|
|
20
|
+
this.listeners.delete(fn);
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
notify(sessions) {
|
|
24
|
+
for (const fn of this.listeners) {
|
|
25
|
+
try {
|
|
26
|
+
fn(sessions);
|
|
27
|
+
}
|
|
28
|
+
catch { }
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
start(definition, run, session) {
|
|
32
|
+
if (!this.definitions.has(definition.id)) {
|
|
33
|
+
const stored = structuredClone(definition);
|
|
34
|
+
const redactRules = (definition, prefix = []) => {
|
|
35
|
+
for (const node of definition.nodes) {
|
|
36
|
+
if (node.itemDefinition)
|
|
37
|
+
redactRules(node.itemDefinition, [...prefix, node.id, "items"]);
|
|
38
|
+
if (node.conditionRule === undefined)
|
|
39
|
+
continue;
|
|
40
|
+
try {
|
|
41
|
+
node.conditionRule = capture(node.conditionRule, new Set(), this.options.redact
|
|
42
|
+
? (path, value) => this.options.redact(["conditions", ...prefix, node.id, ...path], value)
|
|
43
|
+
: undefined);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
node.conditionRule = "[rule capture unavailable]";
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
redactRules(stored);
|
|
51
|
+
this.definitions.set(definition.id, stored);
|
|
52
|
+
this.definitionRevisions.set(definition.id, ++this.revision);
|
|
53
|
+
}
|
|
54
|
+
this.runs.set(run.id, run);
|
|
55
|
+
this.sessions.set(run.id, session);
|
|
56
|
+
this.revisions.set(run.id, ++this.revision);
|
|
57
|
+
const sessions = new Set([session]);
|
|
58
|
+
const limit = Math.max(1, Math.min(200, this.options.limit ?? 50));
|
|
59
|
+
while (this.runs.size > limit) {
|
|
60
|
+
const id = this.runs.keys().next().value;
|
|
61
|
+
sessions.add(this.sessions.get(id));
|
|
62
|
+
this.remove(id);
|
|
63
|
+
}
|
|
64
|
+
this.prune();
|
|
65
|
+
this.notify(sessions);
|
|
66
|
+
}
|
|
67
|
+
captureRun(run, prefix = []) {
|
|
68
|
+
// Child updates are immutable snapshots; unchanged items need no recapture.
|
|
69
|
+
const previous = prefix.length ? this.itemCaptures.get(run) : undefined;
|
|
70
|
+
if (previous)
|
|
71
|
+
return previous;
|
|
72
|
+
const stored = {
|
|
73
|
+
...run,
|
|
74
|
+
steps: Object.fromEntries(Object.entries(run.steps).map(([id, raw]) => {
|
|
75
|
+
const step = { ...raw };
|
|
76
|
+
if (raw.collection)
|
|
77
|
+
step.collection = {
|
|
78
|
+
...raw.collection,
|
|
79
|
+
items: raw.collection.items.map((item) => ({
|
|
80
|
+
...item,
|
|
81
|
+
...(item.run
|
|
82
|
+
? {
|
|
83
|
+
run: this.captureRun(item.run, [
|
|
84
|
+
...prefix,
|
|
85
|
+
"steps",
|
|
86
|
+
id,
|
|
87
|
+
"items",
|
|
88
|
+
String(item.index),
|
|
89
|
+
]),
|
|
90
|
+
}
|
|
91
|
+
: {}),
|
|
92
|
+
})),
|
|
93
|
+
};
|
|
94
|
+
if (step.data === undefined)
|
|
95
|
+
return [id, { ...step }];
|
|
96
|
+
const key = step.data;
|
|
97
|
+
let captured = key && typeof key === "object"
|
|
98
|
+
? this.taskCaptures.get(key)
|
|
99
|
+
: undefined;
|
|
100
|
+
if (!captured) {
|
|
101
|
+
const warnings = new Set();
|
|
102
|
+
let data;
|
|
103
|
+
try {
|
|
104
|
+
data =
|
|
105
|
+
capture(step.data, warnings, this.options.redact
|
|
106
|
+
? (path, value) => this.options.redact([...prefix, "steps", id, "data", ...path], value)
|
|
107
|
+
: undefined) ?? null;
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
data = "[capture unavailable]";
|
|
111
|
+
warnings.add("Task capture unavailable.");
|
|
112
|
+
}
|
|
113
|
+
captured = { data, warnings: [...warnings] };
|
|
114
|
+
if (key && typeof key === "object")
|
|
115
|
+
this.taskCaptures.set(key, captured);
|
|
116
|
+
}
|
|
117
|
+
return [
|
|
118
|
+
id,
|
|
119
|
+
{
|
|
120
|
+
...step,
|
|
121
|
+
data: captured.data,
|
|
122
|
+
captureWarnings: captured.warnings,
|
|
123
|
+
},
|
|
124
|
+
];
|
|
125
|
+
})),
|
|
126
|
+
};
|
|
127
|
+
if ("value" in stored) {
|
|
128
|
+
try {
|
|
129
|
+
stored.value = capture(stored.value, new Set(), this.options.redact
|
|
130
|
+
? (path, value) => this.options.redact([...prefix, ...path], value)
|
|
131
|
+
: undefined);
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
stored.value = "[capture unavailable]";
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (prefix.length)
|
|
138
|
+
this.itemCaptures.set(run, stored);
|
|
139
|
+
return stored;
|
|
140
|
+
}
|
|
141
|
+
update(run) {
|
|
142
|
+
if (!this.runs.has(run.id))
|
|
143
|
+
return; // Clear/eviction cannot resurrect pending runs.
|
|
144
|
+
this.runs.set(run.id, this.captureRun(run));
|
|
145
|
+
this.revisions.set(run.id, ++this.revision);
|
|
146
|
+
this.notify(new Set([this.sessions.get(run.id)]));
|
|
147
|
+
}
|
|
148
|
+
remove(id) {
|
|
149
|
+
this.runs.delete(id);
|
|
150
|
+
this.sessions.delete(id);
|
|
151
|
+
this.revisions.delete(id);
|
|
152
|
+
}
|
|
153
|
+
prune() {
|
|
154
|
+
const used = new Set([...this.runs.values()].map((r) => r.definitionId));
|
|
155
|
+
for (const id of this.definitions.keys())
|
|
156
|
+
if (!used.has(id)) {
|
|
157
|
+
this.definitions.delete(id);
|
|
158
|
+
this.definitionRevisions.delete(id);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
clear(session) {
|
|
162
|
+
for (const id of this.runs.keys())
|
|
163
|
+
if (this.sessions.get(id) === session)
|
|
164
|
+
this.remove(id);
|
|
165
|
+
this.prune();
|
|
166
|
+
this.revision++;
|
|
167
|
+
this.notify(new Set([session]));
|
|
168
|
+
}
|
|
169
|
+
changes(session, cursor) {
|
|
170
|
+
const current = [...this.runs.values()]
|
|
171
|
+
.filter((r) => this.sessions.get(r.id) === session)
|
|
172
|
+
.reverse();
|
|
173
|
+
const used = new Set(current.map((r) => r.definitionId));
|
|
174
|
+
return {
|
|
175
|
+
revision: this.revision,
|
|
176
|
+
data: structuredClone({
|
|
177
|
+
version: 1,
|
|
178
|
+
definitions: [...used]
|
|
179
|
+
.filter((id) => cursor === undefined ||
|
|
180
|
+
this.definitionRevisions.get(id) > cursor)
|
|
181
|
+
.map((id) => this.definitions.get(id)),
|
|
182
|
+
upserts: current.filter((r) => cursor === undefined || this.revisions.get(r.id) > cursor),
|
|
183
|
+
order: current.map((r) => r.id),
|
|
184
|
+
}),
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
}
|
package/lib/server/index.d.ts
CHANGED
|
@@ -8,7 +8,10 @@ export type JevDevtoolsServerOptions = CaptureOptions & {
|
|
|
8
8
|
enabled?: boolean;
|
|
9
9
|
origin: string;
|
|
10
10
|
endpoint?: string;
|
|
11
|
-
/**
|
|
11
|
+
/**
|
|
12
|
+
* Required for non-loopback origins. Without it, Node middleware only accepts
|
|
13
|
+
* loopback connections. Apply your application's authentication.
|
|
14
|
+
*/
|
|
12
15
|
authorize?: (request: Request) => boolean | Promise<boolean>;
|
|
13
16
|
/** Server-derived scope. Defaults to a shared local-development history. */
|
|
14
17
|
getSession?: (request: Request) => string | Promise<string>;
|
|
@@ -19,11 +22,21 @@ export type ClientMetadata = {
|
|
|
19
22
|
};
|
|
20
23
|
/** Node runtime adapter: framework-neutral Request/Response plus Node middleware. */
|
|
21
24
|
export declare function createJevDevtools(options: JevDevtoolsServerOptions): {
|
|
25
|
+
startFlow: (definition: import("../src/flow-protocol.js").FlowDefinition, run: import("../src/flow-protocol.js").FlowRun) => {
|
|
26
|
+
update(run: import("../src/flow-protocol.js").FlowRun): void;
|
|
27
|
+
request<T>(link: {
|
|
28
|
+
runId: string;
|
|
29
|
+
stepId: string;
|
|
30
|
+
executionId: string;
|
|
31
|
+
}, onTrace: (id: string) => void, callback: () => T): T;
|
|
32
|
+
} | undefined;
|
|
22
33
|
handle: (request: Request) => Promise<Response>;
|
|
23
34
|
/**
|
|
24
35
|
* For Node framework routes whose Request URL uses an internal hostname.
|
|
25
36
|
* Require the actual Host header to match the configured public authority;
|
|
26
37
|
* never trust forwarded host/protocol headers. Origin checks still apply.
|
|
38
|
+
* A Request does not expose the socket peer and any non-browser client can
|
|
39
|
+
* set Host, so bind the server to loopback or set authorize.
|
|
27
40
|
*/
|
|
28
41
|
handleNodeRoute(request: Request): Promise<Response>;
|
|
29
42
|
middleware: (req: IncomingMessage, res: ServerResponse, next: () => void) => Promise<void>;
|
package/lib/server/index.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
|
+
import { FlowRecorder } from "./flows.js";
|
|
1
2
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
3
|
import { Readable } from "node:stream";
|
|
3
4
|
import { pipeline } from "node:stream/promises";
|
|
4
5
|
import { TypeSafeClient } from "@typesafe-ai/sdk";
|
|
5
6
|
import { JevRecorder } from "./recorder.js";
|
|
6
7
|
export { JevRecorder } from "./recorder.js";
|
|
8
|
+
// Host and Origin are client-controlled, so locality is decided by the socket peer.
|
|
9
|
+
const isLoopback = (address = "") => address === "::1" || /^(::ffff:)?127\./.test(address);
|
|
7
10
|
const json = (data, status = 200) => Response.json(data, { status, headers: { "cache-control": "no-store" } });
|
|
8
11
|
/** Node runtime adapter: framework-neutral Request/Response plus Node middleware. */
|
|
9
12
|
export function createJevDevtools(options) {
|
|
@@ -17,6 +20,7 @@ export function createJevDevtools(options) {
|
|
|
17
20
|
if (options.enabled && !local && !options.authorize)
|
|
18
21
|
throw new Error("Non-loopback inspector endpoints require authorize");
|
|
19
22
|
const recorder = new JevRecorder(options), scope = new AsyncLocalStorage();
|
|
23
|
+
const flows = new FlowRecorder(options);
|
|
20
24
|
const streams = new Set();
|
|
21
25
|
const pending = new Set();
|
|
22
26
|
const encoder = new TextEncoder();
|
|
@@ -91,15 +95,84 @@ export function createJevDevtools(options) {
|
|
|
91
95
|
return json({ error: "Unauthorized" }, 403);
|
|
92
96
|
const session = await sessionFor(request);
|
|
93
97
|
if (url.pathname === endpoint && request.method === "GET")
|
|
94
|
-
return json({ version: 1, enabled: true });
|
|
98
|
+
return json({ version: 1, enabled: true, flows: true });
|
|
95
99
|
if (url.pathname === endpoint + "/clear") {
|
|
96
100
|
if (request.method !== "DELETE")
|
|
97
101
|
return json({ error: "Method not allowed" }, 405);
|
|
98
102
|
if (request.headers.get("origin") !== origin)
|
|
99
103
|
return json({ error: "Same-origin requests required" }, 403);
|
|
100
104
|
recorder.clear(session);
|
|
105
|
+
flows.clear(session);
|
|
101
106
|
return json({ ok: true });
|
|
102
107
|
}
|
|
108
|
+
let flowPull = () => { };
|
|
109
|
+
if (url.pathname === endpoint + "/flows/events") {
|
|
110
|
+
if (request.method !== "GET")
|
|
111
|
+
return json({ error: "Method not allowed" }, 405);
|
|
112
|
+
let stop = () => { };
|
|
113
|
+
const body = new ReadableStream({
|
|
114
|
+
start(controller) {
|
|
115
|
+
let closed = false, dirty = true, cursor, timer;
|
|
116
|
+
const flush = () => {
|
|
117
|
+
timer = undefined;
|
|
118
|
+
if (closed || !dirty || (controller.desiredSize ?? 0) <= 0)
|
|
119
|
+
return;
|
|
120
|
+
dirty = false;
|
|
121
|
+
const update = flows.changes(session, cursor);
|
|
122
|
+
cursor = update.revision;
|
|
123
|
+
controller.enqueue(encoder.encode(`event: flows\ndata: ${JSON.stringify(update.data)}\n\n`));
|
|
124
|
+
};
|
|
125
|
+
const wake = () => {
|
|
126
|
+
if (!closed && dirty && !timer)
|
|
127
|
+
timer = setImmediate(flush);
|
|
128
|
+
};
|
|
129
|
+
const unsubscribe = flows.subscribe((sessions) => {
|
|
130
|
+
if (sessions.has(session)) {
|
|
131
|
+
dirty = true;
|
|
132
|
+
wake();
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
const heartbeat = setInterval(() => {
|
|
136
|
+
if (!closed && (controller.desiredSize ?? 0) > 0)
|
|
137
|
+
controller.enqueue(encoder.encode(": keepalive\n\n"));
|
|
138
|
+
}, 15000);
|
|
139
|
+
stop = () => {
|
|
140
|
+
if (closed)
|
|
141
|
+
return;
|
|
142
|
+
closed = true;
|
|
143
|
+
unsubscribe();
|
|
144
|
+
clearImmediate(timer);
|
|
145
|
+
clearInterval(heartbeat);
|
|
146
|
+
request.signal.removeEventListener("abort", stop);
|
|
147
|
+
streams.delete(stop);
|
|
148
|
+
try {
|
|
149
|
+
controller.close();
|
|
150
|
+
}
|
|
151
|
+
catch { }
|
|
152
|
+
};
|
|
153
|
+
streams.add(stop);
|
|
154
|
+
request.signal.addEventListener("abort", stop, { once: true });
|
|
155
|
+
flowPull = wake;
|
|
156
|
+
if (request.signal.aborted)
|
|
157
|
+
stop();
|
|
158
|
+
else
|
|
159
|
+
flush();
|
|
160
|
+
},
|
|
161
|
+
pull() {
|
|
162
|
+
flowPull();
|
|
163
|
+
},
|
|
164
|
+
cancel() {
|
|
165
|
+
stop();
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
return new Response(body, {
|
|
169
|
+
headers: {
|
|
170
|
+
"content-type": "text/event-stream",
|
|
171
|
+
"cache-control": "no-store",
|
|
172
|
+
"x-accel-buffering": "no",
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
}
|
|
103
176
|
if (url.pathname !== endpoint + "/events")
|
|
104
177
|
return json({ error: "Not found" }, 404);
|
|
105
178
|
if (request.method !== "GET")
|
|
@@ -185,6 +258,17 @@ export function createJevDevtools(options) {
|
|
|
185
258
|
next();
|
|
186
259
|
return;
|
|
187
260
|
}
|
|
261
|
+
if (enabled() &&
|
|
262
|
+
!options.authorize &&
|
|
263
|
+
!isLoopback(req.socket.remoteAddress)) {
|
|
264
|
+
res
|
|
265
|
+
.writeHead(403, {
|
|
266
|
+
"content-type": "application/json",
|
|
267
|
+
"cache-control": "no-store",
|
|
268
|
+
})
|
|
269
|
+
.end('{"error":"Loopback connections required without authorize"}');
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
188
272
|
const abort = new AbortController();
|
|
189
273
|
res.once("close", () => abort.abort());
|
|
190
274
|
try {
|
|
@@ -209,12 +293,27 @@ export function createJevDevtools(options) {
|
|
|
209
293
|
res.destroy();
|
|
210
294
|
}
|
|
211
295
|
}
|
|
296
|
+
const startFlow = (definition, run) => {
|
|
297
|
+
if (!enabled())
|
|
298
|
+
return;
|
|
299
|
+
flows.start(definition, run, scope.getStore() ?? "local");
|
|
300
|
+
return {
|
|
301
|
+
update: (next) => {
|
|
302
|
+
if (enabled())
|
|
303
|
+
flows.update(next);
|
|
304
|
+
},
|
|
305
|
+
request: (link, onTrace, callback) => recorder.withFlow(link, onTrace, callback),
|
|
306
|
+
};
|
|
307
|
+
};
|
|
212
308
|
return {
|
|
309
|
+
startFlow,
|
|
213
310
|
handle,
|
|
214
311
|
/**
|
|
215
312
|
* For Node framework routes whose Request URL uses an internal hostname.
|
|
216
313
|
* Require the actual Host header to match the configured public authority;
|
|
217
314
|
* never trust forwarded host/protocol headers. Origin checks still apply.
|
|
315
|
+
* A Request does not expose the socket peer and any non-browser client can
|
|
316
|
+
* set Host, so bind the server to loopback or set authorize.
|
|
218
317
|
*/
|
|
219
318
|
handleNodeRoute(request) {
|
|
220
319
|
if (!enabled())
|
package/lib/server/recorder.d.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { TypeSafeClient } from "@typesafe-ai/sdk";
|
|
2
2
|
import type { Fetch } from "@typesafe-ai/sdk";
|
|
3
|
-
import type { Trace } from "../src/protocol.js";
|
|
3
|
+
import type { Json, Trace } from "../src/protocol.js";
|
|
4
4
|
export type CaptureOptions = {
|
|
5
5
|
limit?: number;
|
|
6
6
|
redact?: (path: string[], value: unknown) => unknown;
|
|
7
7
|
};
|
|
8
|
+
export declare function capture(value: unknown, warnings: Set<string>, redact?: CaptureOptions["redact"]): Json;
|
|
8
9
|
export declare class JevRecorder {
|
|
9
10
|
private options;
|
|
10
11
|
private traces;
|
|
@@ -12,6 +13,8 @@ export declare class JevRecorder {
|
|
|
12
13
|
private revision;
|
|
13
14
|
private revisions;
|
|
14
15
|
private context;
|
|
16
|
+
private flowContext;
|
|
17
|
+
withFlow<T>(link: NonNullable<Trace["flow"]>, onTrace: (id: string) => void, callback: () => T): T;
|
|
15
18
|
private limit;
|
|
16
19
|
constructor(options?: CaptureOptions);
|
|
17
20
|
snapshot(session: string): Trace[];
|
package/lib/server/recorder.js
CHANGED
|
@@ -5,7 +5,7 @@ const secretKey = /^(authorization|cookie|set-cookie|api[-_]?key|password|secret
|
|
|
5
5
|
const safeMessage = (value) => value.replace(/Bearer\s+\S+/gi, "Bearer [redacted]").slice(0, 500);
|
|
6
6
|
// Snapshot before the caller can mutate its request. Bounded and cycle-safe;
|
|
7
7
|
// image handles become explicit previews/references instead of opaque SDK objects.
|
|
8
|
-
function capture(value, warnings, redact) {
|
|
8
|
+
export function capture(value, warnings, redact) {
|
|
9
9
|
const seen = new WeakSet();
|
|
10
10
|
let budget = 96_000;
|
|
11
11
|
const visit = (input, path, depth) => {
|
|
@@ -67,10 +67,15 @@ function capture(value, warnings, redact) {
|
|
|
67
67
|
.map((v, i) => visit(v, [...path, String(i)], depth + 1));
|
|
68
68
|
}
|
|
69
69
|
const entries = Object.entries(input);
|
|
70
|
-
|
|
70
|
+
// Choice maps need room for the full roster; retain the overall byte/depth
|
|
71
|
+
// budget and the smaller general-purpose object/array limit.
|
|
72
|
+
const entryLimit = ["criteria", "probabilities"].includes(path.at(-1) ?? "")
|
|
73
|
+
? 256
|
|
74
|
+
: 150;
|
|
75
|
+
if (entries.length > entryLimit)
|
|
71
76
|
warnings.add("Long collection truncated in capture.");
|
|
72
77
|
return Object.fromEntries(entries
|
|
73
|
-
.slice(0,
|
|
78
|
+
.slice(0, entryLimit)
|
|
74
79
|
.map(([key, v]) => [key, visit(v, [...path, key], depth + 1)]));
|
|
75
80
|
};
|
|
76
81
|
return visit(value, [], 0);
|
|
@@ -82,6 +87,10 @@ export class JevRecorder {
|
|
|
82
87
|
revision = 0;
|
|
83
88
|
revisions = new WeakMap();
|
|
84
89
|
context = new AsyncLocalStorage();
|
|
90
|
+
flowContext = new AsyncLocalStorage();
|
|
91
|
+
withFlow(link, onTrace, callback) {
|
|
92
|
+
return this.flowContext.run({ link, onTrace }, callback);
|
|
93
|
+
}
|
|
85
94
|
limit;
|
|
86
95
|
constructor(options = {}) {
|
|
87
96
|
this.options = options;
|
|
@@ -196,6 +205,7 @@ export class JevRecorder {
|
|
|
196
205
|
version: 1,
|
|
197
206
|
id,
|
|
198
207
|
...(typeof meta === "function" ? meta() : meta),
|
|
208
|
+
flow: this.flowContext.getStore()?.link,
|
|
199
209
|
startedAt: Date.now(),
|
|
200
210
|
status: "pending",
|
|
201
211
|
request: captured,
|
|
@@ -203,6 +213,12 @@ export class JevRecorder {
|
|
|
203
213
|
captureWarnings: [...warnings],
|
|
204
214
|
});
|
|
205
215
|
const trace = this.traces.get(id);
|
|
216
|
+
try {
|
|
217
|
+
this.flowContext.getStore()?.onTrace(id);
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
/* Observer only. */
|
|
221
|
+
}
|
|
206
222
|
this.revisions.set(trace, ++this.revision);
|
|
207
223
|
const sessions = new Set([trace.session]);
|
|
208
224
|
while (this.traces.size > this.limit) {
|
|
@@ -10,7 +10,7 @@ export function ChoiceProbabilities({ answer }) {
|
|
|
10
10
|
}, [rows, search]);
|
|
11
11
|
const pages = Math.max(1, Math.ceil(filtered.length / 20));
|
|
12
12
|
const current = Math.min(page, pages - 1);
|
|
13
|
-
return (_jsxs("div", { className: "lab-choice-browser", role: "region", "aria-label": "All Choice probabilities", children: [_jsxs("div", { className: "lab-choice-controls", children: [_jsxs("label", { children: ["Search categories", _jsx("input", { autoFocus: true, type: "search", value: search, onChange: (event) => {
|
|
13
|
+
return (_jsxs("div", { className: "lab-choice-browser", role: "region", "aria-label": "All Choice probabilities", "data-diagram-scroll": "", children: [_jsxs("div", { className: "lab-choice-controls", children: [_jsxs("label", { children: ["Search categories", _jsx("input", { autoFocus: true, type: "search", value: search, onChange: (event) => {
|
|
14
14
|
setSearch(event.target.value);
|
|
15
15
|
setPage(0);
|
|
16
16
|
} })] }), _jsxs("span", { role: "status", children: [filtered.length, " of ", rows.length, " categories \u00B7 highest probability first"] })] }), _jsxs("div", { className: "lab-choice-table-scroll", tabIndex: 0, "aria-label": "Probability table", children: [_jsxs("table", { children: [_jsxs("caption", { children: ["Captured probabilities. Values are not renormalized. Returned choice: ", answer.choice ?? "unavailable", "."] }), _jsx("thead", { children: _jsxs("tr", { children: [_jsx("th", { scope: "col", children: "Category" }), _jsx("th", { scope: "col", children: "Probability" }), _jsx("th", { scope: "col", children: "Exact value (0\u20131)" })] }) }), _jsx("tbody", { children: filtered
|
|
@@ -3,6 +3,7 @@ import { memo, useCallback, useEffect, useRef, useState } from "react";
|
|
|
3
3
|
import { JevPanel } from "./JevDevtools.js";
|
|
4
4
|
import { JevDrawer } from "./JevDrawer.js";
|
|
5
5
|
import { connectTraceStream } from "./trace-stream.js";
|
|
6
|
+
import { connectFlowStream, emptyFlows, } from "./flow-stream.js";
|
|
6
7
|
const RetainedPanel = memo(JevPanel);
|
|
7
8
|
function endpointUrl(endpoint) {
|
|
8
9
|
const url = new URL(endpoint, window.location.origin);
|
|
@@ -18,7 +19,11 @@ export default function ConnectedInspector({ endpoint = "/api/jev-devtools", def
|
|
|
18
19
|
const [open, setOpen] = useState(defaultOpen);
|
|
19
20
|
const [visited, setVisited] = useState(defaultOpen);
|
|
20
21
|
const visible = useRef(defaultOpen);
|
|
21
|
-
const latest = useRef({
|
|
22
|
+
const latest = useRef({
|
|
23
|
+
traces: [],
|
|
24
|
+
connection: "connecting",
|
|
25
|
+
flows: emptyFlows,
|
|
26
|
+
});
|
|
22
27
|
const [snapshot, setSnapshot] = useState(latest.current);
|
|
23
28
|
const toggle = (value) => {
|
|
24
29
|
visible.current = value;
|
|
@@ -29,20 +34,21 @@ export default function ConnectedInspector({ endpoint = "/api/jev-devtools", def
|
|
|
29
34
|
}
|
|
30
35
|
};
|
|
31
36
|
useEffect(() => {
|
|
32
|
-
let alive = true, disconnect = () => { };
|
|
37
|
+
let alive = true, disconnect = () => { }, disconnectFlows = () => { };
|
|
33
38
|
const abort = new AbortController();
|
|
34
39
|
// Keep capture current while closed without scheduling React work. Reopening
|
|
35
40
|
// publishes only the latest snapshot; the retained panel keeps its UI state.
|
|
36
41
|
const update = (patch) => {
|
|
37
42
|
const next = { ...latest.current, ...patch };
|
|
38
43
|
if (next.traces === latest.current.traces &&
|
|
39
|
-
next.connection === latest.current.connection
|
|
44
|
+
next.connection === latest.current.connection &&
|
|
45
|
+
next.flows === latest.current.flows)
|
|
40
46
|
return;
|
|
41
47
|
latest.current = next;
|
|
42
48
|
if (visible.current)
|
|
43
49
|
setSnapshot(latest.current);
|
|
44
50
|
};
|
|
45
|
-
update({ traces: [], connection: "connecting" });
|
|
51
|
+
update({ traces: [], connection: "connecting", flows: emptyFlows });
|
|
46
52
|
void (async () => {
|
|
47
53
|
try {
|
|
48
54
|
const url = endpointUrl(endpoint);
|
|
@@ -59,6 +65,8 @@ export default function ConnectedInspector({ endpoint = "/api/jev-devtools", def
|
|
|
59
65
|
throw new Error("Unsupported inspector endpoint");
|
|
60
66
|
if (!alive)
|
|
61
67
|
return;
|
|
68
|
+
if (config.flows)
|
|
69
|
+
disconnectFlows = connectFlowStream(url, (flows) => update({ flows }));
|
|
62
70
|
disconnect = connectTraceStream(url, (traces) => update({ traces }), (connection) => update({ connection }));
|
|
63
71
|
}
|
|
64
72
|
catch (error) {
|
|
@@ -72,6 +80,7 @@ export default function ConnectedInspector({ endpoint = "/api/jev-devtools", def
|
|
|
72
80
|
alive = false;
|
|
73
81
|
abort.abort();
|
|
74
82
|
disconnect();
|
|
83
|
+
disconnectFlows();
|
|
75
84
|
};
|
|
76
85
|
}, [endpoint]);
|
|
77
86
|
const clear = useCallback(async () => {
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { Json } from "./protocol.js";
|
|
2
|
+
/** Bounded, lazy property rendering inside a fixed-size diagram card. */
|
|
3
|
+
export declare const DiagramContent: import("react").MemoExoticComponent<({ value, label, }: {
|
|
4
|
+
value: Json | undefined;
|
|
5
|
+
label: string;
|
|
6
|
+
}) => import("react").JSX.Element>;
|