@iris-eval/mcp-server 0.3.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -3
- package/dist/audit-log-reader.d.ts +24 -0
- package/dist/audit-log-reader.js +87 -0
- package/dist/config/defaults.js +7 -1
- package/dist/custom-rule-store.d.ts +27 -0
- package/dist/custom-rule-store.js +188 -0
- package/dist/dashboard/assets/index-BEG5FYWH.css +1 -0
- package/dist/dashboard/assets/index-D9JHfSB2.js +12 -0
- package/dist/dashboard/index.html +2 -2
- package/dist/dashboard/routes/audit.d.ts +3 -0
- package/dist/dashboard/routes/audit.js +36 -0
- package/dist/dashboard/routes/eval-stats.js +9 -4
- package/dist/dashboard/routes/evaluations.js +3 -1
- package/dist/dashboard/routes/filters.js +5 -3
- package/dist/dashboard/routes/health.js +8 -1
- package/dist/dashboard/routes/index.d.ts +4 -0
- package/dist/dashboard/routes/index.js +4 -0
- package/dist/dashboard/routes/moments.d.ts +3 -0
- package/dist/dashboard/routes/moments.js +115 -0
- package/dist/dashboard/routes/preferences.d.ts +3 -0
- package/dist/dashboard/routes/preferences.js +52 -0
- package/dist/dashboard/routes/rules.d.ts +10 -0
- package/dist/dashboard/routes/rules.js +169 -0
- package/dist/dashboard/routes/summary.js +3 -1
- package/dist/dashboard/routes/traces.js +7 -4
- package/dist/dashboard/server.d.ts +9 -1
- package/dist/dashboard/server.js +52 -3
- package/dist/eval/citation-verify/extract.d.ts +11 -0
- package/dist/eval/citation-verify/extract.js +102 -0
- package/dist/eval/citation-verify/resolve.d.ts +26 -0
- package/dist/eval/citation-verify/resolve.js +237 -0
- package/dist/eval/citation-verify/verifier.d.ts +43 -0
- package/dist/eval/citation-verify/verifier.js +203 -0
- package/dist/eval/decision-moment.d.ts +12 -0
- package/dist/eval/decision-moment.js +181 -0
- package/dist/eval/llm-judge/client.d.ts +28 -0
- package/dist/eval/llm-judge/client.js +183 -0
- package/dist/eval/llm-judge/evaluator.d.ts +32 -0
- package/dist/eval/llm-judge/evaluator.js +138 -0
- package/dist/eval/llm-judge/pricing.d.ts +9 -0
- package/dist/eval/llm-judge/pricing.js +31 -0
- package/dist/eval/llm-judge/templates/index.d.ts +20 -0
- package/dist/eval/llm-judge/templates/index.js +170 -0
- package/dist/eval/rules/custom.js +13 -2
- package/dist/index.js +77 -14
- package/dist/middleware/index.d.ts +1 -0
- package/dist/middleware/index.js +1 -0
- package/dist/middleware/tenant.d.ts +17 -0
- package/dist/middleware/tenant.js +26 -0
- package/dist/otel/exporter.d.ts +24 -0
- package/dist/otel/exporter.js +116 -0
- package/dist/otel/lazy.d.ts +5 -0
- package/dist/otel/lazy.js +31 -0
- package/dist/otel/mapper.d.ts +24 -0
- package/dist/otel/mapper.js +208 -0
- package/dist/preferences.d.ts +129 -0
- package/dist/preferences.js +152 -0
- package/dist/resources/dashboard-summary.js +3 -1
- package/dist/resources/trace-detail.js +5 -3
- package/dist/server.d.ts +3 -1
- package/dist/server.js +9 -3
- package/dist/storage/migrations/004-tenant-id.d.ts +3 -0
- package/dist/storage/migrations/004-tenant-id.js +40 -0
- package/dist/storage/migrations/index.js +2 -1
- package/dist/storage/sqlite-adapter.d.ts +17 -15
- package/dist/storage/sqlite-adapter.js +130 -79
- package/dist/tools/delete-rule.d.ts +3 -0
- package/dist/tools/delete-rule.js +53 -0
- package/dist/tools/delete-trace.d.ts +3 -0
- package/dist/tools/delete-trace.js +54 -0
- package/dist/tools/deploy-rule.d.ts +3 -0
- package/dist/tools/deploy-rule.js +91 -0
- package/dist/tools/evaluate-output.js +23 -2
- package/dist/tools/evaluate-with-llm-judge.d.ts +3 -0
- package/dist/tools/evaluate-with-llm-judge.js +147 -0
- package/dist/tools/get-traces.js +23 -3
- package/dist/tools/index.d.ts +2 -1
- package/dist/tools/index.js +13 -1
- package/dist/tools/list-rules.d.ts +3 -0
- package/dist/tools/list-rules.js +66 -0
- package/dist/tools/log-trace.js +30 -2
- package/dist/tools/verify-citations.d.ts +3 -0
- package/dist/tools/verify-citations.js +157 -0
- package/dist/types/custom-rule.d.ts +70 -0
- package/dist/types/custom-rule.js +1 -0
- package/dist/types/decision-moment.d.ts +122 -0
- package/dist/types/decision-moment.js +17 -0
- package/dist/types/index.d.ts +2 -0
- package/dist/types/index.js +1 -1
- package/dist/types/query.d.ts +25 -15
- package/dist/types/tenant.d.ts +26 -0
- package/dist/types/tenant.js +58 -0
- package/dist/utils/open-browser.d.ts +1 -0
- package/dist/utils/open-browser.js +45 -0
- package/dist/utils/validate-port-config.d.ts +2 -0
- package/dist/utils/validate-port-config.js +9 -0
- package/package.json +4 -1
- package/server.json +2 -2
- package/dist/dashboard/assets/index-CnDg6bYi.js +0 -43
- package/dist/dashboard/assets/index-ZsBou2-c.css +0 -1
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// OTLP/HTTP JSON exporter — posts ExportTraceServiceRequest payloads to
|
|
2
|
+
// an OpenTelemetry collector, Jaeger, Grafana Tempo, or Datadog OTLP
|
|
3
|
+
// ingest endpoint. Works against any receiver that speaks OTLP/HTTP
|
|
4
|
+
// JSON at /v1/traces (the OTLP spec canonical path).
|
|
5
|
+
//
|
|
6
|
+
// Hand-rolled against the OTLP spec rather than pulling in @opentelemetry/*
|
|
7
|
+
// — consistent with the LLM client + citation resolver approach (keep
|
|
8
|
+
// supply-chain surface small, the wire format is simple). gRPC transport
|
|
9
|
+
// is not included in v0.4.0; callers with gRPC-only receivers should
|
|
10
|
+
// front it with an HTTP-accepting collector (Jaeger/Tempo/OTEL Collector
|
|
11
|
+
// accept HTTP natively).
|
|
12
|
+
//
|
|
13
|
+
// Batch behavior: callers (typically log_trace handler) accumulate Trace
|
|
14
|
+
// rows and call exportTraces([...]) at convenient points. The exporter
|
|
15
|
+
// does NOT own a timer — keeping it stateless keeps it testable and
|
|
16
|
+
// avoids accidentally leaking a process-wide interval in tests.
|
|
17
|
+
import { buildExportPayload } from './mapper.js';
|
|
18
|
+
export class OtelExporter {
|
|
19
|
+
endpoint;
|
|
20
|
+
serviceName;
|
|
21
|
+
headers;
|
|
22
|
+
timeoutMs;
|
|
23
|
+
constructor(config) {
|
|
24
|
+
// Normalize endpoint — strip trailing slash; append /v1/traces if
|
|
25
|
+
// the caller gave a collector root. This matches the behavior of
|
|
26
|
+
// the official OTLP/HTTP clients.
|
|
27
|
+
const base = config.endpoint.replace(/\/+$/, '');
|
|
28
|
+
const path = config.pathPrefix ?? '/v1/traces';
|
|
29
|
+
this.endpoint = base.endsWith(path) ? base : base + path;
|
|
30
|
+
this.serviceName = config.serviceName;
|
|
31
|
+
this.headers = { ...(config.headers ?? {}) };
|
|
32
|
+
this.timeoutMs = config.timeoutMs ?? 15_000;
|
|
33
|
+
}
|
|
34
|
+
async exportTraces(traces) {
|
|
35
|
+
if (traces.length === 0) {
|
|
36
|
+
return { ok: true, status: 204, bytesSent: 0, latencyMs: 0 };
|
|
37
|
+
}
|
|
38
|
+
const payload = buildExportPayload(traces, this.serviceName);
|
|
39
|
+
const body = JSON.stringify(payload);
|
|
40
|
+
const started = Date.now();
|
|
41
|
+
const controller = new AbortController();
|
|
42
|
+
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
43
|
+
try {
|
|
44
|
+
const res = await fetch(this.endpoint, {
|
|
45
|
+
method: 'POST',
|
|
46
|
+
signal: controller.signal,
|
|
47
|
+
headers: {
|
|
48
|
+
'content-type': 'application/json',
|
|
49
|
+
...this.headers,
|
|
50
|
+
},
|
|
51
|
+
body,
|
|
52
|
+
});
|
|
53
|
+
const latencyMs = Date.now() - started;
|
|
54
|
+
if (!res.ok) {
|
|
55
|
+
const text = await res.text().catch(() => '');
|
|
56
|
+
return {
|
|
57
|
+
ok: false,
|
|
58
|
+
status: res.status,
|
|
59
|
+
bytesSent: body.length,
|
|
60
|
+
latencyMs,
|
|
61
|
+
error: `OTLP exporter got ${res.status}: ${text.slice(0, 200)}`,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
ok: true,
|
|
66
|
+
status: res.status,
|
|
67
|
+
bytesSent: body.length,
|
|
68
|
+
latencyMs,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
catch (err) {
|
|
72
|
+
const latencyMs = Date.now() - started;
|
|
73
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
74
|
+
return {
|
|
75
|
+
ok: false,
|
|
76
|
+
status: 0,
|
|
77
|
+
bytesSent: body.length,
|
|
78
|
+
latencyMs,
|
|
79
|
+
error: msg,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
finally {
|
|
83
|
+
clearTimeout(timer);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
export function exporterFromEnv() {
|
|
88
|
+
const endpoint = process.env.IRIS_OTEL_ENDPOINT;
|
|
89
|
+
if (!endpoint)
|
|
90
|
+
return null;
|
|
91
|
+
const serviceName = process.env.IRIS_OTEL_SERVICE_NAME ?? 'iris-mcp';
|
|
92
|
+
// IRIS_OTEL_HEADERS is a comma-separated list of key=value pairs —
|
|
93
|
+
// the OTel Collector convention. e.g. "authorization=Bearer xyz,x-tenant=foo".
|
|
94
|
+
let headers;
|
|
95
|
+
const rawHeaders = process.env.IRIS_OTEL_HEADERS;
|
|
96
|
+
if (rawHeaders) {
|
|
97
|
+
headers = {};
|
|
98
|
+
for (const pair of rawHeaders.split(',')) {
|
|
99
|
+
const eq = pair.indexOf('=');
|
|
100
|
+
if (eq < 1)
|
|
101
|
+
continue;
|
|
102
|
+
const k = pair.slice(0, eq).trim();
|
|
103
|
+
const v = pair.slice(eq + 1).trim();
|
|
104
|
+
if (k && v)
|
|
105
|
+
headers[k] = v;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
const timeoutRaw = process.env.IRIS_OTEL_TIMEOUT_MS;
|
|
109
|
+
const timeoutMs = timeoutRaw ? Number(timeoutRaw) : undefined;
|
|
110
|
+
return new OtelExporter({
|
|
111
|
+
endpoint,
|
|
112
|
+
serviceName,
|
|
113
|
+
headers,
|
|
114
|
+
timeoutMs: Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : undefined,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { OtelExporter } from './exporter.js';
|
|
2
|
+
import type { Trace } from '../types/trace.js';
|
|
3
|
+
export declare function getLazyExporter(): OtelExporter | null;
|
|
4
|
+
export declare function __resetExporterForTests(override?: OtelExporter | null): void;
|
|
5
|
+
export declare function bestEffortExport(trace: Trace, onError?: (err: Error) => void): void;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Process-wide lazy exporter — initialized on first use, then reused.
|
|
2
|
+
// Tests may reset it via __resetExporterForTests.
|
|
3
|
+
import { exporterFromEnv } from './exporter.js';
|
|
4
|
+
let cachedExporter; // undefined = not yet resolved
|
|
5
|
+
export function getLazyExporter() {
|
|
6
|
+
if (cachedExporter === undefined) {
|
|
7
|
+
cachedExporter = exporterFromEnv();
|
|
8
|
+
}
|
|
9
|
+
return cachedExporter;
|
|
10
|
+
}
|
|
11
|
+
export function __resetExporterForTests(override) {
|
|
12
|
+
cachedExporter = override === undefined ? undefined : override;
|
|
13
|
+
}
|
|
14
|
+
// Fire-and-forget wrapper with inline error swallow. Runs in the
|
|
15
|
+
// background; the caller does NOT await. We log on failure so operators
|
|
16
|
+
// aren't surprised by silent drop, but never let an exporter problem
|
|
17
|
+
// bubble into user-visible tool errors.
|
|
18
|
+
export function bestEffortExport(trace, onError) {
|
|
19
|
+
const exporter = getLazyExporter();
|
|
20
|
+
if (!exporter)
|
|
21
|
+
return;
|
|
22
|
+
exporter
|
|
23
|
+
.exportTraces([trace])
|
|
24
|
+
.then((result) => {
|
|
25
|
+
if (!result.ok) {
|
|
26
|
+
const msg = `OTel export failed: status=${result.status} ${result.error ?? ''}`.trim();
|
|
27
|
+
onError?.(new Error(msg));
|
|
28
|
+
}
|
|
29
|
+
})
|
|
30
|
+
.catch((err) => onError?.(err));
|
|
31
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Span, Trace } from '../types/trace.js';
|
|
2
|
+
export interface OtlpKeyValue {
|
|
3
|
+
key: string;
|
|
4
|
+
value: OtlpAnyValue;
|
|
5
|
+
}
|
|
6
|
+
export type OtlpAnyValue = {
|
|
7
|
+
stringValue: string;
|
|
8
|
+
} | {
|
|
9
|
+
boolValue: boolean;
|
|
10
|
+
} | {
|
|
11
|
+
intValue: string;
|
|
12
|
+
} | {
|
|
13
|
+
doubleValue: number;
|
|
14
|
+
} | {
|
|
15
|
+
arrayValue: {
|
|
16
|
+
values: OtlpAnyValue[];
|
|
17
|
+
};
|
|
18
|
+
} | {
|
|
19
|
+
kvlistValue: {
|
|
20
|
+
values: OtlpKeyValue[];
|
|
21
|
+
};
|
|
22
|
+
};
|
|
23
|
+
export declare function mapSpan(span: Span, traceIdOverride?: string): unknown;
|
|
24
|
+
export declare function buildExportPayload(traces: readonly Trace[], serviceName: string): unknown;
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
// Map Iris trace/span types to OTLP JSON shape for export.
|
|
2
|
+
//
|
|
3
|
+
// Reference: https://opentelemetry.io/docs/specs/otlp/#otlphttp-json-encoding
|
|
4
|
+
// and the ExportTraceServiceRequest proto. OTLP JSON encodes uint64 IDs
|
|
5
|
+
// as lowercase hex strings; trace_id is 16 bytes (32 hex chars), span_id
|
|
6
|
+
// is 8 bytes (16 hex chars). Timestamps are nanoseconds since epoch as
|
|
7
|
+
// strings (uint64 range exceeds JS Number.MAX_SAFE_INTEGER so we emit as
|
|
8
|
+
// decimal string).
|
|
9
|
+
//
|
|
10
|
+
// We intentionally do not depend on @opentelemetry/* — the exporter
|
|
11
|
+
// serializes these plain objects, and the payload shape is the OTLP spec.
|
|
12
|
+
// OTel SpanKind enum values (from opentelemetry-proto/trace/v1/trace.proto).
|
|
13
|
+
const KIND_MAP = {
|
|
14
|
+
INTERNAL: 1,
|
|
15
|
+
SERVER: 2,
|
|
16
|
+
CLIENT: 3,
|
|
17
|
+
PRODUCER: 4,
|
|
18
|
+
CONSUMER: 5,
|
|
19
|
+
// Iris-specific kinds — map to INTERNAL for OTel. The actual LLM /
|
|
20
|
+
// tool semantics are preserved in attributes (llm.provider, tool.name).
|
|
21
|
+
LLM: 1,
|
|
22
|
+
TOOL: 1,
|
|
23
|
+
};
|
|
24
|
+
const STATUS_MAP = {
|
|
25
|
+
UNSET: 0,
|
|
26
|
+
OK: 1,
|
|
27
|
+
ERROR: 2,
|
|
28
|
+
};
|
|
29
|
+
// Convert "abcdef0123..." (any length hex) to exactly 32 hex chars, or
|
|
30
|
+
// if non-hex, hash it. Iris sometimes generates IDs like "mcp-<uuid>"
|
|
31
|
+
// that aren't pure hex.
|
|
32
|
+
function toTraceIdHex(raw) {
|
|
33
|
+
const hex = raw.replace(/-/g, '').toLowerCase();
|
|
34
|
+
if (/^[0-9a-f]{32}$/.test(hex))
|
|
35
|
+
return hex;
|
|
36
|
+
if (/^[0-9a-f]{16,}$/.test(hex))
|
|
37
|
+
return hex.slice(0, 32).padEnd(32, '0');
|
|
38
|
+
// Fallback: hash to 32 hex chars deterministically.
|
|
39
|
+
return djb2Hex(raw, 32);
|
|
40
|
+
}
|
|
41
|
+
function toSpanIdHex(raw) {
|
|
42
|
+
const hex = raw.replace(/-/g, '').toLowerCase();
|
|
43
|
+
if (/^[0-9a-f]{16}$/.test(hex))
|
|
44
|
+
return hex;
|
|
45
|
+
if (/^[0-9a-f]{8,}$/.test(hex))
|
|
46
|
+
return hex.slice(0, 16).padEnd(16, '0');
|
|
47
|
+
return djb2Hex(raw, 16);
|
|
48
|
+
}
|
|
49
|
+
// Tiny deterministic string → hex hash. Not cryptographic — just to give
|
|
50
|
+
// OTel consumers a stable id when Iris produced a non-hex identifier.
|
|
51
|
+
function djb2Hex(s, hexLen) {
|
|
52
|
+
let h = 5381n;
|
|
53
|
+
for (let i = 0; i < s.length; i++) {
|
|
54
|
+
h = ((h << 5n) + h + BigInt(s.charCodeAt(i))) & ((1n << 64n) - 1n);
|
|
55
|
+
}
|
|
56
|
+
// Use BigInt to avoid JS Number collisions on long strings.
|
|
57
|
+
let hex = h.toString(16);
|
|
58
|
+
while (hex.length < hexLen)
|
|
59
|
+
hex = hex + djb2Step(s + hex).toString(16);
|
|
60
|
+
return hex.slice(0, hexLen).padEnd(hexLen, '0');
|
|
61
|
+
}
|
|
62
|
+
function djb2Step(s) {
|
|
63
|
+
let h = 5381n;
|
|
64
|
+
for (let i = 0; i < s.length; i++) {
|
|
65
|
+
h = ((h << 5n) + h + BigInt(s.charCodeAt(i))) & ((1n << 64n) - 1n);
|
|
66
|
+
}
|
|
67
|
+
return h;
|
|
68
|
+
}
|
|
69
|
+
function toNanoString(iso) {
|
|
70
|
+
if (!iso)
|
|
71
|
+
return '0';
|
|
72
|
+
const ms = new Date(iso).getTime();
|
|
73
|
+
if (!Number.isFinite(ms))
|
|
74
|
+
return '0';
|
|
75
|
+
// ms → ns as string (BigInt-safe).
|
|
76
|
+
return (BigInt(ms) * 1000000n).toString();
|
|
77
|
+
}
|
|
78
|
+
function toAnyValue(v) {
|
|
79
|
+
if (typeof v === 'string')
|
|
80
|
+
return { stringValue: v };
|
|
81
|
+
if (typeof v === 'boolean')
|
|
82
|
+
return { boolValue: v };
|
|
83
|
+
if (typeof v === 'number') {
|
|
84
|
+
if (Number.isInteger(v))
|
|
85
|
+
return { intValue: String(v) };
|
|
86
|
+
return { doubleValue: v };
|
|
87
|
+
}
|
|
88
|
+
if (Array.isArray(v)) {
|
|
89
|
+
return { arrayValue: { values: v.map(toAnyValue) } };
|
|
90
|
+
}
|
|
91
|
+
if (v && typeof v === 'object') {
|
|
92
|
+
return {
|
|
93
|
+
kvlistValue: {
|
|
94
|
+
values: Object.entries(v).map(([k, val]) => ({
|
|
95
|
+
key: k,
|
|
96
|
+
value: toAnyValue(val),
|
|
97
|
+
})),
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
// null / undefined / bigint / fn / symbol — fall back to string
|
|
102
|
+
return { stringValue: String(v) };
|
|
103
|
+
}
|
|
104
|
+
function flattenAttrs(attrs) {
|
|
105
|
+
if (!attrs)
|
|
106
|
+
return [];
|
|
107
|
+
return Object.entries(attrs).map(([k, v]) => ({ key: k, value: toAnyValue(v) }));
|
|
108
|
+
}
|
|
109
|
+
// Iris Span → OTLP Span JSON shape.
|
|
110
|
+
export function mapSpan(span, traceIdOverride) {
|
|
111
|
+
const attributes = flattenAttrs(span.attributes);
|
|
112
|
+
// Surface Iris-specific kinds via attributes so OTel consumers can
|
|
113
|
+
// filter on iris.span_kind even though we mapped everything non-OTel to INTERNAL.
|
|
114
|
+
if (span.kind === 'LLM' || span.kind === 'TOOL') {
|
|
115
|
+
attributes.push({ key: 'iris.span_kind', value: { stringValue: span.kind } });
|
|
116
|
+
}
|
|
117
|
+
const events = (span.events ?? []).map((e) => ({
|
|
118
|
+
timeUnixNano: toNanoString(e.timestamp),
|
|
119
|
+
name: e.name,
|
|
120
|
+
attributes: flattenAttrs(e.attributes),
|
|
121
|
+
}));
|
|
122
|
+
return {
|
|
123
|
+
traceId: toTraceIdHex(traceIdOverride ?? span.trace_id),
|
|
124
|
+
spanId: toSpanIdHex(span.span_id),
|
|
125
|
+
parentSpanId: span.parent_span_id ? toSpanIdHex(span.parent_span_id) : undefined,
|
|
126
|
+
name: span.name,
|
|
127
|
+
kind: KIND_MAP[span.kind] ?? 1,
|
|
128
|
+
startTimeUnixNano: toNanoString(span.start_time),
|
|
129
|
+
endTimeUnixNano: toNanoString(span.end_time ?? span.start_time),
|
|
130
|
+
attributes,
|
|
131
|
+
events,
|
|
132
|
+
status: {
|
|
133
|
+
code: STATUS_MAP[span.status_code] ?? 0,
|
|
134
|
+
message: span.status_message,
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
// Build a full OTLP ExportTraceServiceRequest payload from an Iris trace.
|
|
139
|
+
// If the trace has no explicit spans, we synthesize one root span from
|
|
140
|
+
// the trace-level fields (agent_name + latency + cost + token_usage as
|
|
141
|
+
// attributes) so exports aren't empty for simple trace rows.
|
|
142
|
+
export function buildExportPayload(traces, serviceName) {
|
|
143
|
+
const resource = {
|
|
144
|
+
attributes: [
|
|
145
|
+
{ key: 'service.name', value: { stringValue: serviceName } },
|
|
146
|
+
{ key: 'telemetry.sdk.name', value: { stringValue: 'iris-mcp' } },
|
|
147
|
+
{ key: 'telemetry.sdk.language', value: { stringValue: 'nodejs' } },
|
|
148
|
+
{ key: 'telemetry.sdk.version', value: { stringValue: '0.4.0' } },
|
|
149
|
+
],
|
|
150
|
+
};
|
|
151
|
+
const spans = [];
|
|
152
|
+
for (const trace of traces) {
|
|
153
|
+
if (trace.spans && trace.spans.length > 0) {
|
|
154
|
+
for (const s of trace.spans)
|
|
155
|
+
spans.push(mapSpan(s, trace.trace_id));
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
// Synthesize root span from trace-level info.
|
|
159
|
+
const attrs = {
|
|
160
|
+
'iris.agent_name': trace.agent_name,
|
|
161
|
+
'iris.framework': trace.framework,
|
|
162
|
+
};
|
|
163
|
+
if (trace.input)
|
|
164
|
+
attrs['iris.input'] = truncate(trace.input);
|
|
165
|
+
if (trace.output)
|
|
166
|
+
attrs['iris.output'] = truncate(trace.output);
|
|
167
|
+
if (trace.cost_usd !== undefined)
|
|
168
|
+
attrs['iris.cost_usd'] = trace.cost_usd;
|
|
169
|
+
if (trace.token_usage?.total_tokens !== undefined)
|
|
170
|
+
attrs['iris.total_tokens'] = trace.token_usage.total_tokens;
|
|
171
|
+
if (trace.token_usage?.prompt_tokens !== undefined)
|
|
172
|
+
attrs['iris.prompt_tokens'] = trace.token_usage.prompt_tokens;
|
|
173
|
+
if (trace.token_usage?.completion_tokens !== undefined)
|
|
174
|
+
attrs['iris.completion_tokens'] = trace.token_usage.completion_tokens;
|
|
175
|
+
const end = trace.latency_ms
|
|
176
|
+
? new Date(new Date(trace.timestamp).getTime() + trace.latency_ms).toISOString()
|
|
177
|
+
: trace.timestamp;
|
|
178
|
+
spans.push({
|
|
179
|
+
traceId: toTraceIdHex(trace.trace_id),
|
|
180
|
+
spanId: toSpanIdHex(trace.trace_id + ':root'),
|
|
181
|
+
parentSpanId: undefined,
|
|
182
|
+
name: trace.agent_name || 'agent_execution',
|
|
183
|
+
kind: 1,
|
|
184
|
+
startTimeUnixNano: toNanoString(trace.timestamp),
|
|
185
|
+
endTimeUnixNano: toNanoString(end),
|
|
186
|
+
attributes: flattenAttrs(attrs),
|
|
187
|
+
events: [],
|
|
188
|
+
status: { code: 0 },
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return {
|
|
193
|
+
resourceSpans: [
|
|
194
|
+
{
|
|
195
|
+
resource,
|
|
196
|
+
scopeSpans: [
|
|
197
|
+
{
|
|
198
|
+
scope: { name: 'iris.trace.v1' },
|
|
199
|
+
spans,
|
|
200
|
+
},
|
|
201
|
+
],
|
|
202
|
+
},
|
|
203
|
+
],
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
function truncate(s, max = 4096) {
|
|
207
|
+
return s.length > max ? s.slice(0, max) + '…' : s;
|
|
208
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
declare const MomentFiltersSchema: z.ZodObject<{
|
|
3
|
+
agentName: z.ZodOptional<z.ZodString>;
|
|
4
|
+
verdict: z.ZodOptional<z.ZodEnum<["pass", "fail", "partial", "unevaluated"]>>;
|
|
5
|
+
significanceKind: z.ZodOptional<z.ZodEnum<["safety-violation", "cost-spike", "first-failure", "novel-pattern", "rule-collision", "normal-pass", "normal-fail"]>>;
|
|
6
|
+
}, "strict", z.ZodTypeAny, {
|
|
7
|
+
verdict?: "pass" | "fail" | "partial" | "unevaluated" | undefined;
|
|
8
|
+
agentName?: string | undefined;
|
|
9
|
+
significanceKind?: "safety-violation" | "cost-spike" | "first-failure" | "novel-pattern" | "rule-collision" | "normal-pass" | "normal-fail" | undefined;
|
|
10
|
+
}, {
|
|
11
|
+
verdict?: "pass" | "fail" | "partial" | "unevaluated" | undefined;
|
|
12
|
+
agentName?: string | undefined;
|
|
13
|
+
significanceKind?: "safety-violation" | "cost-spike" | "first-failure" | "novel-pattern" | "rule-collision" | "normal-pass" | "normal-fail" | undefined;
|
|
14
|
+
}>;
|
|
15
|
+
export declare const PreferencesSchema: z.ZodObject<{
|
|
16
|
+
autoLaunch: z.ZodDefault<z.ZodBoolean>;
|
|
17
|
+
firstSeen: z.ZodOptional<z.ZodString>;
|
|
18
|
+
dismissedBanners: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
|
|
19
|
+
/** Theme override; "system" defers to prefers-color-scheme. v0.4. */
|
|
20
|
+
theme: z.ZodDefault<z.ZodEnum<["dark", "light", "system"]>>;
|
|
21
|
+
/** Last filter set used on /moments — applied on next visit when the URL has no filter params. v0.4. */
|
|
22
|
+
momentFilters: z.ZodDefault<z.ZodObject<{
|
|
23
|
+
agentName: z.ZodOptional<z.ZodString>;
|
|
24
|
+
verdict: z.ZodOptional<z.ZodEnum<["pass", "fail", "partial", "unevaluated"]>>;
|
|
25
|
+
significanceKind: z.ZodOptional<z.ZodEnum<["safety-violation", "cost-spike", "first-failure", "novel-pattern", "rule-collision", "normal-pass", "normal-fail"]>>;
|
|
26
|
+
}, "strict", z.ZodTypeAny, {
|
|
27
|
+
verdict?: "pass" | "fail" | "partial" | "unevaluated" | undefined;
|
|
28
|
+
agentName?: string | undefined;
|
|
29
|
+
significanceKind?: "safety-violation" | "cost-spike" | "first-failure" | "novel-pattern" | "rule-collision" | "normal-pass" | "normal-fail" | undefined;
|
|
30
|
+
}, {
|
|
31
|
+
verdict?: "pass" | "fail" | "partial" | "unevaluated" | undefined;
|
|
32
|
+
agentName?: string | undefined;
|
|
33
|
+
significanceKind?: "safety-violation" | "cost-spike" | "first-failure" | "novel-pattern" | "rule-collision" | "normal-pass" | "normal-fail" | undefined;
|
|
34
|
+
}>>;
|
|
35
|
+
/** Tour ids the user has completed or dismissed. v0.4. */
|
|
36
|
+
dismissedTours: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
|
|
37
|
+
/** Decision Moments hidden from the timeline by user action. v0.4. */
|
|
38
|
+
archivedMoments: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
|
|
39
|
+
/** Density mode for chrome (Design System v2.A). 'compact' default per R2.3. */
|
|
40
|
+
density: z.ZodDefault<z.ZodEnum<["compact", "comfortable"]>>;
|
|
41
|
+
/** Sidebar collapsed (icon-only at 64px) vs expanded (256px). Default expanded per R2.4. */
|
|
42
|
+
sidebarCollapsed: z.ZodDefault<z.ZodBoolean>;
|
|
43
|
+
/** ISO timestamp of last notifications-popover opened — drives unread badge. */
|
|
44
|
+
notificationsLastSeen: z.ZodOptional<z.ZodString>;
|
|
45
|
+
}, "passthrough", z.ZodTypeAny, z.objectOutputType<{
|
|
46
|
+
autoLaunch: z.ZodDefault<z.ZodBoolean>;
|
|
47
|
+
firstSeen: z.ZodOptional<z.ZodString>;
|
|
48
|
+
dismissedBanners: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
|
|
49
|
+
/** Theme override; "system" defers to prefers-color-scheme. v0.4. */
|
|
50
|
+
theme: z.ZodDefault<z.ZodEnum<["dark", "light", "system"]>>;
|
|
51
|
+
/** Last filter set used on /moments — applied on next visit when the URL has no filter params. v0.4. */
|
|
52
|
+
momentFilters: z.ZodDefault<z.ZodObject<{
|
|
53
|
+
agentName: z.ZodOptional<z.ZodString>;
|
|
54
|
+
verdict: z.ZodOptional<z.ZodEnum<["pass", "fail", "partial", "unevaluated"]>>;
|
|
55
|
+
significanceKind: z.ZodOptional<z.ZodEnum<["safety-violation", "cost-spike", "first-failure", "novel-pattern", "rule-collision", "normal-pass", "normal-fail"]>>;
|
|
56
|
+
}, "strict", z.ZodTypeAny, {
|
|
57
|
+
verdict?: "pass" | "fail" | "partial" | "unevaluated" | undefined;
|
|
58
|
+
agentName?: string | undefined;
|
|
59
|
+
significanceKind?: "safety-violation" | "cost-spike" | "first-failure" | "novel-pattern" | "rule-collision" | "normal-pass" | "normal-fail" | undefined;
|
|
60
|
+
}, {
|
|
61
|
+
verdict?: "pass" | "fail" | "partial" | "unevaluated" | undefined;
|
|
62
|
+
agentName?: string | undefined;
|
|
63
|
+
significanceKind?: "safety-violation" | "cost-spike" | "first-failure" | "novel-pattern" | "rule-collision" | "normal-pass" | "normal-fail" | undefined;
|
|
64
|
+
}>>;
|
|
65
|
+
/** Tour ids the user has completed or dismissed. v0.4. */
|
|
66
|
+
dismissedTours: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
|
|
67
|
+
/** Decision Moments hidden from the timeline by user action. v0.4. */
|
|
68
|
+
archivedMoments: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
|
|
69
|
+
/** Density mode for chrome (Design System v2.A). 'compact' default per R2.3. */
|
|
70
|
+
density: z.ZodDefault<z.ZodEnum<["compact", "comfortable"]>>;
|
|
71
|
+
/** Sidebar collapsed (icon-only at 64px) vs expanded (256px). Default expanded per R2.4. */
|
|
72
|
+
sidebarCollapsed: z.ZodDefault<z.ZodBoolean>;
|
|
73
|
+
/** ISO timestamp of last notifications-popover opened — drives unread badge. */
|
|
74
|
+
notificationsLastSeen: z.ZodOptional<z.ZodString>;
|
|
75
|
+
}, z.ZodTypeAny, "passthrough">, z.objectInputType<{
|
|
76
|
+
autoLaunch: z.ZodDefault<z.ZodBoolean>;
|
|
77
|
+
firstSeen: z.ZodOptional<z.ZodString>;
|
|
78
|
+
dismissedBanners: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
|
|
79
|
+
/** Theme override; "system" defers to prefers-color-scheme. v0.4. */
|
|
80
|
+
theme: z.ZodDefault<z.ZodEnum<["dark", "light", "system"]>>;
|
|
81
|
+
/** Last filter set used on /moments — applied on next visit when the URL has no filter params. v0.4. */
|
|
82
|
+
momentFilters: z.ZodDefault<z.ZodObject<{
|
|
83
|
+
agentName: z.ZodOptional<z.ZodString>;
|
|
84
|
+
verdict: z.ZodOptional<z.ZodEnum<["pass", "fail", "partial", "unevaluated"]>>;
|
|
85
|
+
significanceKind: z.ZodOptional<z.ZodEnum<["safety-violation", "cost-spike", "first-failure", "novel-pattern", "rule-collision", "normal-pass", "normal-fail"]>>;
|
|
86
|
+
}, "strict", z.ZodTypeAny, {
|
|
87
|
+
verdict?: "pass" | "fail" | "partial" | "unevaluated" | undefined;
|
|
88
|
+
agentName?: string | undefined;
|
|
89
|
+
significanceKind?: "safety-violation" | "cost-spike" | "first-failure" | "novel-pattern" | "rule-collision" | "normal-pass" | "normal-fail" | undefined;
|
|
90
|
+
}, {
|
|
91
|
+
verdict?: "pass" | "fail" | "partial" | "unevaluated" | undefined;
|
|
92
|
+
agentName?: string | undefined;
|
|
93
|
+
significanceKind?: "safety-violation" | "cost-spike" | "first-failure" | "novel-pattern" | "rule-collision" | "normal-pass" | "normal-fail" | undefined;
|
|
94
|
+
}>>;
|
|
95
|
+
/** Tour ids the user has completed or dismissed. v0.4. */
|
|
96
|
+
dismissedTours: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
|
|
97
|
+
/** Decision Moments hidden from the timeline by user action. v0.4. */
|
|
98
|
+
archivedMoments: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
|
|
99
|
+
/** Density mode for chrome (Design System v2.A). 'compact' default per R2.3. */
|
|
100
|
+
density: z.ZodDefault<z.ZodEnum<["compact", "comfortable"]>>;
|
|
101
|
+
/** Sidebar collapsed (icon-only at 64px) vs expanded (256px). Default expanded per R2.4. */
|
|
102
|
+
sidebarCollapsed: z.ZodDefault<z.ZodBoolean>;
|
|
103
|
+
/** ISO timestamp of last notifications-popover opened — drives unread badge. */
|
|
104
|
+
notificationsLastSeen: z.ZodOptional<z.ZodString>;
|
|
105
|
+
}, z.ZodTypeAny, "passthrough">>;
|
|
106
|
+
export type Preferences = z.infer<typeof PreferencesSchema>;
|
|
107
|
+
export type MomentFilters = z.infer<typeof MomentFiltersSchema>;
|
|
108
|
+
export interface PreferenceState {
|
|
109
|
+
/** True if `~/.iris/preferences.json` did not exist when iris-mcp started. */
|
|
110
|
+
isFirstRun: boolean;
|
|
111
|
+
/** Resolved preferences (defaulted + validated). */
|
|
112
|
+
preferences: Preferences;
|
|
113
|
+
/** Absolute path to the preferences file. */
|
|
114
|
+
path: string;
|
|
115
|
+
}
|
|
116
|
+
export declare function loadOrInitPreferences(customPath?: string): PreferenceState;
|
|
117
|
+
export declare function shouldAutoLaunchDashboard(state: PreferenceState): boolean;
|
|
118
|
+
export interface PreferenceStore {
|
|
119
|
+
readonly path: string;
|
|
120
|
+
/** Current preferences (always defaulted + validated). */
|
|
121
|
+
read(): Preferences;
|
|
122
|
+
/**
|
|
123
|
+
* Apply a partial update + persist atomically. Returns the merged result.
|
|
124
|
+
* Throws if the merge fails Zod validation (e.g., illegal verdict value).
|
|
125
|
+
*/
|
|
126
|
+
patch(input: Partial<Preferences>): Preferences;
|
|
127
|
+
}
|
|
128
|
+
export declare function createPreferenceStore(customPath?: string): PreferenceStore;
|
|
129
|
+
export {};
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* preferences — first-run + per-user dashboard preferences for iris-mcp.
|
|
3
|
+
*
|
|
4
|
+
* State lives at ~/.iris/preferences.json. The file is created on first
|
|
5
|
+
* dashboard launch with `autoLaunch: true` and `firstSeen` set to the
|
|
6
|
+
* current ISO timestamp.
|
|
7
|
+
*
|
|
8
|
+
* Subsequent runs read the file and respect the user's autoLaunch setting.
|
|
9
|
+
* The env var IRIS_NO_AUTO_LAUNCH=1 overrides preferences and disables
|
|
10
|
+
* auto-launch unconditionally (useful for CI, headless servers, container
|
|
11
|
+
* runs, and remote SSH sessions).
|
|
12
|
+
*
|
|
13
|
+
* The file format is intentionally small — additional preferences will be
|
|
14
|
+
* added as the dashboard grows; the schema validates known keys but
|
|
15
|
+
* tolerates unknown ones forward-compatibly.
|
|
16
|
+
*
|
|
17
|
+
* v0.4 expansions (B8.2): theme, momentFilters (last filter set used on
|
|
18
|
+
* /moments), dismissedTours, archivedMoments. These let the dashboard
|
|
19
|
+
* remember the user's last view across reloads + across iris-mcp restarts.
|
|
20
|
+
*/
|
|
21
|
+
import { mkdirSync, readFileSync, writeFileSync, existsSync, renameSync } from 'node:fs';
|
|
22
|
+
import { join, dirname } from 'node:path';
|
|
23
|
+
import { homedir } from 'node:os';
|
|
24
|
+
import { z } from 'zod';
|
|
25
|
+
const MomentFiltersSchema = z
|
|
26
|
+
.object({
|
|
27
|
+
agentName: z.string().max(200).optional(),
|
|
28
|
+
verdict: z.enum(['pass', 'fail', 'partial', 'unevaluated']).optional(),
|
|
29
|
+
significanceKind: z
|
|
30
|
+
.enum([
|
|
31
|
+
'safety-violation',
|
|
32
|
+
'cost-spike',
|
|
33
|
+
'first-failure',
|
|
34
|
+
'novel-pattern',
|
|
35
|
+
'rule-collision',
|
|
36
|
+
'normal-pass',
|
|
37
|
+
'normal-fail',
|
|
38
|
+
])
|
|
39
|
+
.optional(),
|
|
40
|
+
})
|
|
41
|
+
.strict();
|
|
42
|
+
export const PreferencesSchema = z
|
|
43
|
+
.object({
|
|
44
|
+
autoLaunch: z.boolean().default(true),
|
|
45
|
+
firstSeen: z.string().datetime({ offset: true }).optional(),
|
|
46
|
+
dismissedBanners: z.array(z.string().max(80)).default([]),
|
|
47
|
+
/** Theme override; "system" defers to prefers-color-scheme. v0.4. */
|
|
48
|
+
theme: z.enum(['dark', 'light', 'system']).default('system'),
|
|
49
|
+
/** Last filter set used on /moments — applied on next visit when the URL has no filter params. v0.4. */
|
|
50
|
+
momentFilters: MomentFiltersSchema.default({}),
|
|
51
|
+
/** Tour ids the user has completed or dismissed. v0.4. */
|
|
52
|
+
dismissedTours: z.array(z.string().max(80)).default([]),
|
|
53
|
+
/** Decision Moments hidden from the timeline by user action. v0.4. */
|
|
54
|
+
archivedMoments: z.array(z.string().max(200)).default([]),
|
|
55
|
+
/** Density mode for chrome (Design System v2.A). 'compact' default per R2.3. */
|
|
56
|
+
density: z.enum(['compact', 'comfortable']).default('compact'),
|
|
57
|
+
/** Sidebar collapsed (icon-only at 64px) vs expanded (256px). Default expanded per R2.4. */
|
|
58
|
+
sidebarCollapsed: z.boolean().default(false),
|
|
59
|
+
/** ISO timestamp of last notifications-popover opened — drives unread badge. */
|
|
60
|
+
notificationsLastSeen: z.string().datetime({ offset: true }).optional(),
|
|
61
|
+
})
|
|
62
|
+
.passthrough();
|
|
63
|
+
function defaultPreferencesPath() {
|
|
64
|
+
return join(homedir(), '.iris', 'preferences.json');
|
|
65
|
+
}
|
|
66
|
+
function freshPreferences() {
|
|
67
|
+
return PreferencesSchema.parse({
|
|
68
|
+
autoLaunch: true,
|
|
69
|
+
firstSeen: new Date().toISOString(),
|
|
70
|
+
dismissedBanners: [],
|
|
71
|
+
theme: 'system',
|
|
72
|
+
momentFilters: {},
|
|
73
|
+
dismissedTours: [],
|
|
74
|
+
archivedMoments: [],
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
function writeAtomic(targetPath, contents) {
|
|
78
|
+
mkdirSync(dirname(targetPath), { recursive: true });
|
|
79
|
+
const tmp = `${targetPath}.tmp.${process.pid}`;
|
|
80
|
+
writeFileSync(tmp, contents, 'utf-8');
|
|
81
|
+
renameSync(tmp, targetPath);
|
|
82
|
+
}
|
|
83
|
+
export function loadOrInitPreferences(customPath) {
|
|
84
|
+
const path = customPath ?? defaultPreferencesPath();
|
|
85
|
+
if (!existsSync(path)) {
|
|
86
|
+
const fresh = freshPreferences();
|
|
87
|
+
try {
|
|
88
|
+
writeAtomic(path, JSON.stringify(fresh, null, 2));
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
// Filesystem may be read-only (containerized run); we still proceed
|
|
92
|
+
// with the defaults in memory but isFirstRun stays true so callers
|
|
93
|
+
// can decide whether to auto-launch anyway.
|
|
94
|
+
}
|
|
95
|
+
return { isFirstRun: true, preferences: fresh, path };
|
|
96
|
+
}
|
|
97
|
+
try {
|
|
98
|
+
const raw = readFileSync(path, 'utf-8');
|
|
99
|
+
const parsed = PreferencesSchema.safeParse(JSON.parse(raw));
|
|
100
|
+
if (parsed.success) {
|
|
101
|
+
return { isFirstRun: false, preferences: parsed.data, path };
|
|
102
|
+
}
|
|
103
|
+
// Malformed file — fall back to defaults but DON'T overwrite the
|
|
104
|
+
// existing file. The user may want to fix it manually.
|
|
105
|
+
return {
|
|
106
|
+
isFirstRun: false,
|
|
107
|
+
preferences: PreferencesSchema.parse({}),
|
|
108
|
+
path,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return {
|
|
113
|
+
isFirstRun: false,
|
|
114
|
+
preferences: PreferencesSchema.parse({}),
|
|
115
|
+
path,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
export function shouldAutoLaunchDashboard(state) {
|
|
120
|
+
if (process.env.IRIS_NO_AUTO_LAUNCH === '1')
|
|
121
|
+
return false;
|
|
122
|
+
if (process.env.CI === 'true' || process.env.CI === '1')
|
|
123
|
+
return false;
|
|
124
|
+
return state.preferences.autoLaunch !== false;
|
|
125
|
+
}
|
|
126
|
+
export function createPreferenceStore(customPath) {
|
|
127
|
+
const path = customPath ?? defaultPreferencesPath();
|
|
128
|
+
let current = loadOrInitPreferences(path).preferences;
|
|
129
|
+
return {
|
|
130
|
+
path,
|
|
131
|
+
read() {
|
|
132
|
+
return current;
|
|
133
|
+
},
|
|
134
|
+
patch(input) {
|
|
135
|
+
const merged = { ...current, ...input };
|
|
136
|
+
// Re-parse so default coalescing + validation runs, and so passthrough
|
|
137
|
+
// forward-compat preserves any unknown keys present in the file.
|
|
138
|
+
const validated = PreferencesSchema.parse(merged);
|
|
139
|
+
try {
|
|
140
|
+
writeAtomic(path, JSON.stringify(validated, null, 2));
|
|
141
|
+
}
|
|
142
|
+
catch (err) {
|
|
143
|
+
// If persist fails (read-only fs, disk full), still update memory
|
|
144
|
+
// so the UI feels responsive — but rethrow so the API surfaces it.
|
|
145
|
+
current = validated;
|
|
146
|
+
throw err;
|
|
147
|
+
}
|
|
148
|
+
current = validated;
|
|
149
|
+
return validated;
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
}
|