@warlock.js/ai-panoptic 4.8.1 → 4.8.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/CHANGELOG.md +12 -0
- package/cjs/index.cjs +312 -14
- package/cjs/index.cjs.map +1 -1
- package/esm/config/apply-panoptic-config.d.mts.map +1 -1
- package/esm/config/apply-panoptic-config.mjs +3 -1
- package/esm/config/apply-panoptic-config.mjs.map +1 -1
- package/esm/config/panoptic-config.type.d.mts +11 -0
- package/esm/config/panoptic-config.type.d.mts.map +1 -1
- package/esm/dashboard/dashboard.mjs +2 -1
- package/esm/dashboard/dashboard.mjs.map +1 -1
- package/esm/dashboard/dashboard.type.d.mts +15 -2
- package/esm/dashboard/dashboard.type.d.mts.map +1 -1
- package/esm/dashboard/index.mjs +7 -0
- package/esm/dashboard/serve.mjs +123 -8
- package/esm/dashboard/serve.mjs.map +1 -1
- package/esm/dashboard/ui.html.mjs +133 -4
- package/esm/dashboard/ui.html.mjs.map +1 -1
- package/esm/evaluate/evaluate-system-prompt.d.mts +19 -0
- package/esm/evaluate/evaluate-system-prompt.d.mts.map +1 -0
- package/esm/evaluate/evaluate-system-prompt.mjs +22 -0
- package/esm/evaluate/evaluate-system-prompt.mjs.map +1 -0
- package/esm/evaluate/evaluate.type.d.mts +47 -0
- package/esm/evaluate/evaluate.type.d.mts.map +1 -0
- package/esm/evaluate/extract-last-system-prompt.d.mts +19 -0
- package/esm/evaluate/extract-last-system-prompt.d.mts.map +1 -0
- package/esm/evaluate/extract-last-system-prompt.mjs +24 -0
- package/esm/evaluate/extract-last-system-prompt.mjs.map +1 -0
- package/esm/evaluate/find-span-by-id.d.mts +8 -0
- package/esm/evaluate/find-span-by-id.d.mts.map +1 -0
- package/esm/evaluate/find-span-by-id.mjs +13 -0
- package/esm/evaluate/find-span-by-id.mjs.map +1 -0
- package/esm/evaluate/index.d.mts +4 -0
- package/esm/evaluate/index.mjs +5 -0
- package/esm/index.d.mts +5 -1
- package/esm/index.mjs +6 -1
- package/llms-full.txt +95 -3
- package/llms.txt +1 -0
- package/package.json +4 -3
- package/skills/README.md +4 -0
- package/skills/evaluate-system-prompt/SKILL.md +86 -0
- package/skills/use-local-dashboard/SKILL.md +5 -3
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"apply-panoptic-config.d.mts","names":[],"sources":["../../../../../../../@warlock.js/ai-panoptic/src/config/apply-panoptic-config.ts"],"mappings":";;;;
|
|
1
|
+
{"version":3,"file":"apply-panoptic-config.d.mts","names":[],"sources":["../../../../../../../@warlock.js/ai-panoptic/src/config/apply-panoptic-config.ts"],"mappings":";;;;;AAiDA;;;;AAA2D;;;;;;;;;;;;;;iBAA3C,mBAAA,CAAoB,MAAuB,GAAd,cAAc"}
|
|
@@ -3,6 +3,7 @@ import { createInMemoryTraceStore } from "../store/in-memory-trace-store.mjs";
|
|
|
3
3
|
import { panoptic } from "../panoptic/panoptic.mjs";
|
|
4
4
|
import { dashboard } from "../dashboard/dashboard.mjs";
|
|
5
5
|
import { registerObserver, setObserveAll } from "@warlock.js/ai";
|
|
6
|
+
import { log } from "@warlock.js/logger";
|
|
6
7
|
|
|
7
8
|
//#region ../@warlock.js/ai-panoptic/src/config/apply-panoptic-config.ts
|
|
8
9
|
let applied;
|
|
@@ -35,7 +36,8 @@ function applyPanopticConfig(config) {
|
|
|
35
36
|
let cacheStore;
|
|
36
37
|
if (store === void 0 && config.dashboard) {
|
|
37
38
|
if (config.cache !== void 0) {
|
|
38
|
-
|
|
39
|
+
const onError = config.onError ?? ((error) => log.error("ai-panoptic", "cacheStore", error));
|
|
40
|
+
cacheStore = createCacheTraceStore(config.cache, { onError });
|
|
39
41
|
store = cacheStore;
|
|
40
42
|
} else store = createInMemoryTraceStore();
|
|
41
43
|
exporters.push(store);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"apply-panoptic-config.mjs","names":[],"sources":["../../../../../../../@warlock.js/ai-panoptic/src/config/apply-panoptic-config.ts"],"sourcesContent":["import type { ExecutionReport, Observer } from \"@warlock.js/ai\";\nimport { registerObserver, setObserveAll } from \"@warlock.js/ai\";\nimport type { ExporterContract } from \"../contracts/exporter.contract\";\nimport { dashboard } from \"../dashboard/dashboard\";\nimport type { DashboardHandle, DashboardOptions } from \"../dashboard/dashboard.type\";\nimport { panoptic } from \"../panoptic/panoptic\";\nimport type { Panoptic } from \"../panoptic/panoptic.type\";\nimport { createCacheTraceStore } from \"../store/cache-trace-store\";\nimport type { CacheTraceStoreHandle } from \"../store/cache-trace-store\";\nimport { createInMemoryTraceStore } from \"../store/in-memory-trace-store\";\nimport type { TraceStoreContract } from \"../store/trace-store.contract\";\nimport type { PanopticConfig } from \"./panoptic-config.type\";\n\n/**\n * Module-level applied state. Applying panoptic config is idempotent:\n * the collector is registered as a core `Observer` exactly once, the\n * dashboard is started at most once, and repeat calls only refresh the\n * `observeAll` flag (the latest config wins) without double-registering.\n */\ntype AppliedState = {\n /** The single collector registered as a core `Observer`. */\n panopticInstance: Panoptic;\n /** The dashboard handle, once started. Started at most once. */\n dashboardHandle?: DashboardHandle;\n};\n\nlet applied: AppliedState | undefined;\n\n/**\n * Read a {@link PanopticConfig} and wire panoptic onto the core observe\n * seam — the bridge `ai.config({ panoptic })` resolves through. Builds a\n * collector via the EXISTING {@link panoptic} factory (reusing its\n * collection pipeline, not reinventing it), registers it once via core's\n * `registerObserver` (the subscriber's `collect(report)` structurally IS\n * an {@link Observer}, threaded through a thin wrapper), sets\n * `observeAll`, and — when `config.dashboard` is set — starts the\n * dashboard over the config's store (a store-shaped exporter, or a fresh\n * in-memory store panoptic registers when none was supplied).\n *\n * **Idempotent.** Safe to call on every `onConfigApplied` notification:\n * the observer is registered once, the dashboard started once. Repeat\n * calls update `observeAll` to the latest value but never double-register\n * or double-start. `undefined` config is a no-op (nothing wired).\n *\n * @returns the resolved store-shaped exporter when the dashboard needs\n * one and a fresh store was created — primarily for tests; callers can\n * ignore it.\n */\nexport function applyPanopticConfig(config?: PanopticConfig): void {\n if (config === undefined) {\n return;\n }\n\n // observeAll always tracks the latest config, even on a repeat call.\n setObserveAll(Boolean(config.observeAll));\n\n if (applied === undefined) {\n const exporters = [...(config.exporters ?? [])];\n let store = findStore(exporters);\n\n // Track a cache store separately so we can await its hydration before\n // the dashboard reads it — the in-memory store needs no warm-up.\n let cacheStore: CacheTraceStoreHandle | undefined;\n\n // The dashboard needs a queryable store. If none was supplied via\n // exporters but a dashboard is requested, create one and register it\n // as an exporter so the collector fills it. A configured `cache` picks\n // the durable cache-backed store; otherwise fall back to in-memory so\n // the dashboard works with no cache configured.\n if (store === undefined && config.dashboard) {\n if (config.cache !== undefined) {\n cacheStore = createCacheTraceStore(config.cache);\n store = cacheStore;\n } else {\n store = createInMemoryTraceStore();\n }\n\n exporters.push(store as unknown as ExporterContract);\n }\n\n const panopticInstance = panoptic({\n exporters,\n captureContent: config.captureContent,\n fullHistory: config.fullHistory,\n });\n registerObserver(toObserver(panopticInstance));\n\n applied = { panopticInstance };\n\n if (config.dashboard && store !== undefined) {\n const resolvedStore = store;\n\n // Hydrate the cache mirror (if any) before serving, so a restart\n // surfaces previously-persisted traces immediately. A bare in-memory\n // store resolves the readyGate instantly.\n const readyGate =\n cacheStore !== undefined ? cacheStore.ready() : Promise.resolve();\n\n void readyGate\n .catch(() => {\n // Hydration is best-effort — never block the dashboard on it.\n })\n .then(() => startDashboard(resolvedStore, config.dashboard ?? {}))\n .then((handle) => {\n if (applied !== undefined) {\n applied.dashboardHandle = handle;\n }\n });\n }\n }\n}\n\n/**\n * Reset the applied state — internal, test-only. Closes any running\n * dashboard and forgets the registered collector so a fresh\n * `applyPanopticConfig` starts clean. NOT part of the public surface and\n * does NOT unregister from the core observer registry (use core's\n * `clearObservers` for that in tests).\n */\nexport async function resetAppliedPanopticConfig(): Promise<void> {\n const handle = applied?.dashboardHandle;\n applied = undefined;\n\n if (handle !== undefined) {\n await handle.close();\n }\n}\n\n/**\n * The currently-running dashboard handle, or `undefined` when no\n * dashboard is active. Internal, test-only — lets a spec await the\n * asynchronously-started dashboard. NOT part of the public surface.\n */\nexport function getActiveDashboardHandle(): DashboardHandle | undefined {\n return applied?.dashboardHandle;\n}\n\n/**\n * Wrap a {@link Panoptic} subscriber as a core {@link Observer}. The\n * subscriber already exposes `collect(report)` with a matching signature,\n * but the thin wrapper makes the structural adaptation explicit and keeps\n * the registered object a minimal `Observer` rather than the whole\n * subscriber surface.\n */\nfunction toObserver(instance: Panoptic): Observer {\n return {\n collect(report: ExecutionReport): Promise<void> {\n return instance.collect(report);\n },\n };\n}\n\n/**\n * Find the first exporter that satisfies the {@link TraceStoreContract}\n * read surface (`query` / `get` / `aggregate`) — the in-memory store\n * doubles as an exporter, so a store passed via `exporters` is reused for\n * the dashboard rather than creating a second one.\n */\nfunction findStore(exporters: ExporterContract[]): TraceStoreContract | undefined {\n for (const exporter of exporters) {\n const candidate = exporter as unknown as Partial<TraceStoreContract>;\n\n if (\n typeof candidate.query === \"function\" &&\n typeof candidate.get === \"function\" &&\n typeof candidate.aggregate === \"function\"\n ) {\n return candidate as TraceStoreContract;\n }\n }\n\n return undefined;\n}\n\n/** Start the dashboard, normalizing the `true | DashboardOptions` switch. */\nfunction startDashboard(\n store: TraceStoreContract,\n dashboardConfig: boolean | DashboardOptions,\n): Promise<DashboardHandle> {\n const options: DashboardOptions =\n typeof dashboardConfig === \"object\" ? dashboardConfig : {};\n\n return dashboard(store, options);\n}\n"],"mappings":"
|
|
1
|
+
{"version":3,"file":"apply-panoptic-config.mjs","names":[],"sources":["../../../../../../../@warlock.js/ai-panoptic/src/config/apply-panoptic-config.ts"],"sourcesContent":["import type { ExecutionReport, Observer } from \"@warlock.js/ai\";\nimport { registerObserver, setObserveAll } from \"@warlock.js/ai\";\nimport { log } from \"@warlock.js/logger\";\nimport type { ExporterContract } from \"../contracts/exporter.contract\";\nimport { dashboard } from \"../dashboard/dashboard\";\nimport type { DashboardHandle, DashboardOptions } from \"../dashboard/dashboard.type\";\nimport { panoptic } from \"../panoptic/panoptic\";\nimport type { Panoptic } from \"../panoptic/panoptic.type\";\nimport { createCacheTraceStore } from \"../store/cache-trace-store\";\nimport type { CacheTraceStoreHandle } from \"../store/cache-trace-store\";\nimport { createInMemoryTraceStore } from \"../store/in-memory-trace-store\";\nimport type { TraceStoreContract } from \"../store/trace-store.contract\";\nimport type { PanopticConfig } from \"./panoptic-config.type\";\n\n/**\n * Module-level applied state. Applying panoptic config is idempotent:\n * the collector is registered as a core `Observer` exactly once, the\n * dashboard is started at most once, and repeat calls only refresh the\n * `observeAll` flag (the latest config wins) without double-registering.\n */\ntype AppliedState = {\n /** The single collector registered as a core `Observer`. */\n panopticInstance: Panoptic;\n /** The dashboard handle, once started. Started at most once. */\n dashboardHandle?: DashboardHandle;\n};\n\nlet applied: AppliedState | undefined;\n\n/**\n * Read a {@link PanopticConfig} and wire panoptic onto the core observe\n * seam — the bridge `ai.config({ panoptic })` resolves through. Builds a\n * collector via the EXISTING {@link panoptic} factory (reusing its\n * collection pipeline, not reinventing it), registers it once via core's\n * `registerObserver` (the subscriber's `collect(report)` structurally IS\n * an {@link Observer}, threaded through a thin wrapper), sets\n * `observeAll`, and — when `config.dashboard` is set — starts the\n * dashboard over the config's store (a store-shaped exporter, or a fresh\n * in-memory store panoptic registers when none was supplied).\n *\n * **Idempotent.** Safe to call on every `onConfigApplied` notification:\n * the observer is registered once, the dashboard started once. Repeat\n * calls update `observeAll` to the latest value but never double-register\n * or double-start. `undefined` config is a no-op (nothing wired).\n *\n * @returns the resolved store-shaped exporter when the dashboard needs\n * one and a fresh store was created — primarily for tests; callers can\n * ignore it.\n */\nexport function applyPanopticConfig(config?: PanopticConfig): void {\n if (config === undefined) {\n return;\n }\n\n // observeAll always tracks the latest config, even on a repeat call.\n setObserveAll(Boolean(config.observeAll));\n\n if (applied === undefined) {\n const exporters = [...(config.exporters ?? [])];\n let store = findStore(exporters);\n\n // Track a cache store separately so we can await its hydration before\n // the dashboard reads it — the in-memory store needs no warm-up.\n let cacheStore: CacheTraceStoreHandle | undefined;\n\n // The dashboard needs a queryable store. If none was supplied via\n // exporters but a dashboard is requested, create one and register it\n // as an exporter so the collector fills it. A configured `cache` picks\n // the durable cache-backed store; otherwise fall back to in-memory so\n // the dashboard works with no cache configured.\n if (store === undefined && config.dashboard) {\n if (config.cache !== undefined) {\n const onError =\n config.onError ??\n ((error: unknown) => log.error(\"ai-panoptic\", \"cacheStore\", error));\n\n cacheStore = createCacheTraceStore(config.cache, { onError });\n store = cacheStore;\n } else {\n store = createInMemoryTraceStore();\n }\n\n exporters.push(store as unknown as ExporterContract);\n }\n\n const panopticInstance = panoptic({\n exporters,\n captureContent: config.captureContent,\n fullHistory: config.fullHistory,\n });\n registerObserver(toObserver(panopticInstance));\n\n applied = { panopticInstance };\n\n if (config.dashboard && store !== undefined) {\n const resolvedStore = store;\n\n // Hydrate the cache mirror (if any) before serving, so a restart\n // surfaces previously-persisted traces immediately. A bare in-memory\n // store resolves the readyGate instantly.\n const readyGate =\n cacheStore !== undefined ? cacheStore.ready() : Promise.resolve();\n\n void readyGate\n .catch(() => {\n // Hydration is best-effort — never block the dashboard on it.\n })\n .then(() => startDashboard(resolvedStore, config.dashboard ?? {}))\n .then((handle) => {\n if (applied !== undefined) {\n applied.dashboardHandle = handle;\n }\n });\n }\n }\n}\n\n/**\n * Reset the applied state — internal, test-only. Closes any running\n * dashboard and forgets the registered collector so a fresh\n * `applyPanopticConfig` starts clean. NOT part of the public surface and\n * does NOT unregister from the core observer registry (use core's\n * `clearObservers` for that in tests).\n */\nexport async function resetAppliedPanopticConfig(): Promise<void> {\n const handle = applied?.dashboardHandle;\n applied = undefined;\n\n if (handle !== undefined) {\n await handle.close();\n }\n}\n\n/**\n * The currently-running dashboard handle, or `undefined` when no\n * dashboard is active. Internal, test-only — lets a spec await the\n * asynchronously-started dashboard. NOT part of the public surface.\n */\nexport function getActiveDashboardHandle(): DashboardHandle | undefined {\n return applied?.dashboardHandle;\n}\n\n/**\n * Wrap a {@link Panoptic} subscriber as a core {@link Observer}. The\n * subscriber already exposes `collect(report)` with a matching signature,\n * but the thin wrapper makes the structural adaptation explicit and keeps\n * the registered object a minimal `Observer` rather than the whole\n * subscriber surface.\n */\nfunction toObserver(instance: Panoptic): Observer {\n return {\n collect(report: ExecutionReport): Promise<void> {\n return instance.collect(report);\n },\n };\n}\n\n/**\n * Find the first exporter that satisfies the {@link TraceStoreContract}\n * read surface (`query` / `get` / `aggregate`) — the in-memory store\n * doubles as an exporter, so a store passed via `exporters` is reused for\n * the dashboard rather than creating a second one.\n */\nfunction findStore(exporters: ExporterContract[]): TraceStoreContract | undefined {\n for (const exporter of exporters) {\n const candidate = exporter as unknown as Partial<TraceStoreContract>;\n\n if (\n typeof candidate.query === \"function\" &&\n typeof candidate.get === \"function\" &&\n typeof candidate.aggregate === \"function\"\n ) {\n return candidate as TraceStoreContract;\n }\n }\n\n return undefined;\n}\n\n/** Start the dashboard, normalizing the `true | DashboardOptions` switch. */\nfunction startDashboard(\n store: TraceStoreContract,\n dashboardConfig: boolean | DashboardOptions,\n): Promise<DashboardHandle> {\n const options: DashboardOptions =\n typeof dashboardConfig === \"object\" ? dashboardConfig : {};\n\n return dashboard(store, options);\n}\n"],"mappings":";;;;;;;;AA2BA,IAAI;;;;;;;;;;;;;;;;;;;;;AAsBJ,SAAgB,oBAAoB,QAA+B;CACjE,IAAI,WAAW,QACb;CAIF,cAAc,QAAQ,OAAO,UAAU,CAAC;CAExC,IAAI,YAAY,QAAW;EACzB,MAAM,YAAY,CAAC,GAAI,OAAO,aAAa,CAAC,CAAE;EAC9C,IAAI,QAAQ,UAAU,SAAS;EAI/B,IAAI;EAOJ,IAAI,UAAU,UAAa,OAAO,WAAW;GAC3C,IAAI,OAAO,UAAU,QAAW;IAC9B,MAAM,UACJ,OAAO,aACL,UAAmB,IAAI,MAAM,eAAe,cAAc,KAAK;IAEnE,aAAa,sBAAsB,OAAO,OAAO,EAAE,QAAQ,CAAC;IAC5D,QAAQ;GACV,OACE,QAAQ,yBAAyB;GAGnC,UAAU,KAAK,KAAoC;EACrD;EAEA,MAAM,mBAAmB,SAAS;GAChC;GACA,gBAAgB,OAAO;GACvB,aAAa,OAAO;EACtB,CAAC;EACD,iBAAiB,WAAW,gBAAgB,CAAC;EAE7C,UAAU,EAAE,iBAAiB;EAE7B,IAAI,OAAO,aAAa,UAAU,QAAW;GAC3C,MAAM,gBAAgB;GAQtB,CAFE,eAAe,SAAY,WAAW,MAAM,IAAI,QAAQ,QAAQ,EAEpD,CACX,YAAY,CAEb,CAAC,CAAC,CACD,WAAW,eAAe,eAAe,OAAO,aAAa,CAAC,CAAC,CAAC,CAAC,CACjE,MAAM,WAAW;IAChB,IAAI,YAAY,QACd,QAAQ,kBAAkB;GAE9B,CAAC;EACL;CACF;AACF;;;;;;;;AAkCA,SAAS,WAAW,UAA8B;CAChD,OAAO,EACL,QAAQ,QAAwC;EAC9C,OAAO,SAAS,QAAQ,MAAM;CAChC,EACF;AACF;;;;;;;AAQA,SAAS,UAAU,WAA+D;CAChF,KAAK,MAAM,YAAY,WAAW;EAChC,MAAM,YAAY;EAElB,IACE,OAAO,UAAU,UAAU,cAC3B,OAAO,UAAU,QAAQ,cACzB,OAAO,UAAU,cAAc,YAE/B,OAAO;CAEX;AAGF;;AAGA,SAAS,eACP,OACA,iBAC0B;CAI1B,OAAO,UAAU,OAFf,OAAO,oBAAoB,WAAW,kBAAkB,CAAC,CAE5B;AACjC"}
|
|
@@ -72,6 +72,17 @@ type PanopticConfig = {
|
|
|
72
72
|
* set `captureMessages`. Default `false`.
|
|
73
73
|
*/
|
|
74
74
|
fullHistory?: boolean;
|
|
75
|
+
/**
|
|
76
|
+
* Called whenever the cache-backed store (see {@link cache}) fails a
|
|
77
|
+
* hydrate-on-startup or write-through — a bad URL, an unreachable Redis, an
|
|
78
|
+
* auth failure. The store is best-effort by design (a flaky cache must
|
|
79
|
+
* never throw into the collector's hot path), so without this hook a
|
|
80
|
+
* failure is otherwise invisible: the dashboard just silently shows no
|
|
81
|
+
* traces, forever, with no diagnostic anywhere. When omitted, defaults to
|
|
82
|
+
* `log.error("ai-panoptic", "cacheStore", error)` so it's never silent
|
|
83
|
+
* out of the box; pass your own (even a no-op) to override.
|
|
84
|
+
*/
|
|
85
|
+
onError?: (error: unknown) => void;
|
|
75
86
|
};
|
|
76
87
|
declare module "@warlock.js/ai" {
|
|
77
88
|
interface AIConfig {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"panoptic-config.type.d.mts","names":[],"sources":["../../../../../../../@warlock.js/ai-panoptic/src/config/panoptic-config.type.ts"],"mappings":";;;;;;;AA0BA
|
|
1
|
+
{"version":3,"file":"panoptic-config.type.d.mts","names":[],"sources":["../../../../../../../@warlock.js/ai-panoptic/src/config/panoptic-config.type.ts"],"mappings":";;;;;;;AA0BA;;;;;;;;;;;;;;;;;;;;KAAY,cAAA;EA0DV;;;;;;EAnDA,SAAA,GAAY,gBAAA;EA4De;;AAAA;;;;;;;;;;EA/C3B,KAAA,GAAQ,gBAAA;;;;;;;;EAQR,SAAA,aAAsB,gBAAA;;;;;;EAMtB,UAAA;;;;;;EAMA,cAAA;;;;;;EAMA,WAAA;;;;;;;;;;;EAWA,OAAA,IAAW,KAAA;AAAA;AAAA;EAAA,UAQD,QAAA;;IAER,QAAA,GAAW,cAAc;EAAA;AAAA"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"dashboard.mjs","names":[],"sources":["../../../../../../../@warlock.js/ai-panoptic/src/dashboard/dashboard.ts"],"sourcesContent":["import { createServer } from \"node:http\";\nimport type { TraceStoreContract } from \"../store/trace-store.contract\";\nimport type { DashboardHandle, DashboardOptions } from \"./dashboard.type\";\nimport { createRequestHandler } from \"./serve\";\n\nconst DEFAULT_PORT = 4319;\nconst DEFAULT_HOST = \"127.0.0.1\";\nconst DEFAULT_TITLE = \"Panoptic\";\n\n/**\n * Start the local Panoptic dashboard over a {@link TraceStoreContract} —\n * a zero-dependency `node:http` server that serves a read-only JSON API\n * and one self-contained HTML page polling it. The store is the live\n * object the collector fills, so each poll reflects the latest completed\n * traces with no extra wiring.\n *\n * This is a low-level building block. The documented path is\n * `ai.config({ panoptic: { dashboard } })`, which constructs (or reuses)\n * the store and calls this for you. Use it directly only when you manage\n * the store yourself.\n *\n * Binds loopback-only by default (`127.0.0.1`) so prompt content is never\n * exposed to the LAN. Pass `port: 0` for an ephemeral port — the resolved\n * port comes back on the handle. A port already in use rejects with a\n * clear, actionable `Error` rather than the raw `EADDRINUSE`.\n *\n * @example\n * const store = createInMemoryTraceStore();\n * const handle = await dashboard(store, { port: 4319, open: true });\n * console.log(handle.url); // http://127.0.0.1:4319/\n * // ...later:\n * await handle.close();\n */\nexport function dashboard(\n store: TraceStoreContract,\n options: DashboardOptions = {},\n): Promise<DashboardHandle> {\n const port = options.port ?? DEFAULT_PORT;\n const host = options.host ?? DEFAULT_HOST;\n const title = options.title ?? DEFAULT_TITLE;\n const basePath = normalizeBasePath(options.basePath);\n\n // Secure-by-default off loopback (S4): a non-loopback bind without an\n // auth token would expose raw prompt content to the network, so refuse\n // to start rather than silently exposing it.\n if (!isLoopbackHost(host) && !options.authToken) {\n return Promise.reject(\n new Error(\n `Panoptic dashboard: binding to a non-loopback host (\"${host}\") requires an \\`authToken\\` ` +\n \"(the dashboard exposes raw prompt content). Pass `authToken`, or bind to 127.0.0.1.\",\n ),\n );\n }\n\n const allowedHosts = (options.allowedHosts ?? defaultAllowedHosts(host)).map(h =>\n h.toLowerCase(),\n );\n\n const handler = createRequestHandler(store, {\n basePath,\n title,\n authToken: options.authToken,\n allowedHosts,\n });\n const server = createServer(handler);\n\n return new Promise<DashboardHandle>((resolve, reject) => {\n const onError = (error: NodeJS.ErrnoException): void => {\n server.off(\"error\", onError);\n\n if (error.code === \"EADDRINUSE\") {\n reject(\n new Error(\n `Panoptic dashboard: port ${port} in use; pass { port: 0 } for an ephemeral port`,\n ),\n );\n\n return;\n }\n\n reject(error);\n };\n\n server.on(\"error\", onError);\n\n server.listen(port, host, () => {\n server.off(\"error\", onError);\n\n const address = server.address();\n const resolvedPort = typeof address === \"object\" && address !== null ? address.port : port;\n const url = `http://${host}:${resolvedPort}${basePath}`;\n\n if (options.open) {\n openBrowser(url);\n }\n\n resolve({\n url,\n port: resolvedPort,\n close(): Promise<void> {\n return new Promise<void>((closeResolve, closeReject) => {\n server.close((closeError) => {\n if (closeError) {\n closeReject(closeError);\n\n return;\n }\n\n closeResolve();\n });\n });\n },\n });\n });\n });\n}\n\n/** Loopback hosts the dashboard may bind without an auth token. */\nfunction isLoopbackHost(host: string): boolean {\n const h = host.toLowerCase();\n return h === \"127.0.0.1\" || h === \"::1\" || h === \"[::1]\" || h === \"localhost\";\n}\n\n/**\n * Default `Host` allowlist for a given bind host. A loopback bind accepts\n * the loopback names a browser would send; a non-loopback bind defaults to\n * just the bound host (callers can widen via `allowedHosts`).\n */\nfunction defaultAllowedHosts(host: string): string[] {\n if (isLoopbackHost(host)) {\n return [\"localhost\", \"127.0.0.1\", \"[::1]\", \"::1\"];\n }\n return [host];\n}\n\n/**\n * Normalize a caller `basePath` to a leading-and-trailing-slash form the\n * router can prefix routes with. `undefined` / `\"\"` → `\"/\"`.\n */\nfunction normalizeBasePath(basePath?: string): string {\n if (basePath === undefined || basePath.length === 0 || basePath === \"/\") {\n return \"/\";\n }\n\n const withLeading = basePath.startsWith(\"/\") ? basePath : `/${basePath}`;\n\n return withLeading.endsWith(\"/\") ? withLeading : `${withLeading}/`;\n}\n\n/**\n * Best-effort open of the default browser at `url`. Fire-and-forget and\n * fully swallowed — failing to open a browser must never reject the\n * dashboard start. Uses the platform's native opener via a lazy\n * `node:child_process` import so the dependency is paid only when\n * `open: true`.\n */\nfunction openBrowser(url: string): void {\n void import(\"node:child_process\")\n .then(({ spawn }) => {\n const command =\n process.platform === \"win32\" ? \"cmd\" : process.platform === \"darwin\" ? \"open\" : \"xdg-open\";\n const args = process.platform === \"win32\" ? [\"/c\", \"start\", \"\", url] : [url];\n\n const child = spawn(command, args, { stdio: \"ignore\", detached: true });\n child.on(\"error\", () => {\n // Swallow — opening a browser is best-effort.\n });\n child.unref();\n })\n .catch(() => {\n // Swallow — opening a browser is best-effort.\n });\n}\n"],"mappings":";;;;AAKA,MAAM,eAAe;AACrB,MAAM,eAAe;AACrB,MAAM,gBAAgB;;;;;;;;;;;;;;;;;;;;;;;;;AA0BtB,SAAgB,UACd,OACA,UAA4B,CAAC,GACH;CAC1B,MAAM,OAAO,QAAQ,QAAQ;CAC7B,MAAM,OAAO,QAAQ,QAAQ;CAC7B,MAAM,QAAQ,QAAQ,SAAS;CAC/B,MAAM,WAAW,kBAAkB,QAAQ,QAAQ;CAKnD,IAAI,CAAC,eAAe,IAAI,KAAK,CAAC,QAAQ,WACpC,OAAO,QAAQ,uBACb,IAAI,MACF,wDAAwD,KAAK,mHAE/D,CACF;CAGF,MAAM,gBAAgB,QAAQ,gBAAgB,oBAAoB,IAAI,EAAC,CAAE,KAAI,MAC3E,EAAE,YAAY,CAChB;
|
|
1
|
+
{"version":3,"file":"dashboard.mjs","names":[],"sources":["../../../../../../../@warlock.js/ai-panoptic/src/dashboard/dashboard.ts"],"sourcesContent":["import { createServer } from \"node:http\";\nimport type { TraceStoreContract } from \"../store/trace-store.contract\";\nimport type { DashboardHandle, DashboardOptions } from \"./dashboard.type\";\nimport { createRequestHandler } from \"./serve\";\n\nconst DEFAULT_PORT = 4319;\nconst DEFAULT_HOST = \"127.0.0.1\";\nconst DEFAULT_TITLE = \"Panoptic\";\n\n/**\n * Start the local Panoptic dashboard over a {@link TraceStoreContract} —\n * a zero-dependency `node:http` server that serves a read-only JSON API\n * and one self-contained HTML page polling it. The store is the live\n * object the collector fills, so each poll reflects the latest completed\n * traces with no extra wiring.\n *\n * This is a low-level building block. The documented path is\n * `ai.config({ panoptic: { dashboard } })`, which constructs (or reuses)\n * the store and calls this for you. Use it directly only when you manage\n * the store yourself.\n *\n * Binds loopback-only by default (`127.0.0.1`) so prompt content is never\n * exposed to the LAN. Pass `port: 0` for an ephemeral port — the resolved\n * port comes back on the handle. A port already in use rejects with a\n * clear, actionable `Error` rather than the raw `EADDRINUSE`.\n *\n * @example\n * const store = createInMemoryTraceStore();\n * const handle = await dashboard(store, { port: 4319, open: true });\n * console.log(handle.url); // http://127.0.0.1:4319/\n * // ...later:\n * await handle.close();\n */\nexport function dashboard(\n store: TraceStoreContract,\n options: DashboardOptions = {},\n): Promise<DashboardHandle> {\n const port = options.port ?? DEFAULT_PORT;\n const host = options.host ?? DEFAULT_HOST;\n const title = options.title ?? DEFAULT_TITLE;\n const basePath = normalizeBasePath(options.basePath);\n\n // Secure-by-default off loopback (S4): a non-loopback bind without an\n // auth token would expose raw prompt content to the network, so refuse\n // to start rather than silently exposing it.\n if (!isLoopbackHost(host) && !options.authToken) {\n return Promise.reject(\n new Error(\n `Panoptic dashboard: binding to a non-loopback host (\"${host}\") requires an \\`authToken\\` ` +\n \"(the dashboard exposes raw prompt content). Pass `authToken`, or bind to 127.0.0.1.\",\n ),\n );\n }\n\n const allowedHosts = (options.allowedHosts ?? defaultAllowedHosts(host)).map(h =>\n h.toLowerCase(),\n );\n\n const handler = createRequestHandler(store, {\n basePath,\n title,\n authToken: options.authToken,\n allowedHosts,\n evaluate: options.evaluate,\n });\n const server = createServer(handler);\n\n return new Promise<DashboardHandle>((resolve, reject) => {\n const onError = (error: NodeJS.ErrnoException): void => {\n server.off(\"error\", onError);\n\n if (error.code === \"EADDRINUSE\") {\n reject(\n new Error(\n `Panoptic dashboard: port ${port} in use; pass { port: 0 } for an ephemeral port`,\n ),\n );\n\n return;\n }\n\n reject(error);\n };\n\n server.on(\"error\", onError);\n\n server.listen(port, host, () => {\n server.off(\"error\", onError);\n\n const address = server.address();\n const resolvedPort = typeof address === \"object\" && address !== null ? address.port : port;\n const url = `http://${host}:${resolvedPort}${basePath}`;\n\n if (options.open) {\n openBrowser(url);\n }\n\n resolve({\n url,\n port: resolvedPort,\n close(): Promise<void> {\n return new Promise<void>((closeResolve, closeReject) => {\n server.close((closeError) => {\n if (closeError) {\n closeReject(closeError);\n\n return;\n }\n\n closeResolve();\n });\n });\n },\n });\n });\n });\n}\n\n/** Loopback hosts the dashboard may bind without an auth token. */\nfunction isLoopbackHost(host: string): boolean {\n const h = host.toLowerCase();\n return h === \"127.0.0.1\" || h === \"::1\" || h === \"[::1]\" || h === \"localhost\";\n}\n\n/**\n * Default `Host` allowlist for a given bind host. A loopback bind accepts\n * the loopback names a browser would send; a non-loopback bind defaults to\n * just the bound host (callers can widen via `allowedHosts`).\n */\nfunction defaultAllowedHosts(host: string): string[] {\n if (isLoopbackHost(host)) {\n return [\"localhost\", \"127.0.0.1\", \"[::1]\", \"::1\"];\n }\n return [host];\n}\n\n/**\n * Normalize a caller `basePath` to a leading-and-trailing-slash form the\n * router can prefix routes with. `undefined` / `\"\"` → `\"/\"`.\n */\nfunction normalizeBasePath(basePath?: string): string {\n if (basePath === undefined || basePath.length === 0 || basePath === \"/\") {\n return \"/\";\n }\n\n const withLeading = basePath.startsWith(\"/\") ? basePath : `/${basePath}`;\n\n return withLeading.endsWith(\"/\") ? withLeading : `${withLeading}/`;\n}\n\n/**\n * Best-effort open of the default browser at `url`. Fire-and-forget and\n * fully swallowed — failing to open a browser must never reject the\n * dashboard start. Uses the platform's native opener via a lazy\n * `node:child_process` import so the dependency is paid only when\n * `open: true`.\n */\nfunction openBrowser(url: string): void {\n void import(\"node:child_process\")\n .then(({ spawn }) => {\n const command =\n process.platform === \"win32\" ? \"cmd\" : process.platform === \"darwin\" ? \"open\" : \"xdg-open\";\n const args = process.platform === \"win32\" ? [\"/c\", \"start\", \"\", url] : [url];\n\n const child = spawn(command, args, { stdio: \"ignore\", detached: true });\n child.on(\"error\", () => {\n // Swallow — opening a browser is best-effort.\n });\n child.unref();\n })\n .catch(() => {\n // Swallow — opening a browser is best-effort.\n });\n}\n"],"mappings":";;;;AAKA,MAAM,eAAe;AACrB,MAAM,eAAe;AACrB,MAAM,gBAAgB;;;;;;;;;;;;;;;;;;;;;;;;;AA0BtB,SAAgB,UACd,OACA,UAA4B,CAAC,GACH;CAC1B,MAAM,OAAO,QAAQ,QAAQ;CAC7B,MAAM,OAAO,QAAQ,QAAQ;CAC7B,MAAM,QAAQ,QAAQ,SAAS;CAC/B,MAAM,WAAW,kBAAkB,QAAQ,QAAQ;CAKnD,IAAI,CAAC,eAAe,IAAI,KAAK,CAAC,QAAQ,WACpC,OAAO,QAAQ,uBACb,IAAI,MACF,wDAAwD,KAAK,mHAE/D,CACF;CAGF,MAAM,gBAAgB,QAAQ,gBAAgB,oBAAoB,IAAI,EAAC,CAAE,KAAI,MAC3E,EAAE,YAAY,CAChB;CASA,MAAM,SAAS,aAPC,qBAAqB,OAAO;EAC1C;EACA;EACA,WAAW,QAAQ;EACnB;EACA,UAAU,QAAQ;CACpB,CACkC,CAAC;CAEnC,OAAO,IAAI,SAA0B,SAAS,WAAW;EACvD,MAAM,WAAW,UAAuC;GACtD,OAAO,IAAI,SAAS,OAAO;GAE3B,IAAI,MAAM,SAAS,cAAc;IAC/B,uBACE,IAAI,MACF,4BAA4B,KAAK,gDACnC,CACF;IAEA;GACF;GAEA,OAAO,KAAK;EACd;EAEA,OAAO,GAAG,SAAS,OAAO;EAE1B,OAAO,OAAO,MAAM,YAAY;GAC9B,OAAO,IAAI,SAAS,OAAO;GAE3B,MAAM,UAAU,OAAO,QAAQ;GAC/B,MAAM,eAAe,OAAO,YAAY,YAAY,YAAY,OAAO,QAAQ,OAAO;GACtF,MAAM,MAAM,UAAU,KAAK,GAAG,eAAe;GAE7C,IAAI,QAAQ,MACV,YAAY,GAAG;GAGjB,QAAQ;IACN;IACA,MAAM;IACN,QAAuB;KACrB,OAAO,IAAI,SAAe,cAAc,gBAAgB;MACtD,OAAO,OAAO,eAAe;OAC3B,IAAI,YAAY;QACd,YAAY,UAAU;QAEtB;OACF;OAEA,aAAa;MACf,CAAC;KACH,CAAC;IACH;GACF,CAAC;EACH,CAAC;CACH,CAAC;AACH;;AAGA,SAAS,eAAe,MAAuB;CAC7C,MAAM,IAAI,KAAK,YAAY;CAC3B,OAAO,MAAM,eAAe,MAAM,SAAS,MAAM,WAAW,MAAM;AACpE;;;;;;AAOA,SAAS,oBAAoB,MAAwB;CACnD,IAAI,eAAe,IAAI,GACrB,OAAO;EAAC;EAAa;EAAa;EAAS;CAAK;CAElD,OAAO,CAAC,IAAI;AACd;;;;;AAMA,SAAS,kBAAkB,UAA2B;CACpD,IAAI,aAAa,UAAa,SAAS,WAAW,KAAK,aAAa,KAClE,OAAO;CAGT,MAAM,cAAc,SAAS,WAAW,GAAG,IAAI,WAAW,IAAI;CAE9D,OAAO,YAAY,SAAS,GAAG,IAAI,cAAc,GAAG,YAAY;AAClE;;;;;;;;AASA,SAAS,YAAY,KAAmB;CACtC,AAAK,OAAO,qBAAqB,CAC9B,MAAM,EAAE,YAAY;EAKnB,MAAM,QAAQ,MAHZ,QAAQ,aAAa,UAAU,QAAQ,QAAQ,aAAa,WAAW,SAAS,YACrE,QAAQ,aAAa,UAAU;GAAC;GAAM;GAAS;GAAI;EAAG,IAAI,CAAC,GAAG,GAExC;GAAE,OAAO;GAAU,UAAU;EAAK,CAAC;EACtE,MAAM,GAAG,eAAe,CAExB,CAAC;EACD,MAAM,MAAM;CACd,CAAC,CAAC,CACD,YAAY,CAEb,CAAC;AACL"}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { EvaluateConfig } from "../evaluate/evaluate.type.mjs";
|
|
2
|
+
|
|
1
3
|
//#region ../@warlock.js/ai-panoptic/src/dashboard/dashboard.type.d.ts
|
|
2
4
|
/**
|
|
3
5
|
* Options for the local Panoptic {@link dashboard} server.
|
|
@@ -5,8 +7,10 @@
|
|
|
5
7
|
* Every field is optional and has a safe default. The dashboard is a dev
|
|
6
8
|
* tool over an in-memory trace store: it binds loopback-only by default
|
|
7
9
|
* (so prompt content is never accidentally exposed to the LAN), serves a
|
|
8
|
-
* single self-contained HTML page, and exposes a
|
|
9
|
-
*
|
|
10
|
+
* single self-contained HTML page, and exposes a JSON API the page polls —
|
|
11
|
+
* every route is `GET`-only and read-only EXCEPT the one opt-in write route
|
|
12
|
+
* {@link DashboardOptions.evaluate} enables. There is no auth or persistence
|
|
13
|
+
* by default either.
|
|
10
14
|
*/
|
|
11
15
|
type DashboardOptions = {
|
|
12
16
|
/**
|
|
@@ -47,6 +51,15 @@ type DashboardOptions = {
|
|
|
47
51
|
* bind; for a non-loopback bind you must list the expected host(s).
|
|
48
52
|
*/
|
|
49
53
|
allowedHosts?: string[];
|
|
54
|
+
/**
|
|
55
|
+
* Enables the drawer's "Evaluate system prompt" button and its backing
|
|
56
|
+
* `POST {basePath}api/traces/:traceId/spans/:spanId/evaluate` route — the
|
|
57
|
+
* dashboard's only write-capable route, and only reachable when this is
|
|
58
|
+
* set. Absent ⇒ the button never renders and a POST there 405s like any
|
|
59
|
+
* other route. Gated by the same `authToken` / `allowedHosts` checks as
|
|
60
|
+
* every other route.
|
|
61
|
+
*/
|
|
62
|
+
evaluate?: EvaluateConfig;
|
|
50
63
|
};
|
|
51
64
|
/**
|
|
52
65
|
* Handle returned by {@link dashboard} once the server is listening.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"dashboard.type.d.mts","names":[],"sources":["../../../../../../../@warlock.js/ai-panoptic/src/dashboard/dashboard.type.ts"],"mappings":"
|
|
1
|
+
{"version":3,"file":"dashboard.type.d.mts","names":[],"sources":["../../../../../../../@warlock.js/ai-panoptic/src/dashboard/dashboard.type.ts"],"mappings":";;;;;AAaA;;;;;;;;;KAAY,gBAAA;EAuCV;;;;AASyB;EA1CzB,IAAA;EAkDyB;;;;;EA5CzB,IAAA;EAkDS;;AAAO;;;EA5ChB,QAAA;;;;;EAKA,IAAA;EAEA,KAAA;;;;;;;EAOA,SAAA;;;;;;;EAOA,YAAA;;;;;;;;;EASA,QAAA,GAAW,cAAc;AAAA;;;;;;KAQf,eAAA;kFAED,GAAA;WAEA,IAAA;EAET,KAAA,IAAS,OAAO;AAAA"}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { parseQuery } from "./parse-query.mjs";
|
|
2
|
+
import { dashboardHtml } from "./ui.html.mjs";
|
|
3
|
+
import { createRequestHandler } from "./serve.mjs";
|
|
4
|
+
import { dashboard } from "./dashboard.mjs";
|
|
5
|
+
import { NO_PROMPT_KEY, NO_SESSION_KEY, NO_TYPE_KEY, aggregateByType, filterTraces, groupByPrompt, groupBySession, groupByType, heatIntensity, matchesFilter, maxNodeCost, percentile, rollupCost, traceCost, tracePromptKey } from "./trace-filter.mjs";
|
|
6
|
+
|
|
7
|
+
export { };
|
package/esm/dashboard/serve.mjs
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
|
+
import { evaluateSystemPrompt } from "../evaluate/evaluate-system-prompt.mjs";
|
|
2
|
+
import { extractLastSystemPrompt } from "../evaluate/extract-last-system-prompt.mjs";
|
|
3
|
+
import { findSpanById } from "../evaluate/find-span-by-id.mjs";
|
|
1
4
|
import { parseQuery } from "./parse-query.mjs";
|
|
2
5
|
import { dashboardHtml } from "./ui.html.mjs";
|
|
3
6
|
|
|
4
7
|
//#region ../@warlock.js/ai-panoptic/src/dashboard/serve.ts
|
|
8
|
+
/** Hard cap on the evaluate route's request body — well beyond a real instructions string. */
|
|
9
|
+
const MAX_EVALUATE_BODY_BYTES = 64 * 1024;
|
|
5
10
|
/**
|
|
6
11
|
* Security response headers added to every dashboard response (S4):
|
|
7
12
|
* block MIME-sniffing, framing, referrer leakage, and lock the page's
|
|
@@ -31,15 +36,22 @@ function isAuthorized(req, url, token) {
|
|
|
31
36
|
* trace store. Kept separate from the server lifecycle so it can be unit
|
|
32
37
|
* tested by feeding it a fake `req`/`res` without binding a port.
|
|
33
38
|
*
|
|
34
|
-
* Routes (all under `config.basePath
|
|
39
|
+
* Routes (all under `config.basePath`):
|
|
35
40
|
*
|
|
36
41
|
* - `GET api/traces` → `store.query(parseQuery(searchParams))`
|
|
37
42
|
* - `GET api/traces/:id` → `store.get(id)` or `404`
|
|
38
43
|
* - `GET api/aggregate` → `store.aggregate(parseQuery(searchParams))`
|
|
39
44
|
* - `GET {basePath}` → the self-contained HTML page
|
|
45
|
+
* - `POST api/traces/:traceId/spans/:spanId/evaluate` → ONLY when
|
|
46
|
+
* `config.evaluate` is set; grades the span's last captured system
|
|
47
|
+
* prompt and returns a {@link EvaluateVerdict}. The dashboard's one
|
|
48
|
+
* write route — absent config, POST 405s like it would against any
|
|
49
|
+
* other route (the path is never even pattern-matched).
|
|
40
50
|
*
|
|
41
|
-
* Anything else → `404`.
|
|
42
|
-
*
|
|
51
|
+
* Anything else → `404`. A method the matched route doesn't accept → `405`.
|
|
52
|
+
* Host allowlist + bearer-token auth (S4) are checked for EVERY request,
|
|
53
|
+
* regardless of method, before any routing. The store shapes
|
|
54
|
+
* ({@link Trace}/{@link TraceSpan}) are already JSON-safe, so GET responses
|
|
43
55
|
* are a plain `JSON.stringify` with no serializer.
|
|
44
56
|
*
|
|
45
57
|
* @example
|
|
@@ -50,10 +62,6 @@ function createRequestHandler(store, config) {
|
|
|
50
62
|
const base = config.basePath;
|
|
51
63
|
const apiPrefix = `${base}api`;
|
|
52
64
|
return function handle(req, res) {
|
|
53
|
-
if (req.method !== "GET") {
|
|
54
|
-
sendJson(res, 405, { error: "method_not_allowed" });
|
|
55
|
-
return;
|
|
56
|
-
}
|
|
57
65
|
const url = new URL(req.url ?? "/", "http://localhost");
|
|
58
66
|
const pathname = url.pathname;
|
|
59
67
|
const host = hostHeaderName(req.headers.host);
|
|
@@ -65,6 +73,17 @@ function createRequestHandler(store, config) {
|
|
|
65
73
|
sendJson(res, 401, { error: "unauthorized" });
|
|
66
74
|
return;
|
|
67
75
|
}
|
|
76
|
+
if (req.method === "POST" && config.evaluate) {
|
|
77
|
+
const match = matchEvaluatePath(pathname, apiPrefix);
|
|
78
|
+
if (match) {
|
|
79
|
+
handleEvaluate(store, config.evaluate, match.traceId, match.spanId, req, res);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (req.method !== "GET") {
|
|
84
|
+
sendJson(res, 405, { error: "method_not_allowed" });
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
68
87
|
if (pathname === `${apiPrefix}/traces`) {
|
|
69
88
|
sendJson(res, 200, store.query(parseQuery(url.searchParams)));
|
|
70
89
|
return;
|
|
@@ -91,7 +110,7 @@ function createRequestHandler(store, config) {
|
|
|
91
110
|
"content-type": "text/html; charset=utf-8",
|
|
92
111
|
...SECURITY_HEADERS
|
|
93
112
|
});
|
|
94
|
-
res.end(dashboardHtml(base, config.title));
|
|
113
|
+
res.end(dashboardHtml(base, config.title, Boolean(config.evaluate), config.evaluate?.instructions ?? ""));
|
|
95
114
|
return;
|
|
96
115
|
}
|
|
97
116
|
sendJson(res, 404, { error: "not_found" });
|
|
@@ -105,6 +124,102 @@ function sendJson(res, status, body) {
|
|
|
105
124
|
});
|
|
106
125
|
res.end(JSON.stringify(body));
|
|
107
126
|
}
|
|
127
|
+
/**
|
|
128
|
+
* Match `{apiPrefix}/traces/:traceId/spans/:spanId/evaluate` — the one
|
|
129
|
+
* write route. Returns `undefined` for anything else, including the plain
|
|
130
|
+
* `{apiPrefix}/traces/:id` read route (no `/spans/.../evaluate` suffix),
|
|
131
|
+
* so the two never collide.
|
|
132
|
+
*/
|
|
133
|
+
function matchEvaluatePath(pathname, apiPrefix) {
|
|
134
|
+
const prefix = `${apiPrefix}/traces/`;
|
|
135
|
+
if (!pathname.startsWith(prefix)) return;
|
|
136
|
+
const match = /^([^/]+)\/spans\/([^/]+)\/evaluate$/.exec(pathname.slice(prefix.length));
|
|
137
|
+
if (!match) return;
|
|
138
|
+
return {
|
|
139
|
+
traceId: decodeURIComponent(match[1]),
|
|
140
|
+
spanId: decodeURIComponent(match[2])
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Collect and JSON-parse a request body, capped at
|
|
145
|
+
* {@link MAX_EVALUATE_BODY_BYTES} so an oversized body can't hold the
|
|
146
|
+
* connection open indefinitely. An empty body resolves to `undefined` —
|
|
147
|
+
* the evaluate route treats that as "no per-run override".
|
|
148
|
+
*/
|
|
149
|
+
function readJsonBody(req) {
|
|
150
|
+
return new Promise((resolve, reject) => {
|
|
151
|
+
const chunks = [];
|
|
152
|
+
let size = 0;
|
|
153
|
+
req.on("data", (chunk) => {
|
|
154
|
+
size += chunk.length;
|
|
155
|
+
if (size > MAX_EVALUATE_BODY_BYTES) {
|
|
156
|
+
req.destroy();
|
|
157
|
+
reject(/* @__PURE__ */ new Error("payload_too_large"));
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
chunks.push(chunk);
|
|
161
|
+
});
|
|
162
|
+
req.on("end", () => {
|
|
163
|
+
if (chunks.length === 0) {
|
|
164
|
+
resolve(void 0);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
try {
|
|
168
|
+
resolve(JSON.parse(Buffer.concat(chunks).toString("utf-8")));
|
|
169
|
+
} catch {
|
|
170
|
+
reject(/* @__PURE__ */ new Error("invalid_json"));
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
req.on("error", reject);
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Handle `POST {apiPrefix}/traces/:traceId/spans/:spanId/evaluate`. Looks
|
|
178
|
+
* up the trace + span, extracts its last captured system prompt, and
|
|
179
|
+
* grades it via {@link evaluateSystemPrompt}. `judgePromptBody` itself
|
|
180
|
+
* never throws (a broken judge degrades to an issues-only outcome) — the
|
|
181
|
+
* try/catch here only guards `config.evaluate.model` resolution, the one
|
|
182
|
+
* step that CAN throw (e.g. a factory constructing an SDK client).
|
|
183
|
+
*/
|
|
184
|
+
async function handleEvaluate(store, evaluate, traceId, spanId, req, res) {
|
|
185
|
+
const trace = store.get(traceId);
|
|
186
|
+
if (trace === void 0) {
|
|
187
|
+
sendJson(res, 404, {
|
|
188
|
+
error: "trace_not_found",
|
|
189
|
+
traceId
|
|
190
|
+
});
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
const span = findSpanById(trace.root, spanId);
|
|
194
|
+
if (span === void 0) {
|
|
195
|
+
sendJson(res, 404, {
|
|
196
|
+
error: "span_not_found",
|
|
197
|
+
spanId
|
|
198
|
+
});
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
const systemPrompt = extractLastSystemPrompt(span);
|
|
202
|
+
if (systemPrompt === void 0) {
|
|
203
|
+
sendJson(res, 422, { error: "no_system_prompt" });
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
let body;
|
|
207
|
+
try {
|
|
208
|
+
body = await readJsonBody(req);
|
|
209
|
+
} catch (error) {
|
|
210
|
+
const message = error instanceof Error ? error.message : "invalid_json";
|
|
211
|
+
sendJson(res, message === "payload_too_large" ? 413 : 400, { error: message });
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
try {
|
|
215
|
+
sendJson(res, 200, await evaluateSystemPrompt(systemPrompt, evaluate, body?.instructions));
|
|
216
|
+
} catch (error) {
|
|
217
|
+
sendJson(res, 502, {
|
|
218
|
+
error: "evaluate_failed",
|
|
219
|
+
message: error instanceof Error ? error.message : String(error)
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
}
|
|
108
223
|
|
|
109
224
|
//#endregion
|
|
110
225
|
export { createRequestHandler };
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"serve.mjs","names":[],"sources":["../../../../../../../@warlock.js/ai-panoptic/src/dashboard/serve.ts"],"sourcesContent":["import type { IncomingMessage, ServerResponse } from \"node:http\";\nimport type { TraceStoreContract } from \"../store/trace-store.contract\";\nimport { parseQuery } from \"./parse-query\";\nimport { dashboardHtml } from \"./ui.html\";\n\n/** Fully-resolved routing config the request handler closes over. */\nexport type ServeConfig = {\n /** Normalized mount path, always ending in `/` (e.g. `\"/\"`). */\n basePath: string;\n /** Header title baked into the served page. */\n title: string;\n /** Bearer token required on every request when set (S4). */\n authToken?: string;\n /** `Host` header allowlist — defends against DNS-rebinding (S4). */\n allowedHosts: string[];\n};\n\n/**\n * Security response headers added to every dashboard response (S4):\n * block MIME-sniffing, framing, referrer leakage, and lock the page's\n * content sources down to itself (the UI is fully self-contained — no CDN).\n */\nconst SECURITY_HEADERS: Record<string, string> = {\n \"x-content-type-options\": \"nosniff\",\n \"x-frame-options\": \"DENY\",\n \"referrer-policy\": \"no-referrer\",\n \"content-security-policy\":\n \"default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; base-uri 'none'; form-action 'none'; frame-ancestors 'none'\",\n};\n\n/** Extract the host (no port) from a `Host` header value. */\nfunction hostHeaderName(hostHeader: string | undefined): string | undefined {\n if (!hostHeader) return undefined;\n // IPv6 literal `[::1]:4319` → `[::1]`; otherwise strip `:port`.\n if (hostHeader.startsWith(\"[\")) {\n return hostHeader.slice(0, hostHeader.indexOf(\"]\") + 1);\n }\n const colon = hostHeader.indexOf(\":\");\n return colon === -1 ? hostHeader : hostHeader.slice(0, colon);\n}\n\n/** Constant-ish bearer-token check from `Authorization` header or `?token=`. */\nfunction isAuthorized(req: IncomingMessage, url: URL, token: string): boolean {\n const header = req.headers.authorization;\n if (header && header === `Bearer ${token}`) return true;\n return url.searchParams.get(\"token\") === token;\n}\n\n/**\n * Build the `node:http` request handler for the dashboard over a given\n * trace store. Kept separate from the server lifecycle so it can be unit\n * tested by feeding it a fake `req`/`res` without binding a port.\n *\n * Routes (all under `config.basePath`, all read-only):\n *\n * - `GET api/traces` → `store.query(parseQuery(searchParams))`\n * - `GET api/traces/:id` → `store.get(id)` or `404`\n * - `GET api/aggregate` → `store.aggregate(parseQuery(searchParams))`\n * - `GET {basePath}` → the self-contained HTML page\n *\n * Anything else → `404`. Non-`GET` methods → `405`. The store shapes\n * ({@link Trace}/{@link TraceSpan}) are already JSON-safe, so responses\n * are a plain `JSON.stringify` with no serializer.\n *\n * @example\n * const handler = createRequestHandler(store, { basePath: \"/\", title: \"Panoptic\" });\n * http.createServer(handler).listen(4319, \"127.0.0.1\");\n */\nexport function createRequestHandler(\n store: TraceStoreContract,\n config: ServeConfig,\n): (req: IncomingMessage, res: ServerResponse) => void {\n const base = config.basePath;\n const apiPrefix = `${base}api`;\n\n return function handle(req: IncomingMessage, res: ServerResponse): void {\n if (req.method !== \"GET\") {\n sendJson(res, 405, { error: \"method_not_allowed\" });\n\n return;\n }\n\n // `req.url` is path + query only; a dummy origin lets URL parse it.\n const url = new URL(req.url ?? \"/\", \"http://localhost\");\n const pathname = url.pathname;\n\n // Host-header allowlist — blocks DNS-rebinding attacks that point a\n // hostile domain at this loopback port (S4).\n const host = hostHeaderName(req.headers.host);\n if (!host || !config.allowedHosts.includes(host.toLowerCase())) {\n sendJson(res, 403, { error: \"host_not_allowed\" });\n\n return;\n }\n\n // Bearer-token auth when configured (always required off loopback, S4).\n if (config.authToken && !isAuthorized(req, url, config.authToken)) {\n sendJson(res, 401, { error: \"unauthorized\" });\n\n return;\n }\n\n if (pathname === `${apiPrefix}/traces`) {\n sendJson(res, 200, store.query(parseQuery(url.searchParams)));\n\n return;\n }\n\n if (pathname.startsWith(`${apiPrefix}/traces/`)) {\n const traceId = decodeURIComponent(pathname.slice(`${apiPrefix}/traces/`.length));\n const trace = traceId.length > 0 ? store.get(traceId) : undefined;\n\n if (trace === undefined) {\n sendJson(res, 404, { error: \"trace_not_found\", traceId });\n\n return;\n }\n\n sendJson(res, 200, trace);\n\n return;\n }\n\n if (pathname === `${apiPrefix}/aggregate`) {\n sendJson(res, 200, store.aggregate(parseQuery(url.searchParams)));\n\n return;\n }\n\n if (pathname === base || pathname === base.replace(/\\/$/, \"\")) {\n res.writeHead(200, {\n \"content-type\": \"text/html; charset=utf-8\",\n ...SECURITY_HEADERS,\n });\n res.end(dashboardHtml(base, config.title));\n\n return;\n }\n\n sendJson(res, 404, { error: \"not_found\" });\n };\n}\n\n/** Write a JSON response with the given status code and security headers. */\nfunction sendJson(res: ServerResponse, status: number, body: unknown): void {\n res.writeHead(status, {\n \"content-type\": \"application/json; charset=utf-8\",\n ...SECURITY_HEADERS,\n });\n res.end(JSON.stringify(body));\n}\n"],"mappings":";;;;;;;;;AAsBA,MAAM,mBAA2C;CAC/C,0BAA0B;CAC1B,mBAAmB;CACnB,mBAAmB;CACnB,2BACE;AACJ;;AAGA,SAAS,eAAe,YAAoD;CAC1E,IAAI,CAAC,YAAY,OAAO;CAExB,IAAI,WAAW,WAAW,GAAG,GAC3B,OAAO,WAAW,MAAM,GAAG,WAAW,QAAQ,GAAG,IAAI,CAAC;CAExD,MAAM,QAAQ,WAAW,QAAQ,GAAG;CACpC,OAAO,UAAU,KAAK,aAAa,WAAW,MAAM,GAAG,KAAK;AAC9D;;AAGA,SAAS,aAAa,KAAsB,KAAU,OAAwB;CAC5E,MAAM,SAAS,IAAI,QAAQ;CAC3B,IAAI,UAAU,WAAW,UAAU,SAAS,OAAO;CACnD,OAAO,IAAI,aAAa,IAAI,OAAO,MAAM;AAC3C;;;;;;;;;;;;;;;;;;;;;AAsBA,SAAgB,qBACd,OACA,QACqD;CACrD,MAAM,OAAO,OAAO;CACpB,MAAM,YAAY,GAAG,KAAK;CAE1B,OAAO,SAAS,OAAO,KAAsB,KAA2B;EACtE,IAAI,IAAI,WAAW,OAAO;GACxB,SAAS,KAAK,KAAK,EAAE,OAAO,qBAAqB,CAAC;GAElD;EACF;EAGA,MAAM,MAAM,IAAI,IAAI,IAAI,OAAO,KAAK,kBAAkB;EACtD,MAAM,WAAW,IAAI;EAIrB,MAAM,OAAO,eAAe,IAAI,QAAQ,IAAI;EAC5C,IAAI,CAAC,QAAQ,CAAC,OAAO,aAAa,SAAS,KAAK,YAAY,CAAC,GAAG;GAC9D,SAAS,KAAK,KAAK,EAAE,OAAO,mBAAmB,CAAC;GAEhD;EACF;EAGA,IAAI,OAAO,aAAa,CAAC,aAAa,KAAK,KAAK,OAAO,SAAS,GAAG;GACjE,SAAS,KAAK,KAAK,EAAE,OAAO,eAAe,CAAC;GAE5C;EACF;EAEA,IAAI,aAAa,GAAG,UAAU,UAAU;GACtC,SAAS,KAAK,KAAK,MAAM,MAAM,WAAW,IAAI,YAAY,CAAC,CAAC;GAE5D;EACF;EAEA,IAAI,SAAS,WAAW,GAAG,UAAU,SAAS,GAAG;GAC/C,MAAM,UAAU,mBAAmB,SAAS,MAAM,GAAG,UAAU,UAAU,MAAM,CAAC;GAChF,MAAM,QAAQ,QAAQ,SAAS,IAAI,MAAM,IAAI,OAAO,IAAI;GAExD,IAAI,UAAU,QAAW;IACvB,SAAS,KAAK,KAAK;KAAE,OAAO;KAAmB;IAAQ,CAAC;IAExD;GACF;GAEA,SAAS,KAAK,KAAK,KAAK;GAExB;EACF;EAEA,IAAI,aAAa,GAAG,UAAU,aAAa;GACzC,SAAS,KAAK,KAAK,MAAM,UAAU,WAAW,IAAI,YAAY,CAAC,CAAC;GAEhE;EACF;EAEA,IAAI,aAAa,QAAQ,aAAa,KAAK,QAAQ,OAAO,EAAE,GAAG;GAC7D,IAAI,UAAU,KAAK;IACjB,gBAAgB;IAChB,GAAG;GACL,CAAC;GACD,IAAI,IAAI,cAAc,MAAM,OAAO,KAAK,CAAC;GAEzC;EACF;EAEA,SAAS,KAAK,KAAK,EAAE,OAAO,YAAY,CAAC;CAC3C;AACF;;AAGA,SAAS,SAAS,KAAqB,QAAgB,MAAqB;CAC1E,IAAI,UAAU,QAAQ;EACpB,gBAAgB;EAChB,GAAG;CACL,CAAC;CACD,IAAI,IAAI,KAAK,UAAU,IAAI,CAAC;AAC9B"}
|
|
1
|
+
{"version":3,"file":"serve.mjs","names":[],"sources":["../../../../../../../@warlock.js/ai-panoptic/src/dashboard/serve.ts"],"sourcesContent":["import type { IncomingMessage, ServerResponse } from \"node:http\";\nimport type { EvaluateConfig } from \"../evaluate/evaluate.type\";\nimport { evaluateSystemPrompt } from \"../evaluate/evaluate-system-prompt\";\nimport { extractLastSystemPrompt } from \"../evaluate/extract-last-system-prompt\";\nimport { findSpanById } from \"../evaluate/find-span-by-id\";\nimport type { TraceStoreContract } from \"../store/trace-store.contract\";\nimport { parseQuery } from \"./parse-query\";\nimport { dashboardHtml } from \"./ui.html\";\n\n/** Fully-resolved routing config the request handler closes over. */\nexport type ServeConfig = {\n /** Normalized mount path, always ending in `/` (e.g. `\"/\"`). */\n basePath: string;\n /** Header title baked into the served page. */\n title: string;\n /** Bearer token required on every request when set (S4). */\n authToken?: string;\n /** `Host` header allowlist — defends against DNS-rebinding (S4). */\n allowedHosts: string[];\n /**\n * Enables the one write route: `POST\n * {basePath}api/traces/:traceId/spans/:spanId/evaluate`. Absent ⇒ the\n * route 405s (POST isn't accepted by any route) and the served page never\n * renders the Evaluate button.\n */\n evaluate?: EvaluateConfig;\n};\n\n/** Request body accepted by the evaluate route — everything optional. */\ntype EvaluateRequestBody = {\n /** Per-run instructions override; falls back to `config.evaluate.instructions`. */\n instructions?: string;\n};\n\n/** Hard cap on the evaluate route's request body — well beyond a real instructions string. */\nconst MAX_EVALUATE_BODY_BYTES = 64 * 1024;\n\n/**\n * Security response headers added to every dashboard response (S4):\n * block MIME-sniffing, framing, referrer leakage, and lock the page's\n * content sources down to itself (the UI is fully self-contained — no CDN).\n */\nconst SECURITY_HEADERS: Record<string, string> = {\n \"x-content-type-options\": \"nosniff\",\n \"x-frame-options\": \"DENY\",\n \"referrer-policy\": \"no-referrer\",\n \"content-security-policy\":\n \"default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; base-uri 'none'; form-action 'none'; frame-ancestors 'none'\",\n};\n\n/** Extract the host (no port) from a `Host` header value. */\nfunction hostHeaderName(hostHeader: string | undefined): string | undefined {\n if (!hostHeader) return undefined;\n // IPv6 literal `[::1]:4319` → `[::1]`; otherwise strip `:port`.\n if (hostHeader.startsWith(\"[\")) {\n return hostHeader.slice(0, hostHeader.indexOf(\"]\") + 1);\n }\n const colon = hostHeader.indexOf(\":\");\n return colon === -1 ? hostHeader : hostHeader.slice(0, colon);\n}\n\n/** Constant-ish bearer-token check from `Authorization` header or `?token=`. */\nfunction isAuthorized(req: IncomingMessage, url: URL, token: string): boolean {\n const header = req.headers.authorization;\n if (header && header === `Bearer ${token}`) return true;\n return url.searchParams.get(\"token\") === token;\n}\n\n/**\n * Build the `node:http` request handler for the dashboard over a given\n * trace store. Kept separate from the server lifecycle so it can be unit\n * tested by feeding it a fake `req`/`res` without binding a port.\n *\n * Routes (all under `config.basePath`):\n *\n * - `GET api/traces` → `store.query(parseQuery(searchParams))`\n * - `GET api/traces/:id` → `store.get(id)` or `404`\n * - `GET api/aggregate` → `store.aggregate(parseQuery(searchParams))`\n * - `GET {basePath}` → the self-contained HTML page\n * - `POST api/traces/:traceId/spans/:spanId/evaluate` → ONLY when\n * `config.evaluate` is set; grades the span's last captured system\n * prompt and returns a {@link EvaluateVerdict}. The dashboard's one\n * write route — absent config, POST 405s like it would against any\n * other route (the path is never even pattern-matched).\n *\n * Anything else → `404`. A method the matched route doesn't accept → `405`.\n * Host allowlist + bearer-token auth (S4) are checked for EVERY request,\n * regardless of method, before any routing. The store shapes\n * ({@link Trace}/{@link TraceSpan}) are already JSON-safe, so GET responses\n * are a plain `JSON.stringify` with no serializer.\n *\n * @example\n * const handler = createRequestHandler(store, { basePath: \"/\", title: \"Panoptic\" });\n * http.createServer(handler).listen(4319, \"127.0.0.1\");\n */\nexport function createRequestHandler(\n store: TraceStoreContract,\n config: ServeConfig,\n): (req: IncomingMessage, res: ServerResponse) => void {\n const base = config.basePath;\n const apiPrefix = `${base}api`;\n\n return function handle(req: IncomingMessage, res: ServerResponse): void {\n // `req.url` is path + query only; a dummy origin lets URL parse it.\n const url = new URL(req.url ?? \"/\", \"http://localhost\");\n const pathname = url.pathname;\n\n // Host-header allowlist — blocks DNS-rebinding attacks that point a\n // hostile domain at this loopback port (S4). Checked for every method.\n const host = hostHeaderName(req.headers.host);\n if (!host || !config.allowedHosts.includes(host.toLowerCase())) {\n sendJson(res, 403, { error: \"host_not_allowed\" });\n\n return;\n }\n\n // Bearer-token auth when configured (always required off loopback, S4).\n if (config.authToken && !isAuthorized(req, url, config.authToken)) {\n sendJson(res, 401, { error: \"unauthorized\" });\n\n return;\n }\n\n // The one write route — opt-in via `config.evaluate`, so it must be\n // checked before the blanket GET-only gate below.\n if (req.method === \"POST\" && config.evaluate) {\n const match = matchEvaluatePath(pathname, apiPrefix);\n\n if (match) {\n void handleEvaluate(store, config.evaluate, match.traceId, match.spanId, req, res);\n\n return;\n }\n }\n\n if (req.method !== \"GET\") {\n sendJson(res, 405, { error: \"method_not_allowed\" });\n\n return;\n }\n\n if (pathname === `${apiPrefix}/traces`) {\n sendJson(res, 200, store.query(parseQuery(url.searchParams)));\n\n return;\n }\n\n if (pathname.startsWith(`${apiPrefix}/traces/`)) {\n const traceId = decodeURIComponent(pathname.slice(`${apiPrefix}/traces/`.length));\n const trace = traceId.length > 0 ? store.get(traceId) : undefined;\n\n if (trace === undefined) {\n sendJson(res, 404, { error: \"trace_not_found\", traceId });\n\n return;\n }\n\n sendJson(res, 200, trace);\n\n return;\n }\n\n if (pathname === `${apiPrefix}/aggregate`) {\n sendJson(res, 200, store.aggregate(parseQuery(url.searchParams)));\n\n return;\n }\n\n if (pathname === base || pathname === base.replace(/\\/$/, \"\")) {\n res.writeHead(200, {\n \"content-type\": \"text/html; charset=utf-8\",\n ...SECURITY_HEADERS,\n });\n res.end(dashboardHtml(base, config.title, Boolean(config.evaluate), config.evaluate?.instructions ?? \"\"));\n\n return;\n }\n\n sendJson(res, 404, { error: \"not_found\" });\n };\n}\n\n/** Write a JSON response with the given status code and security headers. */\nfunction sendJson(res: ServerResponse, status: number, body: unknown): void {\n res.writeHead(status, {\n \"content-type\": \"application/json; charset=utf-8\",\n ...SECURITY_HEADERS,\n });\n res.end(JSON.stringify(body));\n}\n\n/**\n * Match `{apiPrefix}/traces/:traceId/spans/:spanId/evaluate` — the one\n * write route. Returns `undefined` for anything else, including the plain\n * `{apiPrefix}/traces/:id` read route (no `/spans/.../evaluate` suffix),\n * so the two never collide.\n */\nfunction matchEvaluatePath(\n pathname: string,\n apiPrefix: string,\n): { traceId: string; spanId: string } | undefined {\n const prefix = `${apiPrefix}/traces/`;\n\n if (!pathname.startsWith(prefix)) {\n return undefined;\n }\n\n const match = /^([^/]+)\\/spans\\/([^/]+)\\/evaluate$/.exec(pathname.slice(prefix.length));\n\n if (!match) {\n return undefined;\n }\n\n return {\n traceId: decodeURIComponent(match[1]),\n spanId: decodeURIComponent(match[2]),\n };\n}\n\n/**\n * Collect and JSON-parse a request body, capped at\n * {@link MAX_EVALUATE_BODY_BYTES} so an oversized body can't hold the\n * connection open indefinitely. An empty body resolves to `undefined` —\n * the evaluate route treats that as \"no per-run override\".\n */\nfunction readJsonBody<T>(req: IncomingMessage): Promise<T | undefined> {\n return new Promise((resolve, reject) => {\n const chunks: Buffer[] = [];\n let size = 0;\n\n req.on(\"data\", (chunk: Buffer) => {\n size += chunk.length;\n\n if (size > MAX_EVALUATE_BODY_BYTES) {\n req.destroy();\n reject(new Error(\"payload_too_large\"));\n\n return;\n }\n\n chunks.push(chunk);\n });\n\n req.on(\"end\", () => {\n if (chunks.length === 0) {\n resolve(undefined);\n\n return;\n }\n\n try {\n resolve(JSON.parse(Buffer.concat(chunks).toString(\"utf-8\")) as T);\n } catch {\n reject(new Error(\"invalid_json\"));\n }\n });\n\n req.on(\"error\", reject);\n });\n}\n\n/**\n * Handle `POST {apiPrefix}/traces/:traceId/spans/:spanId/evaluate`. Looks\n * up the trace + span, extracts its last captured system prompt, and\n * grades it via {@link evaluateSystemPrompt}. `judgePromptBody` itself\n * never throws (a broken judge degrades to an issues-only outcome) — the\n * try/catch here only guards `config.evaluate.model` resolution, the one\n * step that CAN throw (e.g. a factory constructing an SDK client).\n */\nasync function handleEvaluate(\n store: TraceStoreContract,\n evaluate: EvaluateConfig,\n traceId: string,\n spanId: string,\n req: IncomingMessage,\n res: ServerResponse,\n): Promise<void> {\n const trace = store.get(traceId);\n\n if (trace === undefined) {\n sendJson(res, 404, { error: \"trace_not_found\", traceId });\n\n return;\n }\n\n const span = findSpanById(trace.root, spanId);\n\n if (span === undefined) {\n sendJson(res, 404, { error: \"span_not_found\", spanId });\n\n return;\n }\n\n const systemPrompt = extractLastSystemPrompt(span);\n\n if (systemPrompt === undefined) {\n sendJson(res, 422, { error: \"no_system_prompt\" });\n\n return;\n }\n\n let body: EvaluateRequestBody | undefined;\n\n try {\n body = await readJsonBody<EvaluateRequestBody>(req);\n } catch (error) {\n const message = error instanceof Error ? error.message : \"invalid_json\";\n sendJson(res, message === \"payload_too_large\" ? 413 : 400, { error: message });\n\n return;\n }\n\n try {\n const verdict = await evaluateSystemPrompt(systemPrompt, evaluate, body?.instructions);\n sendJson(res, 200, verdict);\n } catch (error) {\n sendJson(res, 502, {\n error: \"evaluate_failed\",\n message: error instanceof Error ? error.message : String(error),\n });\n }\n}\n"],"mappings":"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"}
|
|
@@ -40,7 +40,7 @@ import { WARLOCK_LOGO_DATA_URI } from "./warlock-logo.mjs";
|
|
|
40
40
|
* @param basePath Normalized mount path ending in `/` (e.g. `"/"`).
|
|
41
41
|
* @param title Header title shown in the page.
|
|
42
42
|
*/
|
|
43
|
-
function dashboardHtml(basePath, title) {
|
|
43
|
+
function dashboardHtml(basePath, title, evaluateEnabled = false, evaluateDefaultInstructions = "") {
|
|
44
44
|
const apiBase = `${basePath}api`;
|
|
45
45
|
const safeTitle = escapeHtml(title);
|
|
46
46
|
return `<!doctype html>
|
|
@@ -221,6 +221,18 @@ function dashboardHtml(basePath, title) {
|
|
|
221
221
|
.legend { margin-left: auto; display: flex; align-items: center; gap: 6px; font-size: 11px; color: var(--dim); }
|
|
222
222
|
.legend .grad { width: 64px; height: 8px; border-radius: 4px; background: linear-gradient(90deg, var(--surface2), var(--cost)); border: 1px solid var(--border); }
|
|
223
223
|
.gantt { padding: 8px 10px; }
|
|
224
|
+
|
|
225
|
+
/* Evaluate — the drawer's one write action (config-gated). */
|
|
226
|
+
.eval-btn { background: transparent; border: 1px solid var(--border); color: var(--text2); cursor: pointer; font-size: 12px; padding: 4px 10px; border-radius: 6px; margin: 10px 0 0; }
|
|
227
|
+
.eval-btn:hover:not(:disabled) { color: var(--text); border-color: var(--border2); }
|
|
228
|
+
.eval-btn:disabled { opacity: .6; cursor: default; }
|
|
229
|
+
.eval-panel { margin-top: 8px; padding: 10px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface2); }
|
|
230
|
+
.eval-panel textarea { width: 100%; min-height: 64px; background: var(--panel); color: var(--text); border: 1px solid var(--border); border-radius: 6px; padding: 8px; font: 12px/1.5 ui-sans-serif, system-ui, sans-serif; resize: vertical; }
|
|
231
|
+
.eval-actions { display: flex; align-items: center; gap: 8px; margin-top: 8px; }
|
|
232
|
+
.eval-error { color: var(--fail); font-size: 12px; }
|
|
233
|
+
.eval-result { margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--border); font-size: 12px; }
|
|
234
|
+
.eval-score { font-weight: 600; }
|
|
235
|
+
.eval-issues { margin: 6px 0 0; padding-left: 18px; }
|
|
224
236
|
.grow { display: flex; align-items: center; gap: 8px; padding: 2px 0; font-size: 12px; cursor: pointer; border-radius: 4px; }
|
|
225
237
|
.grow:hover { background: var(--surface); }
|
|
226
238
|
.grow.selected { background: var(--sel-bg); }
|
|
@@ -285,7 +297,21 @@ function dashboardHtml(basePath, title) {
|
|
|
285
297
|
<script>
|
|
286
298
|
(function () {
|
|
287
299
|
var API = ${JSON.stringify(apiBase)};
|
|
300
|
+
var EVALUATE_ENABLED = ${JSON.stringify(evaluateEnabled)};
|
|
288
301
|
var BT = String.fromCharCode(96);
|
|
302
|
+
var EVALUATE_DEFAULT_INSTRUCTIONS = ${encodeForInlineScript(evaluateDefaultInstructions)};
|
|
303
|
+
|
|
304
|
+
// Carry the ?token= the page itself was loaded with onto every
|
|
305
|
+
// subsequent poll — otherwise the API calls below inherit no auth and
|
|
306
|
+
// 401 forever once authToken is configured (the initial page load is
|
|
307
|
+
// the only request the URL's query string naturally reaches).
|
|
308
|
+
var TOKEN = new URLSearchParams(window.location.search).get("token");
|
|
309
|
+
function fetchAuthed(url, options) {
|
|
310
|
+
var opts = options || {};
|
|
311
|
+
var headers = opts.headers || {};
|
|
312
|
+
if (TOKEN) headers = Object.assign({}, headers, { Authorization: "Bearer " + TOKEN });
|
|
313
|
+
return fetch(url, Object.assign({}, opts, { headers: headers }));
|
|
314
|
+
}
|
|
289
315
|
|
|
290
316
|
var state = {
|
|
291
317
|
traces: [], selectedId: null, selectedSpanId: null, collapsed: {}, sig: null,
|
|
@@ -297,7 +323,8 @@ function dashboardHtml(basePath, title) {
|
|
|
297
323
|
showStats: false, // per-type aggregate-stats panel toggle (independent of grouping)
|
|
298
324
|
collapsedGroups: {}, // collapsed group headers (session, prompt, or type)
|
|
299
325
|
view: "tree", // drawer left pane: "tree" | "timeline"
|
|
300
|
-
hashApplied: false
|
|
326
|
+
hashApplied: false, // guards one-time deep-link open on load
|
|
327
|
+
evaluate: {} // per-span evaluate UI state, keyed by spanId
|
|
301
328
|
};
|
|
302
329
|
|
|
303
330
|
var STATUS_FILTERS = ["completed", "failed", "cancelled"];
|
|
@@ -1006,6 +1033,80 @@ function dashboardHtml(basePath, title) {
|
|
|
1006
1033
|
return '<div class="meta-sec"><div class="meta-title">metadata</div><div class="kv">' + kv + "</div></div>";
|
|
1007
1034
|
}
|
|
1008
1035
|
|
|
1036
|
+
// --- Evaluate: grade a span's last captured system prompt -------------
|
|
1037
|
+
// Config-gated (EVALUATE_ENABLED) — the drawer's only write action, POSTing
|
|
1038
|
+
// to a route that itself only exists when the server was configured with
|
|
1039
|
+
// evaluate. UI state lives in state.evaluate, keyed by spanId, so it
|
|
1040
|
+
// survives a re-render (e.g. switching to a sibling span and back).
|
|
1041
|
+
function extractLastSystemPrompt(span) {
|
|
1042
|
+
if (!Array.isArray(span.input)) return null;
|
|
1043
|
+
for (var i = span.input.length - 1; i >= 0; i--) {
|
|
1044
|
+
var m = span.input[i];
|
|
1045
|
+
if (m && typeof m === "object" && m.role === "system" && typeof m.content === "string") return m.content;
|
|
1046
|
+
}
|
|
1047
|
+
return null;
|
|
1048
|
+
}
|
|
1049
|
+
function evalState(spanId) {
|
|
1050
|
+
return state.evaluate[spanId] || (state.evaluate[spanId] = {
|
|
1051
|
+
open: false, instructions: EVALUATE_DEFAULT_INSTRUCTIONS, status: "idle", result: null, error: null
|
|
1052
|
+
});
|
|
1053
|
+
}
|
|
1054
|
+
function evalResultHtml(result) {
|
|
1055
|
+
var score = typeof result.score === "number" ? Math.round(result.score * 100) + "%" : "n/a";
|
|
1056
|
+
var issues = (result.issues || []).map(function (i) { return "<li>" + esc(i) + "</li>"; }).join("");
|
|
1057
|
+
return '<div class="eval-result"><span class="eval-score">Score: ' + score + "</span>"
|
|
1058
|
+
+ (issues ? '<ul class="eval-issues">' + issues + "</ul>" : "") + "</div>";
|
|
1059
|
+
}
|
|
1060
|
+
function evalSectionHtml(span) {
|
|
1061
|
+
if (!EVALUATE_ENABLED) return "";
|
|
1062
|
+
var sysPrompt = extractLastSystemPrompt(span);
|
|
1063
|
+
if (!sysPrompt) return "";
|
|
1064
|
+
var st = evalState(span.spanId);
|
|
1065
|
+
var btn = '<button type="button" class="eval-btn" data-evaluate-toggle="' + esc(span.spanId) + '">'
|
|
1066
|
+
+ (st.open ? "Hide evaluate" : "Evaluate system prompt") + "</button>";
|
|
1067
|
+
if (!st.open) return btn;
|
|
1068
|
+
var running = st.status === "running";
|
|
1069
|
+
return btn + '<div class="eval-panel">'
|
|
1070
|
+
+ '<textarea id="eval-instructions" placeholder="Grading instructions (optional — falls back to the configured default)"' + (running ? " disabled" : "") + ">" + esc(st.instructions || "") + "</textarea>"
|
|
1071
|
+
+ '<div class="eval-actions">'
|
|
1072
|
+
+ '<button type="button" class="eval-btn" data-evaluate-run="' + esc(span.spanId) + '"' + (running ? " disabled" : "") + ">" + (running ? "Evaluating…" : "Run") + "</button>"
|
|
1073
|
+
+ (st.error ? '<span class="eval-error">' + esc(st.error) + "</span>" : "")
|
|
1074
|
+
+ "</div>"
|
|
1075
|
+
+ (st.result ? evalResultHtml(st.result) : "")
|
|
1076
|
+
+ "</div>";
|
|
1077
|
+
}
|
|
1078
|
+
function rerenderDetail() {
|
|
1079
|
+
var t = findTrace(state.selectedId);
|
|
1080
|
+
if (!t) return;
|
|
1081
|
+
var sel = findSpan(t.root, state.selectedSpanId) || t.root;
|
|
1082
|
+
document.getElementById("drawer-detail").innerHTML = renderDetail(sel, t);
|
|
1083
|
+
}
|
|
1084
|
+
function toggleEvalPanel(spanId) {
|
|
1085
|
+
evalState(spanId).open = !evalState(spanId).open;
|
|
1086
|
+
rerenderDetail();
|
|
1087
|
+
}
|
|
1088
|
+
function runEvaluate(traceId, spanId) {
|
|
1089
|
+
var st = evalState(spanId);
|
|
1090
|
+
var textarea = document.getElementById("eval-instructions");
|
|
1091
|
+
if (textarea) st.instructions = textarea.value;
|
|
1092
|
+
st.status = "running"; st.error = null;
|
|
1093
|
+
rerenderDetail();
|
|
1094
|
+
fetchAuthed(API + "/traces/" + encodeURIComponent(traceId) + "/spans/" + encodeURIComponent(spanId) + "/evaluate", {
|
|
1095
|
+
method: "POST",
|
|
1096
|
+
headers: { "content-type": "application/json" },
|
|
1097
|
+
body: JSON.stringify({ instructions: st.instructions })
|
|
1098
|
+
}).then(function (r) {
|
|
1099
|
+
return r.json().then(function (data) { return { ok: r.ok, data: data }; });
|
|
1100
|
+
}).then(function (res) {
|
|
1101
|
+
if (res.ok) { st.status = "done"; st.result = res.data; st.error = null; }
|
|
1102
|
+
else { st.status = "error"; st.error = (res.data && (res.data.message || res.data.error)) || "evaluate failed"; st.result = null; }
|
|
1103
|
+
rerenderDetail();
|
|
1104
|
+
}).catch(function () {
|
|
1105
|
+
st.status = "error"; st.error = "network error"; st.result = null;
|
|
1106
|
+
rerenderDetail();
|
|
1107
|
+
});
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1009
1110
|
function renderDetail(span, trace) {
|
|
1010
1111
|
var path = findPath(trace.root, span.spanId) || [span];
|
|
1011
1112
|
var crumb = path.map(function (p, i) { return (i ? '<span class="sep"> › </span>' : "") + "<span>" + esc(p.name) + "</span>"; }).join("");
|
|
@@ -1028,6 +1129,7 @@ function dashboardHtml(basePath, title) {
|
|
|
1028
1129
|
+ '<span class="dim">· ' + dur(span.duration) + "</span></div>"
|
|
1029
1130
|
+ metaLine
|
|
1030
1131
|
+ io
|
|
1132
|
+
+ evalSectionHtml(span, trace)
|
|
1031
1133
|
+ renderMeta(span, trace);
|
|
1032
1134
|
}
|
|
1033
1135
|
|
|
@@ -1190,9 +1292,20 @@ function dashboardHtml(basePath, title) {
|
|
|
1190
1292
|
if (vb) { setView(vb.getAttribute("data-view")); return; }
|
|
1191
1293
|
var tog = e.target.closest ? e.target.closest("[data-toggle]") : null;
|
|
1192
1294
|
if (tog) { toggleSpan(tog.getAttribute("data-toggle")); return; }
|
|
1295
|
+
var evalToggle = e.target.closest ? e.target.closest("[data-evaluate-toggle]") : null;
|
|
1296
|
+
if (evalToggle) { toggleEvalPanel(evalToggle.getAttribute("data-evaluate-toggle")); return; }
|
|
1297
|
+
var evalRun = e.target.closest ? e.target.closest("[data-evaluate-run]") : null;
|
|
1298
|
+
if (evalRun && state.selectedId) { runEvaluate(state.selectedId, evalRun.getAttribute("data-evaluate-run")); return; }
|
|
1193
1299
|
var node = e.target.closest ? e.target.closest(".tnode[data-span], .grow[data-span]") : null;
|
|
1194
1300
|
if (node) selectSpan(node.getAttribute("data-span"));
|
|
1195
1301
|
});
|
|
1302
|
+
// Track the instructions textarea live (no re-render on keystroke, so
|
|
1303
|
+
// typing never loses focus/cursor position).
|
|
1304
|
+
document.getElementById("drawer-body").addEventListener("input", function (e) {
|
|
1305
|
+
if (e.target && e.target.id === "eval-instructions" && state.selectedSpanId) {
|
|
1306
|
+
evalState(state.selectedSpanId).instructions = e.target.value;
|
|
1307
|
+
}
|
|
1308
|
+
});
|
|
1196
1309
|
document.getElementById("backdrop").addEventListener("click", closeDrawer);
|
|
1197
1310
|
document.addEventListener("keydown", function (e) { if (e.key === "Escape" || e.keyCode === 27) closeDrawer(); });
|
|
1198
1311
|
|
|
@@ -1288,8 +1401,8 @@ function dashboardHtml(basePath, title) {
|
|
|
1288
1401
|
|
|
1289
1402
|
function poll() {
|
|
1290
1403
|
Promise.all([
|
|
1291
|
-
|
|
1292
|
-
|
|
1404
|
+
fetchAuthed(API + "/aggregate").then(function (r) { return r.json(); }),
|
|
1405
|
+
fetchAuthed(API + "/traces").then(function (r) { return r.json(); })
|
|
1293
1406
|
]).then(function (res) {
|
|
1294
1407
|
renderStats(res[0]);
|
|
1295
1408
|
state.traces = res[1] || [];
|
|
@@ -1324,6 +1437,22 @@ function dashboardHtml(basePath, title) {
|
|
|
1324
1437
|
function escapeHtml(value) {
|
|
1325
1438
|
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
1326
1439
|
}
|
|
1440
|
+
/**
|
|
1441
|
+
* Encode free-form text (e.g. `evaluate.instructions`, which an operator
|
|
1442
|
+
* could type anything into) as a JS EXPRESSION that reconstructs it at
|
|
1443
|
+
* runtime, WITHOUT ever emitting a literal backtick into the served page —
|
|
1444
|
+
* the client script is itself built from a TS template literal, so a raw
|
|
1445
|
+
* backtick in the output would be a real syntax hazard (see the `BT =
|
|
1446
|
+
* String.fromCharCode(96)` construction already in the client script for
|
|
1447
|
+
* the same reason). `JSON.stringify` alone doesn't escape backticks (they
|
|
1448
|
+
* aren't JSON-significant), so a value containing one is split around it
|
|
1449
|
+
* and rejoined with the client's own `BT` constant. No backticks in the
|
|
1450
|
+
* input ⇒ a single plain `JSON.stringify(value)` — no unnecessary
|
|
1451
|
+
* concatenation in the common case.
|
|
1452
|
+
*/
|
|
1453
|
+
function encodeForInlineScript(value) {
|
|
1454
|
+
return value.split("`").map((part) => JSON.stringify(part)).join(" + BT + ");
|
|
1455
|
+
}
|
|
1327
1456
|
|
|
1328
1457
|
//#endregion
|
|
1329
1458
|
export { dashboardHtml };
|