@warlock.js/ai-panoptic 5.2.2 → 5.2.4
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 +6 -0
- package/cjs/index.cjs.map +1 -1
- package/esm/collector/collector.mjs.map +1 -1
- package/esm/collector/extract-span-attributes.mjs.map +1 -1
- package/esm/collector/report-to-span.mjs.map +1 -1
- package/esm/config/apply-panoptic-config.mjs.map +1 -1
- package/esm/dashboard/dashboard.mjs.map +1 -1
- package/esm/dashboard/parse-query.mjs.map +1 -1
- package/esm/dashboard/serve.mjs.map +1 -1
- package/esm/dashboard/trace-filter.mjs.map +1 -1
- package/esm/dashboard/ui.html.mjs.map +1 -1
- package/esm/exporters/console/format-span-io.mjs.map +1 -1
- package/esm/exporters/file/file-exporter.mjs.map +1 -1
- package/esm/exporters/langfuse/langfuse-exporter.mjs.map +1 -1
- package/esm/exporters/otel/otel-exporter.mjs.map +1 -1
- package/esm/panoptic/panoptic-middleware.mjs.map +1 -1
- package/esm/panoptic/panoptic.mjs.map +1 -1
- package/esm/register.mjs.map +1 -1
- package/esm/store/cache-trace-store.mjs.map +1 -1
- package/esm/store/in-memory-trace-store.mjs.map +1 -1
- package/esm/store/sum-usage.d.mts.map +1 -1
- package/package.json +4 -4
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"collector.mjs","names":[],"sources":["../../../../../../../ai-panoptic/src/collector/collector.ts"],"sourcesContent":["import type { BaseReport } from \"@warlock.js/ai\";\nimport type { CollectorContract } from \"../contracts/collector.contract\";\nimport type { ExporterContract } from \"../contracts/exporter.contract\";\nimport type { Trace } from \"../contracts/trace.type\";\nimport { walkSpans } from \"../exporters/utils/walk-spans\";\nimport type { ContentCaptureOptions } from \"./content-capture.type\";\nimport { reportToTrace } from \"./report-to-trace\";\n\n/**\n * Notified when a registered exporter throws during `collect` / `flush` /\n * `shutdown`. The failure stays isolated (the originating run never sees\n * it) — this is purely a chance to surface it (route to your logger,\n * bump a metric). Receives the exporter's `name` and the thrown error.\n */\nexport type ExporterErrorHandler = (exporterName: string, error: unknown) => void;\n\n/** Options for {@link createCollector}. */\nexport type CollectorOptions = ContentCaptureOptions & {\n /**\n * Called when an exporter throws (and is isolated). Overrides the default\n * one-time `console.warn` per exporter — route the failure to your own\n * logger / metrics instead. Errors thrown by the handler itself are\n * swallowed so observability never crashes the run.\n */\n onError?: ExporterErrorHandler;\n};\n\n/**\n * Drive the source end of the Panoptic pipeline: ingest core\n * `@warlock.js/ai` {@link BaseReport} trees, project them into {@link\n * Trace}s, and fan each trace out to every registered exporter.\n *\n * Owns exporter registration (deduped by `ExporterContract.name`), the\n * report→trace projection, and graceful shutdown so exporters drain\n * before exit. Instantiated fresh per collector via {@link\n * createCollector}; callers never see `new`.\n *\n * **Failure isolation.** An exporter that throws never propagates back\n * into the originating run — `collect`, `flush`, and `shutdown` settle\n * every exporter independently (mirrors how the core event hooks\n * swallow consumer errors). One broken exporter can neither crash the\n * agent loop nor stop sibling exporters from receiving the trace. The\n * failure is still **surfaced** — via the `onError` option, or a one-time\n * `console.warn` per exporter — so a misconfiguration (e.g. a missing\n * optional peer like `langfuse`) doesn't fail silently.\n */\nclass Collector implements CollectorContract {\n /**\n * Registered exporters in insertion order. A `Map` keyed by\n * `ExporterContract.name` gives O(1) dedupe on `use` while preserving\n * registration order for deterministic fan-out.\n */\n private readonly exporters = new Map<string, ExporterContract>();\n\n /**\n * Exporters already warned about on the default error path — so a\n * persistent misconfiguration surfaces once, not on every trace.\n */\n private readonly warnedExporters = new Set<string>();\n\n /**\n * Collector options: content capture threaded into every `toTrace`\n * projection (`captureContent` populates span `input` / `output`), plus\n * an optional `onError` for isolated exporter failures.\n */\n public constructor(private readonly options: CollectorOptions = {}) {}\n\n public use(exporter: ExporterContract): this {\n if (!this.exporters.has(exporter.name)) {\n this.exporters.set(exporter.name, exporter);\n }\n\n return this;\n }\n\n public toTrace(report: BaseReport, rootError?: unknown): Trace {\n return reportToTrace(report, rootError, this.options);\n }\n\n public async collect(report: BaseReport, rootError?: unknown): Promise<void> {\n const trace = this.toTrace(report, rootError);\n\n await this.dispatch(trace);\n }\n\n public async flush(): Promise<void> {\n await this.settleAll((exporter) => exporter.flush?.());\n }\n\n public async shutdown(): Promise<void> {\n await this.flush();\n\n await this.settleAll((exporter) => exporter.shutdown?.());\n\n this.exporters.clear();\n }\n\n /**\n * Fan one trace out to every exporter and, when an exporter advertises\n * the per-span hook, deliver every span in the finalized tree to it as\n * well. `exportSpan` is a post-completion per-span hook (not a live /\n * streaming feed — the trace is already finalized): we walk the tree in\n * pre-order with {@link walkSpans} so the exporter sees the root and\n * every descendant exactly once. Every invocation is isolated so a\n * throwing exporter can't abort the dispatch to its siblings or escape\n * into the originating run.\n */\n private async dispatch(trace: Trace): Promise<void> {\n await this.settleAll(async (exporter) => {\n await exporter.export(trace);\n\n if (exporter.exportSpan !== undefined) {\n for (const span of walkSpans(trace.root)) {\n await exporter.exportSpan(span);\n }\n }\n });\n }\n\n /**\n * Run `task` against every registered exporter and wait for all of\n * them to settle, swallowing individual rejections. `Promise.allSettled`\n * guarantees one failure neither rejects the batch nor blocks the\n * others — the contract's failure-isolation requirement.\n */\n private async settleAll(\n task: (exporter: ExporterContract) => void | Promise<void>,\n ): Promise<void> {\n const runs = [...this.exporters.entries()].map(([name, exporter]) =>\n Promise.resolve()\n .then(() => task(exporter))\n .catch((error: unknown) => this.reportExporterError(name, error)),\n );\n\n await Promise.allSettled(runs);\n }\n\n /**\n * Surface an isolated exporter failure. The originating run never sees it\n * (the isolation guarantee holds), but a silent failure is the wrong\n * default for a config error — e.g. a missing optional peer like\n * `langfuse` would otherwise drop every trace with no signal. The\n * supplied `onError` is called, or — by default — a `console.warn` is\n * emitted ONCE per exporter so the cause is visible without spamming.\n */\n private reportExporterError(name: string, error: unknown): void {\n if (this.options.onError) {\n try {\n this.options.onError(name, error);\n } catch {\n // Never let the error handler itself escape into the run.\n }\n return;\n }\n\n if (this.warnedExporters.has(name)) {\n return;\n }\n\n this.warnedExporters.add(name);\n const message = error instanceof Error ? error.message : String(error);\n console.warn(`[panoptic] exporter \"${name}\" failed and was isolated: ${message}`);\n }\n}\n\n/**\n * Create a Panoptic collector — the single integration point an app\n * wires into its agents/workflows (typically via the `onComplete`\n * report hook). Register exporters with `use`, then feed finalized root\n * reports to `collect`.\n *\n * @example\n * const collector = createCollector().use(otelExporter).use(langfuseExporter);\n * agent.on(\"onComplete\", ({ result }) => collector.collect(result.report));\n * // on shutdown:\n * await collector.shutdown();\n */\nexport function createCollector(options: CollectorOptions = {}): CollectorContract {\n return new Collector(options);\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;AA8CA,IAAM,YAAN,MAA6C;CAmBP;;;;;;CAbpC,AAAiB,4BAAY,IAAI,IAA8B;;;;;CAM/D,AAAiB,kCAAkB,IAAI,IAAY;;;;;;CAOnD,AAAO,YAAY,AAAiB,UAA4B,CAAC,GAAG;EAAhC;CAAiC;CAErE,AAAO,IAAI,UAAkC;EAC3C,IAAI,CAAC,KAAK,UAAU,IAAI,SAAS,IAAI,GACnC,KAAK,UAAU,IAAI,SAAS,MAAM,QAAQ;EAG5C,OAAO;CACT;CAEA,AAAO,QAAQ,QAAoB,WAA4B;EAC7D,OAAO,cAAc,QAAQ,WAAW,KAAK,OAAO;CACtD;CAEA,MAAa,QAAQ,QAAoB,WAAoC;EAC3E,MAAM,QAAQ,KAAK,QAAQ,QAAQ,SAAS;EAE5C,MAAM,KAAK,SAAS,KAAK;CAC3B;CAEA,MAAa,QAAuB;EAClC,MAAM,KAAK,WAAW,aAAa,SAAS,QAAQ,CAAC;CACvD;CAEA,MAAa,WAA0B;EACrC,MAAM,KAAK,MAAM;EAEjB,MAAM,KAAK,WAAW,aAAa,SAAS,WAAW,CAAC;EAExD,KAAK,UAAU,MAAM;CACvB;;;;;;;;;;;CAYA,MAAc,SAAS,OAA6B;EAClD,MAAM,KAAK,UAAU,OAAO,aAAa;GACvC,MAAM,SAAS,OAAO,KAAK;GAE3B,IAAI,SAAS,eAAe,QAC1B,KAAK,MAAM,QAAQ,UAAU,MAAM,IAAI,GACrC,MAAM,SAAS,WAAW,IAAI;EAGpC,CAAC;CACH;;;;;;;CAQA,MAAc,UACZ,MACe;EACf,MAAM,OAAO,CAAC,GAAG,KAAK,UAAU,QAAQ,CAAC,
|
|
1
|
+
{"version":3,"file":"collector.mjs","names":[],"sources":["../../../../../../../ai-panoptic/src/collector/collector.ts"],"sourcesContent":["import type { BaseReport } from \"@warlock.js/ai\";\nimport type { CollectorContract } from \"../contracts/collector.contract\";\nimport type { ExporterContract } from \"../contracts/exporter.contract\";\nimport type { Trace } from \"../contracts/trace.type\";\nimport { walkSpans } from \"../exporters/utils/walk-spans\";\nimport type { ContentCaptureOptions } from \"./content-capture.type\";\nimport { reportToTrace } from \"./report-to-trace\";\n\n/**\n * Notified when a registered exporter throws during `collect` / `flush` /\n * `shutdown`. The failure stays isolated (the originating run never sees\n * it) — this is purely a chance to surface it (route to your logger,\n * bump a metric). Receives the exporter's `name` and the thrown error.\n */\nexport type ExporterErrorHandler = (exporterName: string, error: unknown) => void;\n\n/** Options for {@link createCollector}. */\nexport type CollectorOptions = ContentCaptureOptions & {\n /**\n * Called when an exporter throws (and is isolated). Overrides the default\n * one-time `console.warn` per exporter — route the failure to your own\n * logger / metrics instead. Errors thrown by the handler itself are\n * swallowed so observability never crashes the run.\n */\n onError?: ExporterErrorHandler;\n};\n\n/**\n * Drive the source end of the Panoptic pipeline: ingest core\n * `@warlock.js/ai` {@link BaseReport} trees, project them into {@link\n * Trace}s, and fan each trace out to every registered exporter.\n *\n * Owns exporter registration (deduped by `ExporterContract.name`), the\n * report→trace projection, and graceful shutdown so exporters drain\n * before exit. Instantiated fresh per collector via {@link\n * createCollector}; callers never see `new`.\n *\n * **Failure isolation.** An exporter that throws never propagates back\n * into the originating run — `collect`, `flush`, and `shutdown` settle\n * every exporter independently (mirrors how the core event hooks\n * swallow consumer errors). One broken exporter can neither crash the\n * agent loop nor stop sibling exporters from receiving the trace. The\n * failure is still **surfaced** — via the `onError` option, or a one-time\n * `console.warn` per exporter — so a misconfiguration (e.g. a missing\n * optional peer like `langfuse`) doesn't fail silently.\n */\nclass Collector implements CollectorContract {\n /**\n * Registered exporters in insertion order. A `Map` keyed by\n * `ExporterContract.name` gives O(1) dedupe on `use` while preserving\n * registration order for deterministic fan-out.\n */\n private readonly exporters = new Map<string, ExporterContract>();\n\n /**\n * Exporters already warned about on the default error path — so a\n * persistent misconfiguration surfaces once, not on every trace.\n */\n private readonly warnedExporters = new Set<string>();\n\n /**\n * Collector options: content capture threaded into every `toTrace`\n * projection (`captureContent` populates span `input` / `output`), plus\n * an optional `onError` for isolated exporter failures.\n */\n public constructor(private readonly options: CollectorOptions = {}) {}\n\n public use(exporter: ExporterContract): this {\n if (!this.exporters.has(exporter.name)) {\n this.exporters.set(exporter.name, exporter);\n }\n\n return this;\n }\n\n public toTrace(report: BaseReport, rootError?: unknown): Trace {\n return reportToTrace(report, rootError, this.options);\n }\n\n public async collect(report: BaseReport, rootError?: unknown): Promise<void> {\n const trace = this.toTrace(report, rootError);\n\n await this.dispatch(trace);\n }\n\n public async flush(): Promise<void> {\n await this.settleAll((exporter) => exporter.flush?.());\n }\n\n public async shutdown(): Promise<void> {\n await this.flush();\n\n await this.settleAll((exporter) => exporter.shutdown?.());\n\n this.exporters.clear();\n }\n\n /**\n * Fan one trace out to every exporter and, when an exporter advertises\n * the per-span hook, deliver every span in the finalized tree to it as\n * well. `exportSpan` is a post-completion per-span hook (not a live /\n * streaming feed — the trace is already finalized): we walk the tree in\n * pre-order with {@link walkSpans} so the exporter sees the root and\n * every descendant exactly once. Every invocation is isolated so a\n * throwing exporter can't abort the dispatch to its siblings or escape\n * into the originating run.\n */\n private async dispatch(trace: Trace): Promise<void> {\n await this.settleAll(async (exporter) => {\n await exporter.export(trace);\n\n if (exporter.exportSpan !== undefined) {\n for (const span of walkSpans(trace.root)) {\n await exporter.exportSpan(span);\n }\n }\n });\n }\n\n /**\n * Run `task` against every registered exporter and wait for all of\n * them to settle, swallowing individual rejections. `Promise.allSettled`\n * guarantees one failure neither rejects the batch nor blocks the\n * others — the contract's failure-isolation requirement.\n */\n private async settleAll(\n task: (exporter: ExporterContract) => void | Promise<void>,\n ): Promise<void> {\n const runs = [...this.exporters.entries()].map(([name, exporter]) =>\n Promise.resolve()\n .then(() => task(exporter))\n .catch((error: unknown) => this.reportExporterError(name, error)),\n );\n\n await Promise.allSettled(runs);\n }\n\n /**\n * Surface an isolated exporter failure. The originating run never sees it\n * (the isolation guarantee holds), but a silent failure is the wrong\n * default for a config error — e.g. a missing optional peer like\n * `langfuse` would otherwise drop every trace with no signal. The\n * supplied `onError` is called, or — by default — a `console.warn` is\n * emitted ONCE per exporter so the cause is visible without spamming.\n */\n private reportExporterError(name: string, error: unknown): void {\n if (this.options.onError) {\n try {\n this.options.onError(name, error);\n } catch {\n // Never let the error handler itself escape into the run.\n }\n return;\n }\n\n if (this.warnedExporters.has(name)) {\n return;\n }\n\n this.warnedExporters.add(name);\n const message = error instanceof Error ? error.message : String(error);\n console.warn(`[panoptic] exporter \"${name}\" failed and was isolated: ${message}`);\n }\n}\n\n/**\n * Create a Panoptic collector — the single integration point an app\n * wires into its agents/workflows (typically via the `onComplete`\n * report hook). Register exporters with `use`, then feed finalized root\n * reports to `collect`.\n *\n * @example\n * const collector = createCollector().use(otelExporter).use(langfuseExporter);\n * agent.on(\"onComplete\", ({ result }) => collector.collect(result.report));\n * // on shutdown:\n * await collector.shutdown();\n */\nexport function createCollector(options: CollectorOptions = {}): CollectorContract {\n return new Collector(options);\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;AA8CA,IAAM,YAAN,MAA6C;CAmBP;;;;;;CAbpC,AAAiB,4BAAY,IAAI,IAA8B;;;;;CAM/D,AAAiB,kCAAkB,IAAI,IAAY;;;;;;CAOnD,AAAO,YAAY,AAAiB,UAA4B,CAAC,GAAG;EAAhC;CAAiC;CAErE,AAAO,IAAI,UAAkC;EAC3C,IAAI,CAAC,KAAK,UAAU,IAAI,SAAS,IAAI,GACnC,KAAK,UAAU,IAAI,SAAS,MAAM,QAAQ;EAG5C,OAAO;CACT;CAEA,AAAO,QAAQ,QAAoB,WAA4B;EAC7D,OAAO,cAAc,QAAQ,WAAW,KAAK,OAAO;CACtD;CAEA,MAAa,QAAQ,QAAoB,WAAoC;EAC3E,MAAM,QAAQ,KAAK,QAAQ,QAAQ,SAAS;EAE5C,MAAM,KAAK,SAAS,KAAK;CAC3B;CAEA,MAAa,QAAuB;EAClC,MAAM,KAAK,WAAW,aAAa,SAAS,QAAQ,CAAC;CACvD;CAEA,MAAa,WAA0B;EACrC,MAAM,KAAK,MAAM;EAEjB,MAAM,KAAK,WAAW,aAAa,SAAS,WAAW,CAAC;EAExD,KAAK,UAAU,MAAM;CACvB;;;;;;;;;;;CAYA,MAAc,SAAS,OAA6B;EAClD,MAAM,KAAK,UAAU,OAAO,aAAa;GACvC,MAAM,SAAS,OAAO,KAAK;GAE3B,IAAI,SAAS,eAAe,QAC1B,KAAK,MAAM,QAAQ,UAAU,MAAM,IAAI,GACrC,MAAM,SAAS,WAAW,IAAI;EAGpC,CAAC;CACH;;;;;;;CAQA,MAAc,UACZ,MACe;EACf,MAAM,OAAO,CAAC,GAAG,KAAK,UAAU,QAAQ,CAAC,CAAC,CAAC,KAAK,CAAC,MAAM,cACrD,QAAQ,QAAQ,CAAC,CACd,WAAW,KAAK,QAAQ,CAAC,CAAC,CAC1B,OAAO,UAAmB,KAAK,oBAAoB,MAAM,KAAK,CAAC,CACpE;EAEA,MAAM,QAAQ,WAAW,IAAI;CAC/B;;;;;;;;;CAUA,AAAQ,oBAAoB,MAAc,OAAsB;EAC9D,IAAI,KAAK,QAAQ,SAAS;GACxB,IAAI;IACF,KAAK,QAAQ,QAAQ,MAAM,KAAK;GAClC,QAAQ,CAER;GACA;EACF;EAEA,IAAI,KAAK,gBAAgB,IAAI,IAAI,GAC/B;EAGF,KAAK,gBAAgB,IAAI,IAAI;EAC7B,MAAM,UAAU,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK;EACrE,QAAQ,KAAK,wBAAwB,KAAK,6BAA6B,SAAS;CAClF;AACF;;;;;;;;;;;;;AAcA,SAAgB,gBAAgB,UAA4B,CAAC,GAAsB;CACjF,OAAO,IAAI,UAAU,OAAO;AAC9B"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"extract-span-attributes.mjs","names":[],"sources":["../../../../../../../ai-panoptic/src/collector/extract-span-attributes.ts"],"sourcesContent":["import type { BaseReport } from \"@warlock.js/ai\";\n\n/**\n * Per-primitive `BaseReport` extension fields the collector surfaces as\n * span attributes. Declared as a structural superset (every field\n * optional) so a single reader can pull whatever the concrete report\n * carried without narrowing on `type` first — a `BaseReport` widened to\n * this shape exposes `undefined` for the fields its primitive doesn't\n * set, and the extractor simply skips those.\n *\n * Mirrors the public per-primitive report types in `@warlock.js/ai`\n * (`AgentReport`, `WorkflowReport`, `SupervisorReport`,\n * `OrchestratorReport`, `ToolCall`) — kept structural rather than a\n * union import so adding a new optional report field upstream is a\n * non-breaking read here.\n */\ntype ReportExtensions = {\n model?: { name?: string; provider?: string };\n trips?: unknown[];\n promptName?: string;\n promptVersion?: string;\n workflowName?: string;\n supervisorName?: string;\n signature?: string;\n terminatedBy?: string;\n iterations?: number;\n steps?: Record<string, unknown>;\n turns?: unknown[];\n turnIndex?: number;\n tripIndex?: number;\n recoveredFrom?: string;\n};\n\n/**\n * Build the free-form `TraceSpan.attributes` bag for one report node.\n *\n * The collector keeps the first-class span fields (identity, timing,\n * status, usage, error) on the span itself and routes everything\n * primitive-specific here — trip/step/iteration counts, the model\n * identity an agent ran against, the tool's originating trip index, a\n * supervisor's termination reason. Exporters forward this verbatim as\n * backend span attributes (OTel attributes, Langfuse metadata).\n *\n * Only populated keys are emitted; the function returns `undefined`\n * when the node carried no extra detail, so the optional\n * `TraceSpan.attributes` field stays absent rather than holding an\n * empty object (matches the contract's \"absent when empty\" note).\n *\n * Retry count is surfaced from the shared `BaseReport.attempts` for\n * every primitive so cost dashboards see the real call count.\n *\n * @example\n * const attributes = extractSpanAttributes(agentReport);\n * // { \"agent.trips\": 3, \"agent.model.name\": \"gpt-4o\", \"agent.model.provider\": \"openai\" }\n */\nexport function extractSpanAttributes(report: BaseReport): Record<string, unknown> | undefined {\n const extensions = report as BaseReport & ReportExtensions;\n const attributes: Record<string, unknown> = {};\n\n if (report.attempts !== undefined && report.attempts.length > 0) {\n attributes[\"retries\"] = report.attempts.length;\n }\n\n switch (report.type) {\n case \"agent\": {\n addAgentAttributes(attributes, extensions);\n break;\n }\n\n case \"workflow\": {\n addWorkflowAttributes(attributes, extensions);\n break;\n }\n\n case \"supervisor\":\n case \"team\": {\n // ai.team reuses the supervisor engine, so a team report carries the\n // same supervisorName / terminatedBy / iterations fields.\n addSupervisorAttributes(attributes, extensions);\n break;\n }\n\n case \"orchestrator\": {\n addOrchestratorAttributes(attributes, extensions);\n break;\n }\n\n case \"tool\": {\n addToolAttributes(attributes, extensions);\n break;\n }\n\n default: {\n break;\n }\n }\n\n if (Object.keys(attributes).length === 0) {\n return undefined;\n }\n\n return attributes;\n}\n\nfunction addAgentAttributes(attributes: Record<string, unknown>, extensions: ReportExtensions): void {\n if (Array.isArray(extensions.trips)) {\n attributes[\"agent.trips\"] = extensions.trips.length;\n }\n\n if (extensions.model?.name !== undefined) {\n attributes[\"agent.model.name\"] = extensions.model.name;\n }\n\n if (extensions.model?.provider !== undefined) {\n attributes[\"agent.model.provider\"] = extensions.model.provider;\n }\n\n // Prompt-version linkage (core `AgentReport.promptName` / `promptVersion`).\n // Present only when the agent ran against a *named* `ai.prompts` builder;\n // surfaced so the dashboard can group / filter runs by `name@version` and\n // attribute behavior shifts to a specific prompt revision.\n if (extensions.promptName !== undefined) {\n attributes[\"agent.promptName\"] = extensions.promptName;\n }\n\n if (extensions.promptVersion !== undefined) {\n attributes[\"agent.promptVersion\"] = extensions.promptVersion;\n }\n}\n\nfunction addWorkflowAttributes(attributes: Record<string, unknown>, extensions: ReportExtensions): void {\n if (extensions.workflowName !== undefined) {\n attributes[\"workflow.name\"] = extensions.workflowName;\n }\n\n if (extensions.signature !== undefined) {\n attributes[\"workflow.signature\"] = extensions.signature;\n }\n\n if (extensions.steps !== undefined) {\n attributes[\"workflow.steps\"] = Object.keys(extensions.steps).length;\n }\n}\n\nfunction addSupervisorAttributes(attributes: Record<string, unknown>, extensions: ReportExtensions): void {\n if (extensions.supervisorName !== undefined) {\n attributes[\"supervisor.name\"] = extensions.supervisorName;\n }\n\n if (extensions.terminatedBy !== undefined) {\n attributes[\"supervisor.terminatedBy\"] = extensions.terminatedBy;\n }\n\n if (extensions.iterations !== undefined) {\n attributes[\"supervisor.iterations\"] = extensions.iterations;\n }\n}\n\nfunction addOrchestratorAttributes(attributes: Record<string, unknown>, extensions: ReportExtensions): void {\n if (extensions.turnIndex !== undefined) {\n attributes[\"orchestrator.turnIndex\"] = extensions.turnIndex;\n }\n\n if (extensions.signature !== undefined) {\n attributes[\"orchestrator.signature\"] = extensions.signature;\n }\n\n if (Array.isArray(extensions.turns)) {\n attributes[\"orchestrator.turns\"] = extensions.turns.length;\n }\n}\n\nfunction addToolAttributes(attributes: Record<string, unknown>, extensions: ReportExtensions): void {\n if (extensions.tripIndex !== undefined) {\n attributes[\"tool.tripIndex\"] = extensions.tripIndex;\n }\n\n if (extensions.recoveredFrom !== undefined) {\n attributes[\"tool.recoveredFrom\"] = extensions.recoveredFrom;\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;AAuDA,SAAgB,sBAAsB,QAAyD;CAC7F,MAAM,aAAa;CACnB,MAAM,aAAsC,CAAC;CAE7C,IAAI,OAAO,aAAa,UAAa,OAAO,SAAS,SAAS,GAC5D,WAAW,aAAa,OAAO,SAAS;CAG1C,QAAQ,OAAO,MAAf;EACE,KAAK;GACH,mBAAmB,YAAY,UAAU;GACzC;EAGF,KAAK;GACH,sBAAsB,YAAY,UAAU;GAC5C;EAGF,KAAK;EACL,KAAK;GAGH,wBAAwB,YAAY,UAAU;GAC9C;EAGF,KAAK;GACH,0BAA0B,YAAY,UAAU;GAChD;EAGF,KAAK;GACH,kBAAkB,YAAY,UAAU;GACxC;EAGF,SACE;CAEJ;CAEA,IAAI,OAAO,KAAK,UAAU,
|
|
1
|
+
{"version":3,"file":"extract-span-attributes.mjs","names":[],"sources":["../../../../../../../ai-panoptic/src/collector/extract-span-attributes.ts"],"sourcesContent":["import type { BaseReport } from \"@warlock.js/ai\";\n\n/**\n * Per-primitive `BaseReport` extension fields the collector surfaces as\n * span attributes. Declared as a structural superset (every field\n * optional) so a single reader can pull whatever the concrete report\n * carried without narrowing on `type` first — a `BaseReport` widened to\n * this shape exposes `undefined` for the fields its primitive doesn't\n * set, and the extractor simply skips those.\n *\n * Mirrors the public per-primitive report types in `@warlock.js/ai`\n * (`AgentReport`, `WorkflowReport`, `SupervisorReport`,\n * `OrchestratorReport`, `ToolCall`) — kept structural rather than a\n * union import so adding a new optional report field upstream is a\n * non-breaking read here.\n */\ntype ReportExtensions = {\n model?: { name?: string; provider?: string };\n trips?: unknown[];\n promptName?: string;\n promptVersion?: string;\n workflowName?: string;\n supervisorName?: string;\n signature?: string;\n terminatedBy?: string;\n iterations?: number;\n steps?: Record<string, unknown>;\n turns?: unknown[];\n turnIndex?: number;\n tripIndex?: number;\n recoveredFrom?: string;\n};\n\n/**\n * Build the free-form `TraceSpan.attributes` bag for one report node.\n *\n * The collector keeps the first-class span fields (identity, timing,\n * status, usage, error) on the span itself and routes everything\n * primitive-specific here — trip/step/iteration counts, the model\n * identity an agent ran against, the tool's originating trip index, a\n * supervisor's termination reason. Exporters forward this verbatim as\n * backend span attributes (OTel attributes, Langfuse metadata).\n *\n * Only populated keys are emitted; the function returns `undefined`\n * when the node carried no extra detail, so the optional\n * `TraceSpan.attributes` field stays absent rather than holding an\n * empty object (matches the contract's \"absent when empty\" note).\n *\n * Retry count is surfaced from the shared `BaseReport.attempts` for\n * every primitive so cost dashboards see the real call count.\n *\n * @example\n * const attributes = extractSpanAttributes(agentReport);\n * // { \"agent.trips\": 3, \"agent.model.name\": \"gpt-4o\", \"agent.model.provider\": \"openai\" }\n */\nexport function extractSpanAttributes(report: BaseReport): Record<string, unknown> | undefined {\n const extensions = report as BaseReport & ReportExtensions;\n const attributes: Record<string, unknown> = {};\n\n if (report.attempts !== undefined && report.attempts.length > 0) {\n attributes[\"retries\"] = report.attempts.length;\n }\n\n switch (report.type) {\n case \"agent\": {\n addAgentAttributes(attributes, extensions);\n break;\n }\n\n case \"workflow\": {\n addWorkflowAttributes(attributes, extensions);\n break;\n }\n\n case \"supervisor\":\n case \"team\": {\n // ai.team reuses the supervisor engine, so a team report carries the\n // same supervisorName / terminatedBy / iterations fields.\n addSupervisorAttributes(attributes, extensions);\n break;\n }\n\n case \"orchestrator\": {\n addOrchestratorAttributes(attributes, extensions);\n break;\n }\n\n case \"tool\": {\n addToolAttributes(attributes, extensions);\n break;\n }\n\n default: {\n break;\n }\n }\n\n if (Object.keys(attributes).length === 0) {\n return undefined;\n }\n\n return attributes;\n}\n\nfunction addAgentAttributes(attributes: Record<string, unknown>, extensions: ReportExtensions): void {\n if (Array.isArray(extensions.trips)) {\n attributes[\"agent.trips\"] = extensions.trips.length;\n }\n\n if (extensions.model?.name !== undefined) {\n attributes[\"agent.model.name\"] = extensions.model.name;\n }\n\n if (extensions.model?.provider !== undefined) {\n attributes[\"agent.model.provider\"] = extensions.model.provider;\n }\n\n // Prompt-version linkage (core `AgentReport.promptName` / `promptVersion`).\n // Present only when the agent ran against a *named* `ai.prompts` builder;\n // surfaced so the dashboard can group / filter runs by `name@version` and\n // attribute behavior shifts to a specific prompt revision.\n if (extensions.promptName !== undefined) {\n attributes[\"agent.promptName\"] = extensions.promptName;\n }\n\n if (extensions.promptVersion !== undefined) {\n attributes[\"agent.promptVersion\"] = extensions.promptVersion;\n }\n}\n\nfunction addWorkflowAttributes(attributes: Record<string, unknown>, extensions: ReportExtensions): void {\n if (extensions.workflowName !== undefined) {\n attributes[\"workflow.name\"] = extensions.workflowName;\n }\n\n if (extensions.signature !== undefined) {\n attributes[\"workflow.signature\"] = extensions.signature;\n }\n\n if (extensions.steps !== undefined) {\n attributes[\"workflow.steps\"] = Object.keys(extensions.steps).length;\n }\n}\n\nfunction addSupervisorAttributes(attributes: Record<string, unknown>, extensions: ReportExtensions): void {\n if (extensions.supervisorName !== undefined) {\n attributes[\"supervisor.name\"] = extensions.supervisorName;\n }\n\n if (extensions.terminatedBy !== undefined) {\n attributes[\"supervisor.terminatedBy\"] = extensions.terminatedBy;\n }\n\n if (extensions.iterations !== undefined) {\n attributes[\"supervisor.iterations\"] = extensions.iterations;\n }\n}\n\nfunction addOrchestratorAttributes(attributes: Record<string, unknown>, extensions: ReportExtensions): void {\n if (extensions.turnIndex !== undefined) {\n attributes[\"orchestrator.turnIndex\"] = extensions.turnIndex;\n }\n\n if (extensions.signature !== undefined) {\n attributes[\"orchestrator.signature\"] = extensions.signature;\n }\n\n if (Array.isArray(extensions.turns)) {\n attributes[\"orchestrator.turns\"] = extensions.turns.length;\n }\n}\n\nfunction addToolAttributes(attributes: Record<string, unknown>, extensions: ReportExtensions): void {\n if (extensions.tripIndex !== undefined) {\n attributes[\"tool.tripIndex\"] = extensions.tripIndex;\n }\n\n if (extensions.recoveredFrom !== undefined) {\n attributes[\"tool.recoveredFrom\"] = extensions.recoveredFrom;\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;AAuDA,SAAgB,sBAAsB,QAAyD;CAC7F,MAAM,aAAa;CACnB,MAAM,aAAsC,CAAC;CAE7C,IAAI,OAAO,aAAa,UAAa,OAAO,SAAS,SAAS,GAC5D,WAAW,aAAa,OAAO,SAAS;CAG1C,QAAQ,OAAO,MAAf;EACE,KAAK;GACH,mBAAmB,YAAY,UAAU;GACzC;EAGF,KAAK;GACH,sBAAsB,YAAY,UAAU;GAC5C;EAGF,KAAK;EACL,KAAK;GAGH,wBAAwB,YAAY,UAAU;GAC9C;EAGF,KAAK;GACH,0BAA0B,YAAY,UAAU;GAChD;EAGF,KAAK;GACH,kBAAkB,YAAY,UAAU;GACxC;EAGF,SACE;CAEJ;CAEA,IAAI,OAAO,KAAK,UAAU,CAAC,CAAC,WAAW,GACrC;CAGF,OAAO;AACT;AAEA,SAAS,mBAAmB,YAAqC,YAAoC;CACnG,IAAI,MAAM,QAAQ,WAAW,KAAK,GAChC,WAAW,iBAAiB,WAAW,MAAM;CAG/C,IAAI,WAAW,OAAO,SAAS,QAC7B,WAAW,sBAAsB,WAAW,MAAM;CAGpD,IAAI,WAAW,OAAO,aAAa,QACjC,WAAW,0BAA0B,WAAW,MAAM;CAOxD,IAAI,WAAW,eAAe,QAC5B,WAAW,sBAAsB,WAAW;CAG9C,IAAI,WAAW,kBAAkB,QAC/B,WAAW,yBAAyB,WAAW;AAEnD;AAEA,SAAS,sBAAsB,YAAqC,YAAoC;CACtG,IAAI,WAAW,iBAAiB,QAC9B,WAAW,mBAAmB,WAAW;CAG3C,IAAI,WAAW,cAAc,QAC3B,WAAW,wBAAwB,WAAW;CAGhD,IAAI,WAAW,UAAU,QACvB,WAAW,oBAAoB,OAAO,KAAK,WAAW,KAAK,CAAC,CAAC;AAEjE;AAEA,SAAS,wBAAwB,YAAqC,YAAoC;CACxG,IAAI,WAAW,mBAAmB,QAChC,WAAW,qBAAqB,WAAW;CAG7C,IAAI,WAAW,iBAAiB,QAC9B,WAAW,6BAA6B,WAAW;CAGrD,IAAI,WAAW,eAAe,QAC5B,WAAW,2BAA2B,WAAW;AAErD;AAEA,SAAS,0BAA0B,YAAqC,YAAoC;CAC1G,IAAI,WAAW,cAAc,QAC3B,WAAW,4BAA4B,WAAW;CAGpD,IAAI,WAAW,cAAc,QAC3B,WAAW,4BAA4B,WAAW;CAGpD,IAAI,MAAM,QAAQ,WAAW,KAAK,GAChC,WAAW,wBAAwB,WAAW,MAAM;AAExD;AAEA,SAAS,kBAAkB,YAAqC,YAAoC;CAClG,IAAI,WAAW,cAAc,QAC3B,WAAW,oBAAoB,WAAW;CAG5C,IAAI,WAAW,kBAAkB,QAC/B,WAAW,wBAAwB,WAAW;AAElD"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"report-to-span.mjs","names":[],"sources":["../../../../../../../ai-panoptic/src/collector/report-to-span.ts"],"sourcesContent":["import type { BaseReport } from \"@warlock.js/ai\";\nimport type { TraceSpan } from \"../contracts/trace.type\";\nimport type { ContentCaptureOptions, ContentRedactor } from \"./content-capture.type\";\nimport { extractSpanAttributes } from \"./extract-span-attributes\";\nimport { normalizeError } from \"./normalize-error\";\n\n/**\n * Project one {@link BaseReport} node — and its entire subtree — into a\n * {@link TraceSpan}. Pure and recursive: identity, timing, status, and\n * the rolled-up `usage` map across 1:1 from the report; lineage maps\n * `runId → spanId`, `parentRunId → parentSpanId`, `rootRunId → traceId`;\n * children recurse in invocation order so the span tree mirrors the\n * report tree exactly.\n *\n * The error is normalized to the JSON-safe {@link\n * import(\"../contracts/trace.type\").TraceSpanError} shape only when the\n * node carried one (failed / cancelled). Primitive-specific detail that\n * has no first-class span field (trip / step / iteration counts, model\n * identity, tool trip index) is routed into the optional `attributes`\n * bag via {@link extractSpanAttributes}.\n *\n * When {@link ContentCaptureOptions.captureContent} is set, the raw\n * prompt/response (agents) and args/result (tools) are additionally\n * copied onto `span.input` / `span.output` — off by default because\n * payloads are large and often sensitive. A {@link ContentRedactor} can\n * mask each value first.\n *\n * No external lookup is needed — a `BaseReport` already carries\n * everything a span requires, so a collector can flatten a tree without\n * consulting any other source.\n *\n * @example\n * const root = reportToSpan(result.report);\n * console.log(root.spanId, root.traceId, root.children.length);\n */\nexport function reportToSpan(report: BaseReport, options?: ContentCaptureOptions): TraceSpan {\n const span: TraceSpan = {\n spanId: report.runId,\n traceId: report.rootRunId,\n name: report.name,\n type: report.type,\n status: report.status,\n startedAt: report.startedAt,\n endedAt: report.endedAt,\n duration: report.duration,\n usage: report.usage,\n children: report.children.map((child) => reportToSpan(child, options)),\n };\n\n if (report.parentRunId !== undefined) {\n span.parentSpanId = report.parentRunId;\n }\n\n if (report.sessionId !== undefined) {\n span.sessionId = report.sessionId;\n }\n\n if (report.version !== undefined) {\n span.version = report.version;\n }\n\n const error = normalizeError((report as { error?: unknown }).error);\n if (error !== undefined) {\n span.error = error;\n }\n\n const attributes = extractSpanAttributes(report);\n if (attributes !== undefined) {\n span.attributes = attributes;\n }\n\n if (options?.captureContent) {\n captureContent(span, report, options);\n }\n\n return span;\n}\n\n/** Per-primitive content fields read off a widened report node. */\ntype ContentReport = {\n input?: unknown;\n output?: unknown;\n systemPrompt?: string;\n trips?: Array<{ input?: unknown; output?: unknown }>;\n /**\n * The full assembled conversation an agent run captured when its\n * `captureMessages` option was set (core `AgentReport.messages`).\n * Present only on opted-in agent runs; absent otherwise.\n */\n messages?: unknown;\n};\n\n/**\n * Copy the node's raw content onto `span.input` / `span.output`.\n *\n * - Tools carry the call arguments + return value directly on the report\n * (`ToolCall.input` / `ToolCall.output`).\n * - Agents carry a `trips[]` history plus the resolved `systemPrompt`. The\n * input is emitted as a `[system, user]` chat array — so backends like\n * Langfuse render the full prompt as sent — or the bare user string when\n * there's no system prompt. The output is the last NON-EMPTY trip\n * `output` (the final response text); a failed / max-trips run can end on\n * a trip whose `output` is `\"\"` or tool-call-only, so we scan back for the\n * last one that carried text. Intermediate trips store a `\"[tool results]\"`\n * placeholder upstream.\n * - When {@link ContentCaptureOptions.fullHistory} is on AND the agent run\n * opted into `captureMessages` (so the report carries a `messages`\n * array), the *whole* `CapturedMessage[]` is emitted as `span.input`\n * instead of the `[system, user]` first-trip array — every role, every\n * trip. The output stays the last non-empty trip output. When `messages`\n * is absent the branch falls back to today's first-trip logic, so a run\n * that didn't opt in degrades gracefully.\n *\n * Each value is passed through the optional {@link ContentRedactor};\n * a redactor returning `undefined` drops the field. Under `fullHistory`\n * the redactor receives the full array as a single value.\n */\nfunction captureContent(\n span: TraceSpan,\n report: BaseReport,\n options: ContentCaptureOptions,\n): void {\n const node = report as BaseReport & ContentReport;\n const redact = options.redactContent;\n\n let input: unknown;\n let output: unknown;\n\n if (report.type === \"tool\") {\n input = node.input;\n output = node.output;\n } else if (\n options.fullHistory &&\n Array.isArray(node.messages) &&\n node.messages.length > 0\n ) {\n // Full-history capture: the entire assembled conversation as a single\n // input value. Output remains the agent's final response text.\n input = node.messages;\n output = Array.isArray(node.trips) ? lastNonEmptyOutput(node.trips) : undefined;\n } else if (Array.isArray(node.trips) && node.trips.length > 0) {\n const userInput = node.trips[0]?.input;\n // Emit a [system, user] chat array when the agent carried a system\n // prompt, so backends (Langfuse) render the full prompt as sent;\n // otherwise keep the bare user string.\n input =\n typeof node.systemPrompt === \"string\" && node.systemPrompt.length > 0\n ? [\n { role: \"system\", content: node.systemPrompt },\n { role: \"user\", content: userInput },\n ]\n : userInput;\n output = lastNonEmptyOutput(node.trips);\n }\n\n if (input !== undefined) {\n const value = redact ? redact(input, { name: span.name, type: span.type, field: \"input\" }) : input;\n if (value !== undefined) {\n span.input = value;\n }\n }\n\n if (output !== undefined) {\n const value = redact ? redact(output, { name: span.name, type: span.type, field: \"output\" }) : output;\n if (value !== undefined) {\n span.output = value;\n }\n }\n}\n\n/**\n * The last trip output that actually carries text — the agent's final\n * response on the happy path. A failed or max-trips run can end on a trip\n * whose `output` is `\"\"` or tool-call-only, so we scan backwards for the\n * last trip that produced text rather than blindly taking the final trip\n * (which would surface an empty string). Returns `undefined` when no trip\n * produced any output.\n */\nfunction lastNonEmptyOutput(trips: Array<{ output?: unknown }>): unknown {\n for (let i = trips.length - 1; i >= 0; i -= 1) {\n const out = trips[i]?.output;\n const hasText = typeof out === \"string\" ? out.length > 0 : out !== undefined;\n\n if (hasText) {\n return out;\n }\n }\n\n return undefined;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAmCA,SAAgB,aAAa,QAAoB,SAA4C;CAC3F,MAAM,OAAkB;EACtB,QAAQ,OAAO;EACf,SAAS,OAAO;EAChB,MAAM,OAAO;EACb,MAAM,OAAO;EACb,QAAQ,OAAO;EACf,WAAW,OAAO;EAClB,SAAS,OAAO;EAChB,UAAU,OAAO;EACjB,OAAO,OAAO;EACd,UAAU,OAAO,SAAS,KAAK,UAAU,aAAa,OAAO,OAAO,CAAC;CACvE;CAEA,IAAI,OAAO,gBAAgB,QACzB,KAAK,eAAe,OAAO;CAG7B,IAAI,OAAO,cAAc,QACvB,KAAK,YAAY,OAAO;CAG1B,IAAI,OAAO,YAAY,QACrB,KAAK,UAAU,OAAO;CAGxB,MAAM,QAAQ,eAAgB,OAA+B,KAAK;CAClE,IAAI,UAAU,QACZ,KAAK,QAAQ;CAGf,MAAM,aAAa,sBAAsB,MAAM;CAC/C,IAAI,eAAe,QACjB,KAAK,aAAa;CAGpB,IAAI,SAAS,gBACX,eAAe,MAAM,QAAQ,OAAO;CAGtC,OAAO;AACT;;;;;;;;;;;;;;;;;;;;;;;;;;AAyCA,SAAS,eACP,MACA,QACA,SACM;CACN,MAAM,OAAO;CACb,MAAM,SAAS,QAAQ;CAEvB,IAAI;CACJ,IAAI;CAEJ,IAAI,OAAO,SAAS,QAAQ;EAC1B,QAAQ,KAAK;EACb,SAAS,KAAK;CAChB,OAAO,IACL,QAAQ,eACR,MAAM,QAAQ,KAAK,QAAQ,KAC3B,KAAK,SAAS,SAAS,GACvB;EAGA,QAAQ,KAAK;EACb,SAAS,MAAM,QAAQ,KAAK,KAAK,IAAI,mBAAmB,KAAK,KAAK,IAAI;CACxE,OAAO,IAAI,MAAM,QAAQ,KAAK,KAAK,KAAK,KAAK,MAAM,SAAS,GAAG;EAC7D,MAAM,YAAY,KAAK,MAAM,
|
|
1
|
+
{"version":3,"file":"report-to-span.mjs","names":[],"sources":["../../../../../../../ai-panoptic/src/collector/report-to-span.ts"],"sourcesContent":["import type { BaseReport } from \"@warlock.js/ai\";\nimport type { TraceSpan } from \"../contracts/trace.type\";\nimport type { ContentCaptureOptions, ContentRedactor } from \"./content-capture.type\";\nimport { extractSpanAttributes } from \"./extract-span-attributes\";\nimport { normalizeError } from \"./normalize-error\";\n\n/**\n * Project one {@link BaseReport} node — and its entire subtree — into a\n * {@link TraceSpan}. Pure and recursive: identity, timing, status, and\n * the rolled-up `usage` map across 1:1 from the report; lineage maps\n * `runId → spanId`, `parentRunId → parentSpanId`, `rootRunId → traceId`;\n * children recurse in invocation order so the span tree mirrors the\n * report tree exactly.\n *\n * The error is normalized to the JSON-safe {@link\n * import(\"../contracts/trace.type\").TraceSpanError} shape only when the\n * node carried one (failed / cancelled). Primitive-specific detail that\n * has no first-class span field (trip / step / iteration counts, model\n * identity, tool trip index) is routed into the optional `attributes`\n * bag via {@link extractSpanAttributes}.\n *\n * When {@link ContentCaptureOptions.captureContent} is set, the raw\n * prompt/response (agents) and args/result (tools) are additionally\n * copied onto `span.input` / `span.output` — off by default because\n * payloads are large and often sensitive. A {@link ContentRedactor} can\n * mask each value first.\n *\n * No external lookup is needed — a `BaseReport` already carries\n * everything a span requires, so a collector can flatten a tree without\n * consulting any other source.\n *\n * @example\n * const root = reportToSpan(result.report);\n * console.log(root.spanId, root.traceId, root.children.length);\n */\nexport function reportToSpan(report: BaseReport, options?: ContentCaptureOptions): TraceSpan {\n const span: TraceSpan = {\n spanId: report.runId,\n traceId: report.rootRunId,\n name: report.name,\n type: report.type,\n status: report.status,\n startedAt: report.startedAt,\n endedAt: report.endedAt,\n duration: report.duration,\n usage: report.usage,\n children: report.children.map((child) => reportToSpan(child, options)),\n };\n\n if (report.parentRunId !== undefined) {\n span.parentSpanId = report.parentRunId;\n }\n\n if (report.sessionId !== undefined) {\n span.sessionId = report.sessionId;\n }\n\n if (report.version !== undefined) {\n span.version = report.version;\n }\n\n const error = normalizeError((report as { error?: unknown }).error);\n if (error !== undefined) {\n span.error = error;\n }\n\n const attributes = extractSpanAttributes(report);\n if (attributes !== undefined) {\n span.attributes = attributes;\n }\n\n if (options?.captureContent) {\n captureContent(span, report, options);\n }\n\n return span;\n}\n\n/** Per-primitive content fields read off a widened report node. */\ntype ContentReport = {\n input?: unknown;\n output?: unknown;\n systemPrompt?: string;\n trips?: Array<{ input?: unknown; output?: unknown }>;\n /**\n * The full assembled conversation an agent run captured when its\n * `captureMessages` option was set (core `AgentReport.messages`).\n * Present only on opted-in agent runs; absent otherwise.\n */\n messages?: unknown;\n};\n\n/**\n * Copy the node's raw content onto `span.input` / `span.output`.\n *\n * - Tools carry the call arguments + return value directly on the report\n * (`ToolCall.input` / `ToolCall.output`).\n * - Agents carry a `trips[]` history plus the resolved `systemPrompt`. The\n * input is emitted as a `[system, user]` chat array — so backends like\n * Langfuse render the full prompt as sent — or the bare user string when\n * there's no system prompt. The output is the last NON-EMPTY trip\n * `output` (the final response text); a failed / max-trips run can end on\n * a trip whose `output` is `\"\"` or tool-call-only, so we scan back for the\n * last one that carried text. Intermediate trips store a `\"[tool results]\"`\n * placeholder upstream.\n * - When {@link ContentCaptureOptions.fullHistory} is on AND the agent run\n * opted into `captureMessages` (so the report carries a `messages`\n * array), the *whole* `CapturedMessage[]` is emitted as `span.input`\n * instead of the `[system, user]` first-trip array — every role, every\n * trip. The output stays the last non-empty trip output. When `messages`\n * is absent the branch falls back to today's first-trip logic, so a run\n * that didn't opt in degrades gracefully.\n *\n * Each value is passed through the optional {@link ContentRedactor};\n * a redactor returning `undefined` drops the field. Under `fullHistory`\n * the redactor receives the full array as a single value.\n */\nfunction captureContent(\n span: TraceSpan,\n report: BaseReport,\n options: ContentCaptureOptions,\n): void {\n const node = report as BaseReport & ContentReport;\n const redact = options.redactContent;\n\n let input: unknown;\n let output: unknown;\n\n if (report.type === \"tool\") {\n input = node.input;\n output = node.output;\n } else if (\n options.fullHistory &&\n Array.isArray(node.messages) &&\n node.messages.length > 0\n ) {\n // Full-history capture: the entire assembled conversation as a single\n // input value. Output remains the agent's final response text.\n input = node.messages;\n output = Array.isArray(node.trips) ? lastNonEmptyOutput(node.trips) : undefined;\n } else if (Array.isArray(node.trips) && node.trips.length > 0) {\n const userInput = node.trips[0]?.input;\n // Emit a [system, user] chat array when the agent carried a system\n // prompt, so backends (Langfuse) render the full prompt as sent;\n // otherwise keep the bare user string.\n input =\n typeof node.systemPrompt === \"string\" && node.systemPrompt.length > 0\n ? [\n { role: \"system\", content: node.systemPrompt },\n { role: \"user\", content: userInput },\n ]\n : userInput;\n output = lastNonEmptyOutput(node.trips);\n }\n\n if (input !== undefined) {\n const value = redact ? redact(input, { name: span.name, type: span.type, field: \"input\" }) : input;\n if (value !== undefined) {\n span.input = value;\n }\n }\n\n if (output !== undefined) {\n const value = redact ? redact(output, { name: span.name, type: span.type, field: \"output\" }) : output;\n if (value !== undefined) {\n span.output = value;\n }\n }\n}\n\n/**\n * The last trip output that actually carries text — the agent's final\n * response on the happy path. A failed or max-trips run can end on a trip\n * whose `output` is `\"\"` or tool-call-only, so we scan backwards for the\n * last trip that produced text rather than blindly taking the final trip\n * (which would surface an empty string). Returns `undefined` when no trip\n * produced any output.\n */\nfunction lastNonEmptyOutput(trips: Array<{ output?: unknown }>): unknown {\n for (let i = trips.length - 1; i >= 0; i -= 1) {\n const out = trips[i]?.output;\n const hasText = typeof out === \"string\" ? out.length > 0 : out !== undefined;\n\n if (hasText) {\n return out;\n }\n }\n\n return undefined;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAmCA,SAAgB,aAAa,QAAoB,SAA4C;CAC3F,MAAM,OAAkB;EACtB,QAAQ,OAAO;EACf,SAAS,OAAO;EAChB,MAAM,OAAO;EACb,MAAM,OAAO;EACb,QAAQ,OAAO;EACf,WAAW,OAAO;EAClB,SAAS,OAAO;EAChB,UAAU,OAAO;EACjB,OAAO,OAAO;EACd,UAAU,OAAO,SAAS,KAAK,UAAU,aAAa,OAAO,OAAO,CAAC;CACvE;CAEA,IAAI,OAAO,gBAAgB,QACzB,KAAK,eAAe,OAAO;CAG7B,IAAI,OAAO,cAAc,QACvB,KAAK,YAAY,OAAO;CAG1B,IAAI,OAAO,YAAY,QACrB,KAAK,UAAU,OAAO;CAGxB,MAAM,QAAQ,eAAgB,OAA+B,KAAK;CAClE,IAAI,UAAU,QACZ,KAAK,QAAQ;CAGf,MAAM,aAAa,sBAAsB,MAAM;CAC/C,IAAI,eAAe,QACjB,KAAK,aAAa;CAGpB,IAAI,SAAS,gBACX,eAAe,MAAM,QAAQ,OAAO;CAGtC,OAAO;AACT;;;;;;;;;;;;;;;;;;;;;;;;;;AAyCA,SAAS,eACP,MACA,QACA,SACM;CACN,MAAM,OAAO;CACb,MAAM,SAAS,QAAQ;CAEvB,IAAI;CACJ,IAAI;CAEJ,IAAI,OAAO,SAAS,QAAQ;EAC1B,QAAQ,KAAK;EACb,SAAS,KAAK;CAChB,OAAO,IACL,QAAQ,eACR,MAAM,QAAQ,KAAK,QAAQ,KAC3B,KAAK,SAAS,SAAS,GACvB;EAGA,QAAQ,KAAK;EACb,SAAS,MAAM,QAAQ,KAAK,KAAK,IAAI,mBAAmB,KAAK,KAAK,IAAI;CACxE,OAAO,IAAI,MAAM,QAAQ,KAAK,KAAK,KAAK,KAAK,MAAM,SAAS,GAAG;EAC7D,MAAM,YAAY,KAAK,MAAM,EAAE,EAAE;EAIjC,QACE,OAAO,KAAK,iBAAiB,YAAY,KAAK,aAAa,SAAS,IAChE,CACE;GAAE,MAAM;GAAU,SAAS,KAAK;EAAa,GAC7C;GAAE,MAAM;GAAQ,SAAS;EAAU,CACrC,IACA;EACN,SAAS,mBAAmB,KAAK,KAAK;CACxC;CAEA,IAAI,UAAU,QAAW;EACvB,MAAM,QAAQ,SAAS,OAAO,OAAO;GAAE,MAAM,KAAK;GAAM,MAAM,KAAK;GAAM,OAAO;EAAQ,CAAC,IAAI;EAC7F,IAAI,UAAU,QACZ,KAAK,QAAQ;CAEjB;CAEA,IAAI,WAAW,QAAW;EACxB,MAAM,QAAQ,SAAS,OAAO,QAAQ;GAAE,MAAM,KAAK;GAAM,MAAM,KAAK;GAAM,OAAO;EAAS,CAAC,IAAI;EAC/F,IAAI,UAAU,QACZ,KAAK,SAAS;CAElB;AACF;;;;;;;;;AAUA,SAAS,mBAAmB,OAA6C;CACvE,KAAK,IAAI,IAAI,MAAM,SAAS,GAAG,KAAK,GAAG,KAAK,GAAG;EAC7C,MAAM,MAAM,MAAM,EAAE,EAAE;EAGtB,IAFgB,OAAO,QAAQ,WAAW,IAAI,SAAS,IAAI,QAAQ,QAGjE,OAAO;CAEX;AAGF"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"apply-panoptic-config.mjs","names":[],"sources":["../../../../../../../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,
|
|
1
|
+
{"version":3,"file":"apply-panoptic-config.mjs","names":[],"sources":["../../../../../../../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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"dashboard.mjs","names":[],"sources":["../../../../../../../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,
|
|
1
|
+
{"version":3,"file":"dashboard.mjs","names":[],"sources":["../../../../../../../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 +1 @@
|
|
|
1
|
-
{"version":3,"file":"parse-query.mjs","names":[],"sources":["../../../../../../../ai-panoptic/src/dashboard/parse-query.ts"],"sourcesContent":["import type { ReportStatus } from \"@warlock.js/ai\";\nimport type { TraceQuery } from \"../store/trace-query.type\";\n\n/**\n * The set of terminal statuses a stored trace can carry. Used to keep\n * `parseQuery` from forwarding arbitrary `?status=` junk into the store.\n */\nconst KNOWN_STATUSES: readonly ReportStatus[] = [\n \"completed\",\n \"failed\",\n \"cancelled\",\n \"max-iterations\",\n \"awaiting-input\",\n \"awaiting-approval\",\n] as const;\n\n/**\n * Map a request's query string onto a {@link TraceQuery} the trace store\n * understands. Every field is optional — an absent param is \"don't care\",\n * so an empty query string yields an empty filter that matches every\n * stored trace.\n *\n * - `traceId` / `sessionId` — passed through verbatim (first value wins).\n * - `status` — **repeatable**: `?status=failed&status=cancelled` becomes\n * `[\"failed\", \"cancelled\"]`; a single value stays a scalar. Unknown\n * status tokens are dropped so a typo never silently matches nothing in\n * a confusing way (it simply isn't filtered on).\n * - `startedAfter` / `startedBefore` — forwarded as ISO strings; the\n * store accepts either a string or `Date`, and `matchTrace` does the\n * inclusive bound comparison.\n *\n * Unknown params are ignored. Pure — takes a `URLSearchParams`, returns a\n * plain object — so it's trivially testable without a live server.\n *\n * @example\n * parseQuery(new URLSearchParams(\"status=failed&status=cancelled&sessionId=s1\"));\n * // → { status: [\"failed\", \"cancelled\"], sessionId: \"s1\" }\n */\nexport function parseQuery(params: URLSearchParams): TraceQuery {\n const query: TraceQuery = {};\n\n const traceId = params.get(\"traceId\");\n if (traceId !== null && traceId.length > 0) {\n query.traceId = traceId;\n }\n\n const sessionId = params.get(\"sessionId\");\n if (sessionId !== null && sessionId.length > 0) {\n query.sessionId = sessionId;\n }\n\n const statuses = params\n .getAll(\"status\")\n .filter((value): value is ReportStatus => (KNOWN_STATUSES as readonly string[]).includes(value));\n if (statuses.length === 1) {\n query.status = statuses[0];\n } else if (statuses.length > 1) {\n query.status = statuses;\n }\n\n const startedAfter = params.get(\"startedAfter\");\n if (startedAfter !== null && startedAfter.length > 0) {\n query.startedAfter = startedAfter;\n }\n\n const startedBefore = params.get(\"startedBefore\");\n if (startedBefore !== null && startedBefore.length > 0) {\n query.startedBefore = startedBefore;\n }\n\n return query;\n}\n"],"mappings":";;;;;AAOA,MAAM,iBAA0C;CAC9C;CACA;CACA;CACA;CACA;CACA;AACF;;;;;;;;;;;;;;;;;;;;;;;AAwBA,SAAgB,WAAW,QAAqC;CAC9D,MAAM,QAAoB,CAAC;CAE3B,MAAM,UAAU,OAAO,IAAI,SAAS;CACpC,IAAI,YAAY,QAAQ,QAAQ,SAAS,GACvC,MAAM,UAAU;CAGlB,MAAM,YAAY,OAAO,IAAI,WAAW;CACxC,IAAI,cAAc,QAAQ,UAAU,SAAS,GAC3C,MAAM,YAAY;CAGpB,MAAM,WAAW,OACd,OAAO,QAAQ,
|
|
1
|
+
{"version":3,"file":"parse-query.mjs","names":[],"sources":["../../../../../../../ai-panoptic/src/dashboard/parse-query.ts"],"sourcesContent":["import type { ReportStatus } from \"@warlock.js/ai\";\nimport type { TraceQuery } from \"../store/trace-query.type\";\n\n/**\n * The set of terminal statuses a stored trace can carry. Used to keep\n * `parseQuery` from forwarding arbitrary `?status=` junk into the store.\n */\nconst KNOWN_STATUSES: readonly ReportStatus[] = [\n \"completed\",\n \"failed\",\n \"cancelled\",\n \"max-iterations\",\n \"awaiting-input\",\n \"awaiting-approval\",\n] as const;\n\n/**\n * Map a request's query string onto a {@link TraceQuery} the trace store\n * understands. Every field is optional — an absent param is \"don't care\",\n * so an empty query string yields an empty filter that matches every\n * stored trace.\n *\n * - `traceId` / `sessionId` — passed through verbatim (first value wins).\n * - `status` — **repeatable**: `?status=failed&status=cancelled` becomes\n * `[\"failed\", \"cancelled\"]`; a single value stays a scalar. Unknown\n * status tokens are dropped so a typo never silently matches nothing in\n * a confusing way (it simply isn't filtered on).\n * - `startedAfter` / `startedBefore` — forwarded as ISO strings; the\n * store accepts either a string or `Date`, and `matchTrace` does the\n * inclusive bound comparison.\n *\n * Unknown params are ignored. Pure — takes a `URLSearchParams`, returns a\n * plain object — so it's trivially testable without a live server.\n *\n * @example\n * parseQuery(new URLSearchParams(\"status=failed&status=cancelled&sessionId=s1\"));\n * // → { status: [\"failed\", \"cancelled\"], sessionId: \"s1\" }\n */\nexport function parseQuery(params: URLSearchParams): TraceQuery {\n const query: TraceQuery = {};\n\n const traceId = params.get(\"traceId\");\n if (traceId !== null && traceId.length > 0) {\n query.traceId = traceId;\n }\n\n const sessionId = params.get(\"sessionId\");\n if (sessionId !== null && sessionId.length > 0) {\n query.sessionId = sessionId;\n }\n\n const statuses = params\n .getAll(\"status\")\n .filter((value): value is ReportStatus => (KNOWN_STATUSES as readonly string[]).includes(value));\n if (statuses.length === 1) {\n query.status = statuses[0];\n } else if (statuses.length > 1) {\n query.status = statuses;\n }\n\n const startedAfter = params.get(\"startedAfter\");\n if (startedAfter !== null && startedAfter.length > 0) {\n query.startedAfter = startedAfter;\n }\n\n const startedBefore = params.get(\"startedBefore\");\n if (startedBefore !== null && startedBefore.length > 0) {\n query.startedBefore = startedBefore;\n }\n\n return query;\n}\n"],"mappings":";;;;;AAOA,MAAM,iBAA0C;CAC9C;CACA;CACA;CACA;CACA;CACA;AACF;;;;;;;;;;;;;;;;;;;;;;;AAwBA,SAAgB,WAAW,QAAqC;CAC9D,MAAM,QAAoB,CAAC;CAE3B,MAAM,UAAU,OAAO,IAAI,SAAS;CACpC,IAAI,YAAY,QAAQ,QAAQ,SAAS,GACvC,MAAM,UAAU;CAGlB,MAAM,YAAY,OAAO,IAAI,WAAW;CACxC,IAAI,cAAc,QAAQ,UAAU,SAAS,GAC3C,MAAM,YAAY;CAGpB,MAAM,WAAW,OACd,OAAO,QAAQ,CAAC,CAChB,QAAQ,UAAkC,eAAqC,SAAS,KAAK,CAAC;CACjG,IAAI,SAAS,WAAW,GACtB,MAAM,SAAS,SAAS;MACnB,IAAI,SAAS,SAAS,GAC3B,MAAM,SAAS;CAGjB,MAAM,eAAe,OAAO,IAAI,cAAc;CAC9C,IAAI,iBAAiB,QAAQ,aAAa,SAAS,GACjD,MAAM,eAAe;CAGvB,MAAM,gBAAgB,OAAO,IAAI,eAAe;CAChD,IAAI,kBAAkB,QAAQ,cAAc,SAAS,GACnD,MAAM,gBAAgB;CAGxB,OAAO;AACT"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"serve.mjs","names":[],"sources":["../../../../../../../ai-panoptic/src/dashboard/serve.ts"],"sourcesContent":["import { timingSafeEqual } from \"node:crypto\";\nimport 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/**\n * Constant-time string equality (S4) — guards against a timing\n * side-channel that could otherwise let a network-adjacent attacker\n * recover the token byte-by-byte via repeated timed guesses. Plain `===`\n * short-circuits on the first differing byte, so response latency leaks\n * how many leading bytes matched; `timingSafeEqual` does not. Lengths are\n * compared first (a length mismatch is not secret and `timingSafeEqual`\n * requires equal-length buffers anyway).\n */\nfunction constantTimeEqual(a: string, b: string): boolean {\n const bufA = Buffer.from(a);\n const bufB = Buffer.from(b);\n\n if (bufA.length !== bufB.length) return false;\n\n return timingSafeEqual(bufA, bufB);\n}\n\n/**\n * Bearer-token check (S4): `Authorization: Bearer <token>` header, or\n * (only when `allowQueryToken`) a `?token=` query param.\n *\n * The query-string form only exists for the one request that structurally\n * cannot carry a custom header — the initial browser navigation that\n * loads the HTML shell (a typed/clicked/bookmarked URL). Every other\n * request the served page makes is same-origin `fetch()`, which can and\n * does set `Authorization` (see `ui.html.ts`'s `fetchAuthed`), so the API\n * routes never need to accept a query-string token. Restricting the\n * fallback to just the page route minimizes the token's exposure in\n * server access logs, proxy logs, and browser history to a single route\n * instead of every poll.\n */\nfunction isAuthorized(\n req: IncomingMessage,\n url: URL,\n token: string,\n allowQueryToken: boolean,\n): boolean {\n const header = req.headers.authorization;\n if (header && constantTimeEqual(header, `Bearer ${token}`)) return true;\n if (!allowQueryToken) return false;\n const queryToken = url.searchParams.get(\"token\");\n return queryToken !== null && constantTimeEqual(queryToken, 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 // `?token=` is only honored on the HTML page route — see `isAuthorized`.\n const isPageRoute = pathname === base || pathname === base.replace(/\\/$/, \"\");\n if (config.authToken && !isAuthorized(req, url, config.authToken, isPageRoute)) {\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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"}
|
|
1
|
+
{"version":3,"file":"serve.mjs","names":[],"sources":["../../../../../../../ai-panoptic/src/dashboard/serve.ts"],"sourcesContent":["import { timingSafeEqual } from \"node:crypto\";\nimport 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/**\n * Constant-time string equality (S4) — guards against a timing\n * side-channel that could otherwise let a network-adjacent attacker\n * recover the token byte-by-byte via repeated timed guesses. Plain `===`\n * short-circuits on the first differing byte, so response latency leaks\n * how many leading bytes matched; `timingSafeEqual` does not. Lengths are\n * compared first (a length mismatch is not secret and `timingSafeEqual`\n * requires equal-length buffers anyway).\n */\nfunction constantTimeEqual(a: string, b: string): boolean {\n const bufA = Buffer.from(a);\n const bufB = Buffer.from(b);\n\n if (bufA.length !== bufB.length) return false;\n\n return timingSafeEqual(bufA, bufB);\n}\n\n/**\n * Bearer-token check (S4): `Authorization: Bearer <token>` header, or\n * (only when `allowQueryToken`) a `?token=` query param.\n *\n * The query-string form only exists for the one request that structurally\n * cannot carry a custom header — the initial browser navigation that\n * loads the HTML shell (a typed/clicked/bookmarked URL). Every other\n * request the served page makes is same-origin `fetch()`, which can and\n * does set `Authorization` (see `ui.html.ts`'s `fetchAuthed`), so the API\n * routes never need to accept a query-string token. Restricting the\n * fallback to just the page route minimizes the token's exposure in\n * server access logs, proxy logs, and browser history to a single route\n * instead of every poll.\n */\nfunction isAuthorized(\n req: IncomingMessage,\n url: URL,\n token: string,\n allowQueryToken: boolean,\n): boolean {\n const header = req.headers.authorization;\n if (header && constantTimeEqual(header, `Bearer ${token}`)) return true;\n if (!allowQueryToken) return false;\n const queryToken = url.searchParams.get(\"token\");\n return queryToken !== null && constantTimeEqual(queryToken, 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 // `?token=` is only honored on the HTML page route — see `isAuthorized`.\n const isPageRoute = pathname === base || pathname === base.replace(/\\/$/, \"\");\n if (config.authToken && !isAuthorized(req, url, config.authToken, isPageRoute)) {\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":";;;;;;;;;AAoCA,MAAM,0BAA0B,KAAK;;;;;;AAOrC,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;;;;;;;;;;AAWA,SAAS,kBAAkB,GAAW,GAAoB;CACxD,MAAM,OAAO,OAAO,KAAK,CAAC;CAC1B,MAAM,OAAO,OAAO,KAAK,CAAC;CAE1B,IAAI,KAAK,WAAW,KAAK,QAAQ,OAAO;CAExC,OAAO,gBAAgB,MAAM,IAAI;AACnC;;;;;;;;;;;;;;;AAgBA,SAAS,aACP,KACA,KACA,OACA,iBACS;CACT,MAAM,SAAS,IAAI,QAAQ;CAC3B,IAAI,UAAU,kBAAkB,QAAQ,UAAU,OAAO,GAAG,OAAO;CACnE,IAAI,CAAC,iBAAiB,OAAO;CAC7B,MAAM,aAAa,IAAI,aAAa,IAAI,OAAO;CAC/C,OAAO,eAAe,QAAQ,kBAAkB,YAAY,KAAK;AACnE;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA6BA,SAAgB,qBACd,OACA,QACqD;CACrD,MAAM,OAAO,OAAO;CACpB,MAAM,YAAY,GAAG,KAAK;CAE1B,OAAO,SAAS,OAAO,KAAsB,KAA2B;EAEtE,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;EAIA,MAAM,cAAc,aAAa,QAAQ,aAAa,KAAK,QAAQ,OAAO,EAAE;EAC5E,IAAI,OAAO,aAAa,CAAC,aAAa,KAAK,KAAK,OAAO,WAAW,WAAW,GAAG;GAC9E,SAAS,KAAK,KAAK,EAAE,OAAO,eAAe,CAAC;GAE5C;EACF;EAIA,IAAI,IAAI,WAAW,UAAU,OAAO,UAAU;GAC5C,MAAM,QAAQ,kBAAkB,UAAU,SAAS;GAEnD,IAAI,OAAO;IACT,AAAK,eAAe,OAAO,OAAO,UAAU,MAAM,SAAS,MAAM,QAAQ,KAAK,GAAG;IAEjF;GACF;EACF;EAEA,IAAI,IAAI,WAAW,OAAO;GACxB,SAAS,KAAK,KAAK,EAAE,OAAO,qBAAqB,CAAC;GAElD;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,OAAO,QAAQ,OAAO,QAAQ,GAAG,OAAO,UAAU,gBAAgB,EAAE,CAAC;GAExG;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;;;;;;;AAQA,SAAS,kBACP,UACA,WACiD;CACjD,MAAM,SAAS,GAAG,UAAU;CAE5B,IAAI,CAAC,SAAS,WAAW,MAAM,GAC7B;CAGF,MAAM,QAAQ,sCAAsC,KAAK,SAAS,MAAM,OAAO,MAAM,CAAC;CAEtF,IAAI,CAAC,OACH;CAGF,OAAO;EACL,SAAS,mBAAmB,MAAM,EAAE;EACpC,QAAQ,mBAAmB,MAAM,EAAE;CACrC;AACF;;;;;;;AAQA,SAAS,aAAgB,KAA8C;CACrE,OAAO,IAAI,SAAS,SAAS,WAAW;EACtC,MAAM,SAAmB,CAAC;EAC1B,IAAI,OAAO;EAEX,IAAI,GAAG,SAAS,UAAkB;GAChC,QAAQ,MAAM;GAEd,IAAI,OAAO,yBAAyB;IAClC,IAAI,QAAQ;IACZ,uBAAO,IAAI,MAAM,mBAAmB,CAAC;IAErC;GACF;GAEA,OAAO,KAAK,KAAK;EACnB,CAAC;EAED,IAAI,GAAG,aAAa;GAClB,IAAI,OAAO,WAAW,GAAG;IACvB,QAAQ,MAAS;IAEjB;GACF;GAEA,IAAI;IACF,QAAQ,KAAK,MAAM,OAAO,OAAO,MAAM,CAAC,CAAC,SAAS,OAAO,CAAC,CAAM;GAClE,QAAQ;IACN,uBAAO,IAAI,MAAM,cAAc,CAAC;GAClC;EACF,CAAC;EAED,IAAI,GAAG,SAAS,MAAM;CACxB,CAAC;AACH;;;;;;;;;AAUA,eAAe,eACb,OACA,UACA,SACA,QACA,KACA,KACe;CACf,MAAM,QAAQ,MAAM,IAAI,OAAO;CAE/B,IAAI,UAAU,QAAW;EACvB,SAAS,KAAK,KAAK;GAAE,OAAO;GAAmB;EAAQ,CAAC;EAExD;CACF;CAEA,MAAM,OAAO,aAAa,MAAM,MAAM,MAAM;CAE5C,IAAI,SAAS,QAAW;EACtB,SAAS,KAAK,KAAK;GAAE,OAAO;GAAkB;EAAO,CAAC;EAEtD;CACF;CAEA,MAAM,eAAe,wBAAwB,IAAI;CAEjD,IAAI,iBAAiB,QAAW;EAC9B,SAAS,KAAK,KAAK,EAAE,OAAO,mBAAmB,CAAC;EAEhD;CACF;CAEA,IAAI;CAEJ,IAAI;EACF,OAAO,MAAM,aAAkC,GAAG;CACpD,SAAS,OAAO;EACd,MAAM,UAAU,iBAAiB,QAAQ,MAAM,UAAU;EACzD,SAAS,KAAK,YAAY,sBAAsB,MAAM,KAAK,EAAE,OAAO,QAAQ,CAAC;EAE7E;CACF;CAEA,IAAI;EAEF,SAAS,KAAK,KAAK,MADG,qBAAqB,cAAc,UAAU,MAAM,YAAY,CAC3D;CAC5B,SAAS,OAAO;EACd,SAAS,KAAK,KAAK;GACjB,OAAO;GACP,SAAS,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK;EAChE,CAAC;CACH;AACF"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"trace-filter.mjs","names":[],"sources":["../../../../../../../ai-panoptic/src/dashboard/trace-filter.ts"],"sourcesContent":["import type { ReportStatus, ReportType } from \"@warlock.js/ai\";\nimport type { Trace, TraceSpan } from \"../contracts/trace.type\";\n\n/**\n * Client-side trace-list filter criteria. The dashboard polls the full\n * trace list every 2s and narrows it in the browser with these — search\n * box, filter chips, and the errors-only header toggle all feed one\n * {@link TraceFilter}. Kept as a pure, framework-free module so the exact\n * matching rules are unit-tested here and the inlined dashboard JS in\n * {@link import(\"./ui.html\").dashboardHtml} mirrors them 1:1.\n *\n * Every field is optional — an absent field is \"don't care\". An empty\n * filter therefore matches every trace.\n */\nexport type TraceFilter = {\n /**\n * Free-text needle matched case-insensitively against a trace's root\n * span name and session id. Whitespace-trimmed; an empty/blank string\n * is treated as absent.\n */\n text?: string;\n /**\n * Restrict to these terminal statuses (root span status). Empty array\n * is treated as \"don't care\" (matches every status).\n */\n statuses?: ReportStatus[];\n /**\n * Restrict to these executable types (root span type). Empty array is\n * treated as \"don't care\".\n */\n types?: ReportType[];\n /**\n * Exact session-id chip. When set, only traces whose `sessionId`\n * equals this value match. Distinct from {@link TraceFilter.text},\n * which is a fuzzy substring across name + session.\n */\n sessionId?: string;\n /**\n * Exact prompt chip — a `name@version` key (see {@link tracePromptKey}).\n * When set, only traces whose root span resolved that exact named prompt\n * version match. Mirrors {@link TraceFilter.sessionId} but over the\n * prompt-version-linkage attributes the collector stamps on agent spans.\n */\n promptKey?: string;\n /**\n * Errors-only header toggle. When `true`, only `failed` / `cancelled`\n * traces match — independent of (and intersected with) `statuses`.\n */\n errorsOnly?: boolean;\n};\n\n/** Statuses the errors-only toggle keeps. */\nconst ERROR_STATUSES: readonly ReportStatus[] = [\"failed\", \"cancelled\"] as const;\n\n/**\n * Span-attribute keys the collector stamps with prompt-version linkage when\n * an agent ran against a *named* `ai.prompts` builder. Read here (not\n * imported) so this pure module stays decoupled from the collector.\n */\nconst PROMPT_NAME_ATTR = \"agent.promptName\";\nconst PROMPT_VERSION_ATTR = \"agent.promptVersion\";\n\n/**\n * The `name@version` prompt key a trace falls under, read from its root\n * span's prompt-version-linkage attributes (`agent.promptName` /\n * `agent.promptVersion`). Returns `undefined` when the run carried no named\n * prompt (raw-string / anonymous / no prompt), so callers can bucket those\n * under a synthetic \"no prompt\" key or skip them.\n *\n * The version is included so two runs of the same prompt name at different\n * versions are distinct keys — that is the whole point of the linkage: to\n * group / filter by the *exact* prompt revision that produced a run.\n */\nexport function tracePromptKey(trace: Trace): string | undefined {\n const attributes = trace.root.attributes;\n\n if (attributes === undefined) {\n return undefined;\n }\n\n const name = attributes[PROMPT_NAME_ATTR];\n\n if (typeof name !== \"string\" || name.length === 0) {\n return undefined;\n }\n\n const version = attributes[PROMPT_VERSION_ATTR];\n const versionLabel = typeof version === \"string\" && version.length > 0 ? version : \"1\";\n\n return `${name}@${versionLabel}`;\n}\n\n/**\n * Does a single trace pass the given filter? Pure predicate — all\n * conditions are ANDed; an unset field never excludes. Used by\n * {@link filterTraces} and mirrored by the dashboard's inlined JS.\n */\nexport function matchesFilter(trace: Trace, filter: TraceFilter): boolean {\n const root = trace.root;\n\n if (filter.errorsOnly === true && !ERROR_STATUSES.includes(root.status)) {\n return false;\n }\n\n if (filter.statuses !== undefined && filter.statuses.length > 0) {\n if (!filter.statuses.includes(root.status)) {\n return false;\n }\n }\n\n if (filter.types !== undefined && filter.types.length > 0) {\n if (!filter.types.includes(root.type)) {\n return false;\n }\n }\n\n if (filter.sessionId !== undefined && filter.sessionId.length > 0) {\n if (trace.sessionId !== filter.sessionId) {\n return false;\n }\n }\n\n if (filter.promptKey !== undefined && filter.promptKey.length > 0) {\n if (tracePromptKey(trace) !== filter.promptKey) {\n return false;\n }\n }\n\n const text = filter.text?.trim().toLowerCase();\n if (text !== undefined && text.length > 0) {\n const haystack = `${root.name} ${trace.sessionId ?? \"\"}`.toLowerCase();\n if (!haystack.includes(text)) {\n return false;\n }\n }\n\n return true;\n}\n\n/** Narrow a trace list to those matching the filter, order preserved. */\nexport function filterTraces(traces: Trace[], filter: TraceFilter): Trace[] {\n return traces.filter((trace) => matchesFilter(trace, filter));\n}\n\n/**\n * The grouping key a trace falls under when the list is grouped by\n * session. Traces without a `sessionId` collapse into one synthetic\n * \"(no session)\" bucket so they remain visible.\n */\nexport const NO_SESSION_KEY = \"(no session)\";\n\n/** One session bucket — its key plus the traces under it, order preserved. */\nexport type SessionGroup = {\n /** The `sessionId`, or {@link NO_SESSION_KEY} for sessionless traces. */\n sessionId: string;\n /** Traces in this group, in the order they appeared in the input. */\n traces: Trace[];\n};\n\n/**\n * Group an (already filtered, newest-first) trace list by session id,\n * preserving first-seen order of both the groups and the traces within\n * each. Sessionless traces bucket under {@link NO_SESSION_KEY}.\n */\nexport function groupBySession(traces: Trace[]): SessionGroup[] {\n const order: string[] = [];\n const byKey = new Map<string, Trace[]>();\n\n for (const trace of traces) {\n const key = trace.sessionId ?? NO_SESSION_KEY;\n let bucket = byKey.get(key);\n\n if (bucket === undefined) {\n bucket = [];\n byKey.set(key, bucket);\n order.push(key);\n }\n\n bucket.push(trace);\n }\n\n return order.map((sessionId) => ({ sessionId, traces: byKey.get(sessionId) ?? [] }));\n}\n\n/**\n * The grouping key a trace falls under when the list is grouped by prompt\n * version. Runs that resolved no named prompt collapse into one synthetic\n * \"(no prompt)\" bucket so they remain visible.\n */\nexport const NO_PROMPT_KEY = \"(no prompt)\";\n\n/** One prompt-version bucket — its `name@version` key plus its traces. */\nexport type PromptGroup = {\n /** The `name@version` key, or {@link NO_PROMPT_KEY} for unlinked traces. */\n promptKey: string;\n /** Traces in this group, in the order they appeared in the input. */\n traces: Trace[];\n};\n\n/**\n * Group an (already filtered, newest-first) trace list by prompt version —\n * the `name@version` key {@link tracePromptKey} derives from each run's\n * prompt-version-linkage attributes. Preserves first-seen order of both the\n * groups and the traces within each. Runs with no named prompt bucket under\n * {@link NO_PROMPT_KEY}.\n *\n * The dashboard offers this as a second group-by dimension beside session,\n * so a reviewer can see every run of \"support@2\" together and compare its\n * cost / failure rate against \"support@3\".\n */\nexport function groupByPrompt(traces: Trace[]): PromptGroup[] {\n const order: string[] = [];\n const byKey = new Map<string, Trace[]>();\n\n for (const trace of traces) {\n const key = tracePromptKey(trace) ?? NO_PROMPT_KEY;\n let bucket = byKey.get(key);\n\n if (bucket === undefined) {\n bucket = [];\n byKey.set(key, bucket);\n order.push(key);\n }\n\n bucket.push(trace);\n }\n\n return order.map((promptKey) => ({ promptKey, traces: byKey.get(promptKey) ?? [] }));\n}\n\n/**\n * The grouping key a trace falls under when the list is grouped by root\n * type. `root.type` is always present, so this is only a defensive\n * fallback for a malformed trace with no typed root.\n */\nexport const NO_TYPE_KEY = \"(no type)\";\n\n/** One root-type bucket — its type discriminator plus its traces. */\nexport type TypeGroup = {\n /** The root span's `type` (e.g. `agent` / `workflow` / `planner`), or {@link NO_TYPE_KEY}. */\n type: string;\n /** Traces in this group, in the order they appeared in the input. */\n traces: Trace[];\n};\n\n/**\n * Group an (already filtered, newest-first) trace list by root type —\n * the `agent` / `workflow` / `supervisor` / `orchestrator` / `planner` /\n * `tool` / `batch` / `callback` discriminator on each trace's root span.\n * Preserves first-seen order of both the groups and the traces within each.\n *\n * The dashboard offers this as the coarsest group-by dimension beside\n * session and prompt: when a benchmark floods the flat list with hundreds\n * of one primitive (e.g. standalone `agent` runs), this collapses them\n * behind a per-type header so the structural primitives (the lone planner,\n * the team, the supervisors) are one click away instead of a scroll past.\n */\nexport function groupByType(traces: Trace[]): TypeGroup[] {\n const order: string[] = [];\n const byKey = new Map<string, Trace[]>();\n\n for (const trace of traces) {\n const key = trace.root.type || NO_TYPE_KEY;\n let bucket = byKey.get(key);\n\n if (bucket === undefined) {\n bucket = [];\n byKey.set(key, bucket);\n order.push(key);\n }\n\n bucket.push(trace);\n }\n\n return order.map((type) => ({ type, traces: byKey.get(type) ?? [] }));\n}\n\n/** One row of the per-type aggregate-stats panel. */\nexport type TypeStat = {\n /** Root type discriminator, or {@link NO_TYPE_KEY}. */\n type: string;\n /** Number of traces of this type. */\n count: number;\n /** Traces whose status is `failed` / `cancelled`. */\n failed: number;\n /** `failed / count` in `[0, 1]`. */\n failRate: number;\n /** Median (p50) root duration in ms. */\n p50: number;\n /** p95 root duration in ms. */\n p95: number;\n /** Summed `usage.total` tokens across the bucket. */\n tokens: number;\n /** Summed per-trace USD cost across the bucket. */\n cost: number;\n};\n\n/**\n * Nearest-rank percentile of `values` at `p` (0–100). Returns 0 for an\n * empty input and sorts a copy so the caller's array is left untouched.\n */\nexport function percentile(values: number[], p: number): number {\n if (values.length === 0) {\n return 0;\n }\n\n const sorted = values.slice().sort((a, b) => a - b);\n const rank = Math.ceil((p / 100) * sorted.length) - 1;\n const index = Math.min(Math.max(rank, 0), sorted.length - 1);\n\n return sorted[index];\n}\n\n/**\n * One trace's total USD cost: its explicit root-usage cost when priced,\n * else the rollup of its subtree. Mirrors the dashboard's `traceCost` so the\n * stats panel and the trace rows agree to the cent.\n */\nexport function traceCost(trace: Trace): number {\n const own = costSumObj(trace.usage.cost);\n return own > 0 ? own : rollupCost(trace.root);\n}\n\n/**\n * Aggregate an (already filtered) trace list into one {@link TypeStat} row\n * per root type — count, failure rate, p50/p95 latency, total tokens, total\n * cost — preserving first-seen type order. Powers the dashboard's per-type\n * stats panel; computed over the same filtered list the trace view shows, so\n * the panel always reflects the active filters.\n */\nexport function aggregateByType(traces: Trace[]): TypeStat[] {\n const order: string[] = [];\n const byKey = new Map<string, Trace[]>();\n\n for (const trace of traces) {\n const key = trace.root.type || NO_TYPE_KEY;\n let bucket = byKey.get(key);\n\n if (bucket === undefined) {\n bucket = [];\n byKey.set(key, bucket);\n order.push(key);\n }\n\n bucket.push(trace);\n }\n\n return order.map((type) => {\n const bucket = byKey.get(type) ?? [];\n const durations = bucket.map((trace) => trace.duration);\n let failed = 0;\n let tokens = 0;\n let cost = 0;\n\n for (const trace of bucket) {\n if (ERROR_STATUSES.includes(trace.root.status)) {\n failed += 1;\n }\n\n tokens += trace.usage.total ?? 0;\n cost += traceCost(trace);\n }\n\n return {\n type,\n count: bucket.length,\n failed,\n failRate: bucket.length > 0 ? failed / bucket.length : 0,\n p50: percentile(durations, 50),\n p95: percentile(durations, 95),\n tokens,\n cost,\n };\n });\n}\n\n/**\n * Sum every priced lane of a usage cost object into one USD number.\n * Mirrors the dashboard's `costSumObj` so heatmap intensities computed\n * here match what the page renders.\n */\nfunction costSumObj(cost: TraceSpan[\"usage\"][\"cost\"]): number {\n if (cost === undefined) {\n return 0;\n }\n\n return (\n (cost.input ?? 0) +\n (cost.output ?? 0) +\n (cost.cachedInput ?? 0) +\n (cost.cachedOutput ?? 0) +\n (cost.reasoning ?? 0)\n );\n}\n\n/**\n * Rollup-aware subtree cost: a node's own cost when it carries one\n * (it already rolls up its trips), otherwise the sum of its children.\n * Avoids double-counting wrapper nodes (workflow/supervisor roots carry\n * tokens but no cost). Mirrors the dashboard's `rollupCost`.\n */\nexport function rollupCost(span: TraceSpan): number {\n const own = costSumObj(span.usage.cost);\n if (own > 0) {\n return own;\n }\n\n let sum = 0;\n for (const child of span.children) {\n sum += rollupCost(child);\n }\n\n return sum;\n}\n\n/**\n * Heatmap intensity in `[0, 1]` for a node's `rollupCost` relative to the\n * trace's most-expensive node. The dashboard tints each tree node's left\n * accent by this. `max <= 0` (a free trace) yields `0` for every node so\n * the heatmap simply stays cold rather than dividing by zero.\n */\nexport function heatIntensity(nodeCost: number, maxCost: number): number {\n if (maxCost <= 0 || nodeCost <= 0) {\n return 0;\n }\n\n const ratio = nodeCost / maxCost;\n\n return ratio > 1 ? 1 : ratio;\n}\n\n/**\n * The largest single-node {@link rollupCost} anywhere in a span tree —\n * the denominator for {@link heatIntensity}. Walks the whole subtree.\n */\nexport function maxNodeCost(root: TraceSpan): number {\n let max = rollupCost(root);\n\n for (const child of root.children) {\n const childMax = maxNodeCost(child);\n if (childMax > max) {\n max = childMax;\n }\n }\n\n return max;\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"trace-filter.mjs","names":[],"sources":["../../../../../../../ai-panoptic/src/dashboard/trace-filter.ts"],"sourcesContent":["import type { ReportStatus, ReportType } from \"@warlock.js/ai\";\nimport type { Trace, TraceSpan } from \"../contracts/trace.type\";\n\n/**\n * Client-side trace-list filter criteria. The dashboard polls the full\n * trace list every 2s and narrows it in the browser with these — search\n * box, filter chips, and the errors-only header toggle all feed one\n * {@link TraceFilter}. Kept as a pure, framework-free module so the exact\n * matching rules are unit-tested here and the inlined dashboard JS in\n * {@link import(\"./ui.html\").dashboardHtml} mirrors them 1:1.\n *\n * Every field is optional — an absent field is \"don't care\". An empty\n * filter therefore matches every trace.\n */\nexport type TraceFilter = {\n /**\n * Free-text needle matched case-insensitively against a trace's root\n * span name and session id. Whitespace-trimmed; an empty/blank string\n * is treated as absent.\n */\n text?: string;\n /**\n * Restrict to these terminal statuses (root span status). Empty array\n * is treated as \"don't care\" (matches every status).\n */\n statuses?: ReportStatus[];\n /**\n * Restrict to these executable types (root span type). Empty array is\n * treated as \"don't care\".\n */\n types?: ReportType[];\n /**\n * Exact session-id chip. When set, only traces whose `sessionId`\n * equals this value match. Distinct from {@link TraceFilter.text},\n * which is a fuzzy substring across name + session.\n */\n sessionId?: string;\n /**\n * Exact prompt chip — a `name@version` key (see {@link tracePromptKey}).\n * When set, only traces whose root span resolved that exact named prompt\n * version match. Mirrors {@link TraceFilter.sessionId} but over the\n * prompt-version-linkage attributes the collector stamps on agent spans.\n */\n promptKey?: string;\n /**\n * Errors-only header toggle. When `true`, only `failed` / `cancelled`\n * traces match — independent of (and intersected with) `statuses`.\n */\n errorsOnly?: boolean;\n};\n\n/** Statuses the errors-only toggle keeps. */\nconst ERROR_STATUSES: readonly ReportStatus[] = [\"failed\", \"cancelled\"] as const;\n\n/**\n * Span-attribute keys the collector stamps with prompt-version linkage when\n * an agent ran against a *named* `ai.prompts` builder. Read here (not\n * imported) so this pure module stays decoupled from the collector.\n */\nconst PROMPT_NAME_ATTR = \"agent.promptName\";\nconst PROMPT_VERSION_ATTR = \"agent.promptVersion\";\n\n/**\n * The `name@version` prompt key a trace falls under, read from its root\n * span's prompt-version-linkage attributes (`agent.promptName` /\n * `agent.promptVersion`). Returns `undefined` when the run carried no named\n * prompt (raw-string / anonymous / no prompt), so callers can bucket those\n * under a synthetic \"no prompt\" key or skip them.\n *\n * The version is included so two runs of the same prompt name at different\n * versions are distinct keys — that is the whole point of the linkage: to\n * group / filter by the *exact* prompt revision that produced a run.\n */\nexport function tracePromptKey(trace: Trace): string | undefined {\n const attributes = trace.root.attributes;\n\n if (attributes === undefined) {\n return undefined;\n }\n\n const name = attributes[PROMPT_NAME_ATTR];\n\n if (typeof name !== \"string\" || name.length === 0) {\n return undefined;\n }\n\n const version = attributes[PROMPT_VERSION_ATTR];\n const versionLabel = typeof version === \"string\" && version.length > 0 ? version : \"1\";\n\n return `${name}@${versionLabel}`;\n}\n\n/**\n * Does a single trace pass the given filter? Pure predicate — all\n * conditions are ANDed; an unset field never excludes. Used by\n * {@link filterTraces} and mirrored by the dashboard's inlined JS.\n */\nexport function matchesFilter(trace: Trace, filter: TraceFilter): boolean {\n const root = trace.root;\n\n if (filter.errorsOnly === true && !ERROR_STATUSES.includes(root.status)) {\n return false;\n }\n\n if (filter.statuses !== undefined && filter.statuses.length > 0) {\n if (!filter.statuses.includes(root.status)) {\n return false;\n }\n }\n\n if (filter.types !== undefined && filter.types.length > 0) {\n if (!filter.types.includes(root.type)) {\n return false;\n }\n }\n\n if (filter.sessionId !== undefined && filter.sessionId.length > 0) {\n if (trace.sessionId !== filter.sessionId) {\n return false;\n }\n }\n\n if (filter.promptKey !== undefined && filter.promptKey.length > 0) {\n if (tracePromptKey(trace) !== filter.promptKey) {\n return false;\n }\n }\n\n const text = filter.text?.trim().toLowerCase();\n if (text !== undefined && text.length > 0) {\n const haystack = `${root.name} ${trace.sessionId ?? \"\"}`.toLowerCase();\n if (!haystack.includes(text)) {\n return false;\n }\n }\n\n return true;\n}\n\n/** Narrow a trace list to those matching the filter, order preserved. */\nexport function filterTraces(traces: Trace[], filter: TraceFilter): Trace[] {\n return traces.filter((trace) => matchesFilter(trace, filter));\n}\n\n/**\n * The grouping key a trace falls under when the list is grouped by\n * session. Traces without a `sessionId` collapse into one synthetic\n * \"(no session)\" bucket so they remain visible.\n */\nexport const NO_SESSION_KEY = \"(no session)\";\n\n/** One session bucket — its key plus the traces under it, order preserved. */\nexport type SessionGroup = {\n /** The `sessionId`, or {@link NO_SESSION_KEY} for sessionless traces. */\n sessionId: string;\n /** Traces in this group, in the order they appeared in the input. */\n traces: Trace[];\n};\n\n/**\n * Group an (already filtered, newest-first) trace list by session id,\n * preserving first-seen order of both the groups and the traces within\n * each. Sessionless traces bucket under {@link NO_SESSION_KEY}.\n */\nexport function groupBySession(traces: Trace[]): SessionGroup[] {\n const order: string[] = [];\n const byKey = new Map<string, Trace[]>();\n\n for (const trace of traces) {\n const key = trace.sessionId ?? NO_SESSION_KEY;\n let bucket = byKey.get(key);\n\n if (bucket === undefined) {\n bucket = [];\n byKey.set(key, bucket);\n order.push(key);\n }\n\n bucket.push(trace);\n }\n\n return order.map((sessionId) => ({ sessionId, traces: byKey.get(sessionId) ?? [] }));\n}\n\n/**\n * The grouping key a trace falls under when the list is grouped by prompt\n * version. Runs that resolved no named prompt collapse into one synthetic\n * \"(no prompt)\" bucket so they remain visible.\n */\nexport const NO_PROMPT_KEY = \"(no prompt)\";\n\n/** One prompt-version bucket — its `name@version` key plus its traces. */\nexport type PromptGroup = {\n /** The `name@version` key, or {@link NO_PROMPT_KEY} for unlinked traces. */\n promptKey: string;\n /** Traces in this group, in the order they appeared in the input. */\n traces: Trace[];\n};\n\n/**\n * Group an (already filtered, newest-first) trace list by prompt version —\n * the `name@version` key {@link tracePromptKey} derives from each run's\n * prompt-version-linkage attributes. Preserves first-seen order of both the\n * groups and the traces within each. Runs with no named prompt bucket under\n * {@link NO_PROMPT_KEY}.\n *\n * The dashboard offers this as a second group-by dimension beside session,\n * so a reviewer can see every run of \"support@2\" together and compare its\n * cost / failure rate against \"support@3\".\n */\nexport function groupByPrompt(traces: Trace[]): PromptGroup[] {\n const order: string[] = [];\n const byKey = new Map<string, Trace[]>();\n\n for (const trace of traces) {\n const key = tracePromptKey(trace) ?? NO_PROMPT_KEY;\n let bucket = byKey.get(key);\n\n if (bucket === undefined) {\n bucket = [];\n byKey.set(key, bucket);\n order.push(key);\n }\n\n bucket.push(trace);\n }\n\n return order.map((promptKey) => ({ promptKey, traces: byKey.get(promptKey) ?? [] }));\n}\n\n/**\n * The grouping key a trace falls under when the list is grouped by root\n * type. `root.type` is always present, so this is only a defensive\n * fallback for a malformed trace with no typed root.\n */\nexport const NO_TYPE_KEY = \"(no type)\";\n\n/** One root-type bucket — its type discriminator plus its traces. */\nexport type TypeGroup = {\n /** The root span's `type` (e.g. `agent` / `workflow` / `planner`), or {@link NO_TYPE_KEY}. */\n type: string;\n /** Traces in this group, in the order they appeared in the input. */\n traces: Trace[];\n};\n\n/**\n * Group an (already filtered, newest-first) trace list by root type —\n * the `agent` / `workflow` / `supervisor` / `orchestrator` / `planner` /\n * `tool` / `batch` / `callback` discriminator on each trace's root span.\n * Preserves first-seen order of both the groups and the traces within each.\n *\n * The dashboard offers this as the coarsest group-by dimension beside\n * session and prompt: when a benchmark floods the flat list with hundreds\n * of one primitive (e.g. standalone `agent` runs), this collapses them\n * behind a per-type header so the structural primitives (the lone planner,\n * the team, the supervisors) are one click away instead of a scroll past.\n */\nexport function groupByType(traces: Trace[]): TypeGroup[] {\n const order: string[] = [];\n const byKey = new Map<string, Trace[]>();\n\n for (const trace of traces) {\n const key = trace.root.type || NO_TYPE_KEY;\n let bucket = byKey.get(key);\n\n if (bucket === undefined) {\n bucket = [];\n byKey.set(key, bucket);\n order.push(key);\n }\n\n bucket.push(trace);\n }\n\n return order.map((type) => ({ type, traces: byKey.get(type) ?? [] }));\n}\n\n/** One row of the per-type aggregate-stats panel. */\nexport type TypeStat = {\n /** Root type discriminator, or {@link NO_TYPE_KEY}. */\n type: string;\n /** Number of traces of this type. */\n count: number;\n /** Traces whose status is `failed` / `cancelled`. */\n failed: number;\n /** `failed / count` in `[0, 1]`. */\n failRate: number;\n /** Median (p50) root duration in ms. */\n p50: number;\n /** p95 root duration in ms. */\n p95: number;\n /** Summed `usage.total` tokens across the bucket. */\n tokens: number;\n /** Summed per-trace USD cost across the bucket. */\n cost: number;\n};\n\n/**\n * Nearest-rank percentile of `values` at `p` (0–100). Returns 0 for an\n * empty input and sorts a copy so the caller's array is left untouched.\n */\nexport function percentile(values: number[], p: number): number {\n if (values.length === 0) {\n return 0;\n }\n\n const sorted = values.slice().sort((a, b) => a - b);\n const rank = Math.ceil((p / 100) * sorted.length) - 1;\n const index = Math.min(Math.max(rank, 0), sorted.length - 1);\n\n return sorted[index];\n}\n\n/**\n * One trace's total USD cost: its explicit root-usage cost when priced,\n * else the rollup of its subtree. Mirrors the dashboard's `traceCost` so the\n * stats panel and the trace rows agree to the cent.\n */\nexport function traceCost(trace: Trace): number {\n const own = costSumObj(trace.usage.cost);\n return own > 0 ? own : rollupCost(trace.root);\n}\n\n/**\n * Aggregate an (already filtered) trace list into one {@link TypeStat} row\n * per root type — count, failure rate, p50/p95 latency, total tokens, total\n * cost — preserving first-seen type order. Powers the dashboard's per-type\n * stats panel; computed over the same filtered list the trace view shows, so\n * the panel always reflects the active filters.\n */\nexport function aggregateByType(traces: Trace[]): TypeStat[] {\n const order: string[] = [];\n const byKey = new Map<string, Trace[]>();\n\n for (const trace of traces) {\n const key = trace.root.type || NO_TYPE_KEY;\n let bucket = byKey.get(key);\n\n if (bucket === undefined) {\n bucket = [];\n byKey.set(key, bucket);\n order.push(key);\n }\n\n bucket.push(trace);\n }\n\n return order.map((type) => {\n const bucket = byKey.get(type) ?? [];\n const durations = bucket.map((trace) => trace.duration);\n let failed = 0;\n let tokens = 0;\n let cost = 0;\n\n for (const trace of bucket) {\n if (ERROR_STATUSES.includes(trace.root.status)) {\n failed += 1;\n }\n\n tokens += trace.usage.total ?? 0;\n cost += traceCost(trace);\n }\n\n return {\n type,\n count: bucket.length,\n failed,\n failRate: bucket.length > 0 ? failed / bucket.length : 0,\n p50: percentile(durations, 50),\n p95: percentile(durations, 95),\n tokens,\n cost,\n };\n });\n}\n\n/**\n * Sum every priced lane of a usage cost object into one USD number.\n * Mirrors the dashboard's `costSumObj` so heatmap intensities computed\n * here match what the page renders.\n */\nfunction costSumObj(cost: TraceSpan[\"usage\"][\"cost\"]): number {\n if (cost === undefined) {\n return 0;\n }\n\n return (\n (cost.input ?? 0) +\n (cost.output ?? 0) +\n (cost.cachedInput ?? 0) +\n (cost.cachedOutput ?? 0) +\n (cost.reasoning ?? 0)\n );\n}\n\n/**\n * Rollup-aware subtree cost: a node's own cost when it carries one\n * (it already rolls up its trips), otherwise the sum of its children.\n * Avoids double-counting wrapper nodes (workflow/supervisor roots carry\n * tokens but no cost). Mirrors the dashboard's `rollupCost`.\n */\nexport function rollupCost(span: TraceSpan): number {\n const own = costSumObj(span.usage.cost);\n if (own > 0) {\n return own;\n }\n\n let sum = 0;\n for (const child of span.children) {\n sum += rollupCost(child);\n }\n\n return sum;\n}\n\n/**\n * Heatmap intensity in `[0, 1]` for a node's `rollupCost` relative to the\n * trace's most-expensive node. The dashboard tints each tree node's left\n * accent by this. `max <= 0` (a free trace) yields `0` for every node so\n * the heatmap simply stays cold rather than dividing by zero.\n */\nexport function heatIntensity(nodeCost: number, maxCost: number): number {\n if (maxCost <= 0 || nodeCost <= 0) {\n return 0;\n }\n\n const ratio = nodeCost / maxCost;\n\n return ratio > 1 ? 1 : ratio;\n}\n\n/**\n * The largest single-node {@link rollupCost} anywhere in a span tree —\n * the denominator for {@link heatIntensity}. Walks the whole subtree.\n */\nexport function maxNodeCost(root: TraceSpan): number {\n let max = rollupCost(root);\n\n for (const child of root.children) {\n const childMax = maxNodeCost(child);\n if (childMax > max) {\n max = childMax;\n }\n }\n\n return max;\n}\n"],"mappings":"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"}
|