diff --git a/.claude/skills/darkhunt-telemetry-integration/SKILL.md b/.claude/skills/darkhunt-telemetry-integration/SKILL.md index 9ad5ac3..a1eaf69 100644 --- a/.claude/skills/darkhunt-telemetry-integration/SKILL.md +++ b/.claude/skills/darkhunt-telemetry-integration/SKILL.md @@ -58,29 +58,52 @@ Key shapes: Node `^18.19.0 || >=20.6.0` (the floor set by its OpenTelemetry deps). Before adding the dependency, verify the target project: -- `package.json` has `"type": "module"` (or the project is otherwise ESM, - e.g. via `.mts` files). If the project is CommonJS, stop and tell the - user the SDK won't `require()` cleanly — they need to migrate to ESM or - use dynamic `import()` from a CJS wrapper, neither of which the agent - should do silently. - `package.json` `engines.node` (or the project's CI matrix) satisfies `^18.19.0 || >=20.6.0`. If the project pins an older Node (< 18.19), flag it and ask before bumping. - -Once both check out: +- **CommonJS is NOT an automatic blocker — check the Node version first.** + The SDK is ESM-only, but a CommonJS project (`"type": "commonjs"`, + tsconfig `module: commonjs`) can still `require()` / `import` it **on + Node ≥ 22.12 (and 23+)**, where Node's `require(ESM)` is on by default — + _provided the SDK's `dist` has no top-level await_ (it currently doesn't, + so a plain `import { DarkhuntTelemetry }` compiled to `require()` loads + fine, e.g. under `ts-node` on Node 25). Verified against this exact CJS + setup. So: + - **Node ≥ 22.12:** proceed even if the project is CommonJS. Don't refuse + up front — wire it and _actually run it once_ to confirm the load (a + no-TLA guarantee isn't forever). Only if the load fails do you need a + dynamic-`import()` wrapper or an ESM migration. + - **Node < 22.12 AND CommonJS:** _then_ the old caution applies — + `require()` of the ESM package throws `ERR_REQUIRE_ESM`. Stop and tell + the user; they need to migrate to ESM or use dynamic `import()` from a + CJS wrapper, neither of which the agent should do silently. + - Native `"type": "module"` / `.mts` projects: always fine. + +Once those check out: ```bash npm install @darkhunt-security/telemetry ``` -Pin a recent published build in `package.json`: +**Where the package lives.** It's the scoped package `@darkhunt-security/telemetry`, +published to **GitHub Packages** — browse published versions / builds at +. It also +resolves from the default public npm registry (`npm install` above works with no +`.npmrc` scope config — verified). If a consumer is pinned to GitHub Packages instead, +they'll have a `@darkhunt-security:registry=https://npm.pkg.github.com` line in `.npmrc`. + +**Don't hardcode a version from this doc — look up the current one.** Builds ship +continuously (the `-build.N` suffix is the CI run), so any number written here goes stale. +Get the latest from the source of truth and pin what you actually installed: -```json -"@darkhunt-security/telemetry": "^0.5.0-build.18" +```bash +npm view @darkhunt-security/telemetry version # → current published build to pin +# or browse the GitHub Packages page linked above ``` -(Match whatever version the user's organisation publishes through CI; the -`-build.N` suffix reflects the CI run.) +Then pin exactly that in `package.json`, e.g. `"@darkhunt-security/telemetry": +"^"`. Match whatever version the user's organisation publishes through +CI. ### 2. Get an API key @@ -122,6 +145,17 @@ the public endpoint (`internal: false` and `enabled`), the constructor throws: `apiKey is required for the public endpoint (pass via options, set DARKHUNT_API_KEY, or use internal: true)`. +> **The key, the base URL, and the tenant must all be the same environment.** +> A `dh-` key is scoped to one environment's tenant (prod / UAT / a dev like +> seth-dev). A key from one environment sent to another environment's host +> authenticates against the wrong tenant → **401** (or spans silently routed +> nowhere). Real trap: a shell with both a prod/UAT key in `~/.zshrc` (e.g. +> `DARKHUNT_API_KEY_UAT`) _and_ a seth-dev key in `~/.darkhunt/credentials.json` +> — pick the key, `DARKHUNT_BASE_URL`, and `DARKHUNT_TENANT_ID` from the **same** +> source/environment. When you enroll (step 2b), `~/.darkhunt/credentials.json` +> holds a matched `{ apiKey, apiBaseUrl, tenantId }` set — prefer that trio +> together rather than mixing an env-var key with a creds-file base URL. + The base URL defaults to `https://api.darkhunt.ai/trace-hub`; override it via the `DARKHUNT_BASE_URL` env var or the `baseUrl` option when pointing at a self-hosted / staging / dev trace-hub. **Two rules when you override it:** use the @@ -129,6 +163,13 @@ self-hosted / staging / dev trace-hub. **Two rules when you override it:** use t which redirects POSTs → 405); and **keep the `/trace-hub` path** — the exporter posts to `{baseUrl}/otlp/t/{tenantId}/v1/traces`, so dropping `/trace-hub` yields 404. Example (dev): `DARKHUNT_BASE_URL=https://api-seth-dev.darkhunt.ai/trace-hub`. +> **Gotcha: the enrolled `credentials.json` `apiBaseUrl` has NO `/trace-hub` +> suffix.** `~/.darkhunt/credentials.json` stores the bare host (e.g. +> `https://api-seth-dev.darkhunt.ai`). If you source `DARKHUNT_BASE_URL` from +> that field you **must append `/trace-hub` yourself** — otherwise the exporter +> POSTs to `{host}/otlp/...` and gets a 404. (i.e. `DARKHUNT_BASE_URL = +"$(jq -r .apiBaseUrl ~/.darkhunt/credentials.json)/trace-hub"`.) + Set **`serviceName`** (option, or `DARKHUNT_SERVICE_NAME` / `OTEL_SERVICE_NAME`) to the OTel Resource `service.name`. The backend records it per span, so in a multi-service / multi-agent system give **each process its own** value (e.g. `weather.coordinator`, `weather.geodata`) to tell @@ -141,27 +182,59 @@ fields the backend uses to show the actual tool (e.g. "geocode") rather than the ### 2b. Create an application (get the `applicationId`) -Every trace needs an `applicationId` — a **workspace-scoped UUID**. Create one (or reuse an existing -one) before wiring the client. - -**CLI path (recommended).** App creation lives in the `darkhunt-cli`'s **MCP tools**, not a bare -subcommand (the CLI's own commands are `scan` / `playground` / `target init`). The CLI ships an MCP -server (`darkhunt-cli mcp`) that an AI client (e.g. Claude Code) drives, reusing the credentials from -`enroll`: +Every trace needs an `applicationId` — a **workspace-scoped UUID**. **Create a new, dedicated +OBSERVABILITY application for this integration** before wiring the client. **Do not reuse an +existing `applicationId`** — mixing a fresh integration's traces into someone else's / a +pre-existing app pollutes that app's scope and makes the new traces hard to find. (The only time +to reuse is when the user explicitly points you at a specific existing app to send to.) + +> **Tool precedence: Darkhunt MCP → `darkhunt-cli` → raw REST (last resort).** Reach for the +> **Darkhunt MCP first**, at the very start of the task — it's the intended, supported interface +> and reuses enrolled credentials. **We highly recommend installing the `darkhunt-cli` for a smooth +> integration** — it isn't just a fallback: it's what `enroll`s your credentials +> (`~/.darkhunt/credentials.json`) _and_ serves the MCP itself (`darkhunt-cli mcp`), so it underpins +> the whole MCP-first path. Only if the MCP is unavailable, use the `darkhunt-cli` directly; only if +> _both_ are unavailable should you fall back to hitting the `…/workflow-manager/api/…` REST +> endpoints with curl. **Don't silently drop to raw REST** because listing/creating an app "seems +> easier" — that's off-pattern and brittle. If neither MCP nor CLI is reachable, say so and ask +> before curling the API directly. + +**MCP path (primary — do this first).** App creation lives in the Darkhunt **MCP tools**, not a bare +`darkhunt-cli` subcommand (the CLI's own commands are `enroll` / `scan` / `corpus` / `datasets` / +`attack-libraries` / `target` / `playground` / `update` / `mcp` — none of them create an app). The CLI +ships the MCP server (`darkhunt-cli mcp`) that an AI client (e.g. Claude Code) drives, reusing the +credentials from `enroll`. + +**Where to get `darkhunt-cli`.** Install it from the official installer releases: + — grab the build for +the user's platform (or run the installer script from that repo). Once installed, `darkhunt-cli` +is on `PATH` and both `enroll` and `mcp` (below) work. If the `darkhunt-cli` command isn't found, +that releases page is where it comes from — point the user there rather than assuming it's an +`npm i -g` package. + +**Check the MCP is connected before anything else.** In Claude Code, look for `darkhunt_*` tools; if +they're absent, the server isn't registered in this session. **Offer to wire it up** rather than +routing around it: ```bash -# 1. Enroll once in a terminal — keeps your dh- key OUT of the chat transcript. -# Tenant is looked up from the key; add --tenant only if the key spans multiple tenants. +# One-time terminal step — keeps your dh- key OUT of the chat transcript. +# Tenant is looked up from the key; add --tenant only if the key spans multiple tenants. darkhunt-cli enroll --api-key dh-... # → ~/.darkhunt/credentials.json + +# Register the MCP server for the project (then RELOAD the session — MCP tools +# load at startup, so the darkhunt_* tools appear only after a restart): +claude mcp add darkhunt -- darkhunt-cli mcp ``` +Once the `darkhunt_*` tools are available (auth reused from enroll): + ```text -# 2. Point your AI client at `darkhunt-cli mcp`, then (auth reused from enroll): darkhunt_status # confirm auth + tenant + reachable API darkhunt_list_workspaces # → pick your workspaceId (UUID) darkhunt_create_application { workspaceId, name, type: 'OBSERVABILITY', description? } - # → returns the new application's UUID -darkhunt_list_applications # (optional) list existing apps + their UUIDs to reuse one + # → returns the NEW application's UUID (use this) +darkhunt_list_applications # only to sanity-check naming / avoid a duplicate — NOT to grab + # and reuse a random existing app's UUID ``` - **`type` defaults to `RED_TEAM`** (a connector-less app for adversarial scanning). For a telemetry @@ -172,12 +245,13 @@ darkhunt_list_applications # (optional) list existing apps + their UUIDs to r set e.g. `DARKHUNT_APP_WEATHER=`, plumbing the right one into each process's `DARKHUNT_APPLICATION_ID`. -**Dashboard path.** Open **app.darkhunt.ai** → the **Get started / Applications** area → create an -application (the new-app / RedTeamWizard flow) → copy its `applicationId` (alongside your `tenantId` and -`workspaceId`). Use this if you'd rather click than script; the CLI/MCP path is better for reproducible, -multi-app setups. +**Dashboard path (fallback if MCP+CLI are both unavailable).** Open **app.darkhunt.ai** → the **Get +started / Applications** area → create a **new** application (the new-app / RedTeamWizard flow) → copy +its `applicationId` (alongside your `tenantId` and `workspaceId`). Use this if you'd rather click than +script; the MCP path is better for reproducible, multi-app setups. -Either way, `applicationId` + `tenantId` + `workspaceId` are the routing fields the client needs next. +Either way, the **newly created** `applicationId` + `tenantId` + `workspaceId` are the routing fields +the client needs next. ### 3. Singleton client (process-wide) @@ -275,10 +349,11 @@ function openGeneration( } ``` -**Critical: capture `startTime = Date.now()` BEFORE any awaited LLM call.** -Without `startTime`, the OTel span starts at construction time (post-LLM-call), -so the recorded duration covers only ~0ms of bookkeeping instead of actual -LLM time. Same applies to the trace root span. +**Critical: capture `startTime = Date.now()` BEFORE any awaited LLM call** — _if_ you use the +manual `trace.generation()` form. Without `startTime`, the OTel span starts at construction time +(post-LLM-call), so the recorded duration covers only ~0ms of bookkeeping instead of actual LLM +time. Same applies to the trace root span. **Better: prefer the active-context form (§6b), which +times the span automatically and needs no `startTime`.** ### 6. End span with payload @@ -302,6 +377,56 @@ trace.end(); finishes. You can pass everything to `end()` if there's no streaming intermediate state to record. +### 6b. Prefer the active-context form (interop + automatic timing) + +The manual `trace.generation(name, { startTime })` → `.end()` pattern works, but the span is never +the **active** OTel span, so (a) you must hand-capture `startTime`, and (b) third-party OTel +auto-instrumentation (the LLM SDK's own spans, an HTTP client) can't nest under it — you get a +walled-off tree. Prefer **`startActiveGeneration`** (and **`startActiveSpan`** for tool / +retriever / guardrail spans): it runs your callback with the span **active** for the awaited call, +ends it when the callback settles (marking ERROR on a throw), and times it automatically. + +```ts +const answer = await trace.startActiveGeneration('answer', { model }, async (gen) => { + gen.update({ inputMessages }); // known at start + const r = await llm(prompt); // span is ACTIVE here → auto-instrumentation nests; timing is real + gen.end({ model, outputMessages: r.messages, usage: r.usage }); + return r.text; // becomes the return value of startActiveGeneration +}); +``` + +No `startTime` to capture, and anything OTel-instrumented inside the callback nests under the +generation. `Trace.startActiveSpan(name, opts, fn)` / `Span.startActiveSpan(...)` are the same for +non-LLM spans. (The manual `trace.generation()` / `trace.span()` factories still exist for +streaming, or when you must hold a span open across separate calls.) + +**Strict-TypeScript projects: two `tsc` errors the snippets above can trigger.** +Many host projects compile with `exactOptionalPropertyTypes` and +`noPropertyAccessFromIndexSignature` (both on in the Anthropic SDK repo, for +example). Two adjustments keep the integration clean: + +- **`exactOptionalPropertyTypes` rejects assigning `undefined` to an optional + field.** So `usage: { input_tokens, output_tokens, cache_read_tokens: +maybeUndefined }` fails to typecheck — build the object and add the optional + cache fields **conditionally** instead of assigning `undefined`: + + ```ts + const usage: { + input_tokens: number; + output_tokens: number; + cache_read_tokens?: number; + cache_creation_tokens?: number; + } = { input_tokens: u.input_tokens, output_tokens: u.output_tokens }; + if (u.cache_read_input_tokens != null) usage.cache_read_tokens = u.cache_read_input_tokens; + if (u.cache_creation_input_tokens != null) + usage.cache_creation_tokens = u.cache_creation_input_tokens; + generation.end({ model, outputMessages, usage }); + ``` + +- **`noPropertyAccessFromIndexSignature` forbids dotted access on `process.env`.** + Read env vars with bracket syntax: `process.env['NODE_ENV']`, not + `process.env.NODE_ENV`. + ## Routing fields Every span carries three required routing attributes — `tenantId`, @@ -408,6 +533,18 @@ drop the rest" pattern. Spans nest naturally — `parent.span(...)` makes the child a child in the trace tree. +### Recipe: non-chat provider calls → observation types + +The table above is generic; here's the worked mapping for the common non-chat +calls in an SDK-examples repo (verified against the OpenAI SDK): + +| Provider call | Observation | Notes | +| --------------------------------------------------------------------------------- | --------------------------------------------------------- | ------------------------------------------------------------------------------------------ | +| Embeddings | `trace.span(name, { observationType: 'embedding' })` | A span has **no** `model` / `usage` field — put the model name + token count in `metadata` | +| Content moderation | `trace.span(name, { observationType: 'guardrail' })` | Set `level: 'WARNING'` + `statusMessage` **only when flagged**; omit `level` otherwise | +| Image generation (DALL·E etc.) | `trace.generation(name, { model, input: prompt })` | It produces content → a generation; `output` = the image URL; **no `usage`** to send | +| Management / async calls (assistant create, fine-tune job, batch submit/retrieve) | `trace.span(name, { observationType: 'tool', toolName })` | These don't run inference themselves (the work is async) — record them as tool spans | + ## Supported fields — SDK ↔ trace-hub mapping This is the canonical list of what the SDK can emit and what trace-hub @@ -445,15 +582,15 @@ OTel resource attributes auto-set by the SDK (read by trace-hub as Set on `trace.span(name, opts)` / `trace.generation(name, opts)` / via `.update(opts)` / `.end(opts)`. -| SDK option | OTel attribute emitted | trace-hub field | Notes | -| ----------------- | ------------------------------------- | -------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | -| `observationType` | `darkhunt.observation.type` | `span.type` | one of `span` / `tool` / `agent` / `generation` / `event` / `chain` / `retriever` / `evaluator` / `embedding` / `guardrail` | -| `input` | `darkhunt.observation.input` | `content.input` | masked; objects walked recursively | -| `output` | `darkhunt.observation.output` | `content.output` | masked | -| `level` | `darkhunt.observation.level` | `span.level` | `'DEFAULT'` / `'DEBUG'` / `'WARNING'` / `'ERROR'` | -| `statusMessage` | OTel `setStatus({ message })` | `error.message` (`_span_status`) | masked; sets ERROR status when paired with `level: 'ERROR'` | -| `version` | `darkhunt.version` | `span.version` | | -| `metadata` | `darkhunt.observation.metadata.` | `span.metadata.` | one OTel attr per key — never a single JSON blob (backend can't iterate) | +| SDK option | OTel attribute emitted | trace-hub field | Notes | +| ----------------- | ------------------------------------- | -------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `observationType` | `darkhunt.observation.type` | `span.type` | one of `span` / `tool` / `agent` / `generation` / `event` / `chain` / `retriever` / `evaluator` / `embedding` / `guardrail` | +| `input` | `darkhunt.observation.input` | `content.input` | masked; objects walked recursively | +| `output` | `darkhunt.observation.output` | `content.output` | masked | +| `level` | `darkhunt.observation.level` | `span.level` | SDK `ObservationLevel` = `'DEBUG'` / `'INFO'` / `'WARNING'` / `'ERROR'` (NOT `'DEFAULT'`). **Omit** `level` to get the backend's default; passing `'DEFAULT'` is not a valid SDK value. | +| `statusMessage` | OTel `setStatus({ message })` | `error.message` (`_span_status`) | masked; sets ERROR status when paired with `level: 'ERROR'` | +| `version` | `darkhunt.version` | `span.version` | | +| `metadata` | `darkhunt.observation.metadata.` | `span.metadata.` | one OTel attr per key — never a single JSON blob (backend can't iterate) | ### Generation-only fields @@ -476,6 +613,13 @@ These add to the span fields above. | `promptName` | `darkhunt.observation.prompt.name` | `prompt.name` | | `promptVersion` | `darkhunt.observation.prompt.version` | `prompt.version` | +> **You usually don't need to pass `cost`.** trace-hub auto-prices a generation +> from `model` + `usage` for known models — send an accurate `model` and +> `usage.{input,output,cache_*}_tokens` and the dashboard shows a computed dollar +> cost (verified: a generation sent with usage but _no_ `cost` rendered `$0.0002`). +> Only set `cost` explicitly for custom / self-hosted / unpriced models the +> backend can't price on its own. + ### Known gaps (backend reads, SDK doesn't yet emit) Don't try to use these from SDK code — there's no public API. If the user @@ -496,31 +640,161 @@ dashboard: but the YAML mapping reads only `_span_status` (the OTel native). The redundant attr is dead weight today. +## Instrumenting a sample / OSS / multi-script repo + +The reference integration (`attack-discovery`) is a single always-configured +service. A **public SDK-examples / quickstart repo** is a different archetype — +it must still run for a user who has _no_ Darkhunt account, and it's usually a +folder of many independent one-shot entry scripts rather than one server. Two +patterns make that clean. + +### Opt-in / graceful degradation — never crash the host app + +The client is **not** safe to construct unconditionally here: on the public +endpoint the constructor **throws** if `apiKey` is missing, and `dh.trace()` +**throws** if any routing field is missing. So a bare `new DarkhuntTelemetry()` +in a sample script breaks `node examples/foo.js` for anyone who just wants to +try the underlying SDK. Gate on config presence and no-op when absent: + +```ts +const REQUIRED = [ + 'DARKHUNT_API_KEY', + 'DARKHUNT_TENANT_ID', + 'DARKHUNT_WORKSPACE_ID', + 'DARKHUNT_APPLICATION_ID', +]; +const enabled = + process.env['DARKHUNT_ENABLED'] !== 'false' && REQUIRED.every((k) => !!process.env[k]); + +// Returns null when unconfigured; callers optional-chain so the demo still runs. +export function startTrace(name, args) { + if (!enabled) return null; + return getClient().trace({ name, ...args }); +} +``` + +Then every call site uses optional chaining and the OpenAI/Anthropic demo runs +untouched when Darkhunt isn't set up: + +```ts +const startTime = Date.now(); +const trace = startTrace('chat.basic'); +const gen = trace?.generation('answer', { model, startTime }); +gen?.update({ inputMessages }); +// ...real LLM call... +gen?.end({ model, outputMessages, usage }); +trace?.end(); +await flushTelemetry(); // no-op when disabled +``` + +### One service.name per example script + +The OTel Resource (`service.name`) is **per-`TracerProvider`, i.e. per process** +— and each example is its own process. So derive a distinct `serviceName` from +the entry script and every example becomes its own topology node, with **zero** +per-file edits: + +```ts +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +const ROOT = path.dirname(fileURLToPath(import.meta.url)); + +function deriveServiceName() { + if (process.env['DARKHUNT_SERVICE_NAME']) return process.env['DARKHUNT_SERVICE_NAME']; + const entry = process.argv[1]; + if (!entry) return 'my-sdk-examples'; // e.g. `node -e ...` → argv[1] is undefined + const rel = path.relative(ROOT, entry); + if (!rel || rel.startsWith('..')) return 'my-sdk-examples'; + const parts = rel + .replace(/\.[cm]?js$/i, '') + .split(path.sep) + .filter((s) => s && s !== 'index'); + return ['my-sdk-examples', ...parts].join('.'); // chat/vision.js → my-sdk-examples.chat.vision +} +``` + +Two things to know: these nodes are **independent by design** — each script +opens its own root trace with no handoffs, so they render as disconnected +islands (that's correct; they're unrelated demos, not a multi-agent graph). And +a node only appears **after it has emitted at least one trace** — a service with +zero spans shows up nowhere, so "I don't see my other examples" just means those +scripts haven't run yet. (These are all still **one application** — the split is +`service.name`, not `applicationId`.) + +> **You MUST tell the user the topology will render as disconnected nodes — and +> why — before you call the integration done.** An island graph is the _correct_ +> output for a repo of independent single-agent scripts, but if you hand it over +> without a word, the user opens the Topology tab, sees unconnected cards, and +> reads it as a broken integration. Pre-empt that: see "On completion, report the +> topology shape" below — it applies to every integration, not just this archetype. + ## Multi-agent topology & handoffs When the service is one agent in a **multi-agent system**, the platform reconstructs the **agent topology** — who handed off to whom. **Identity:** one `serviceName` per agent (e.g. `finance.quant`) — that is the topology node. -### ⚠️ The edges come from the `parentSpanId` chain — you MUST nest (verified against the live trace-hub, 2026-07-08) - -> **This is the single most important thing to get right, and the easiest to get wrong.** The -> deployed trace-hub builds agent→agent edges by walking the **`parentSpanId` cross-service -> chain** (each agent's entry span must be a **child of its caller's span**). It does **not** -> draw the graph from `agent_handoff` span links — it stores links but the graph builder uses -> parent-child. So if each agent opens its **own root trace** and you only wire `handoffFrom` -> links, **the nodes render as disconnected islands** (real bug we hit: ingestion looked -> perfect — per-agent generations, tools, models, cost all correct — but the Topology tab -> showed unconnected cards). - -**To connect the graph, NEST each agent's trace under its caller.** Two things are required: - -1. **Register the global OTel context manager + propagator yourself.** The SDK builds a - `NodeTracerProvider` but never calls `provider.register()`, so the global OTel API has **no** - context manager or propagator — `context.with()` is a no-op and every `dh.trace()` starts a - fresh root with no parent. Do this ONCE, before any client/trace is created: +### How much do you actually need? (least → most divergence from vanilla OTel) + +Reconstruction is built to work off **standard OTel signals**, so the baseline needs **nothing** +Darkhunt-specific. Add custom bits only to sharpen the graph — pick the lowest level that gets you +the graph you need: + +| Level | You emit | You get | Darkhunt-specific? | +| --------------------- | --------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | +| **0 — zero friction** | Plain **nested** OTel spans + one `service.name` per agent (normal context propagation) | The **call-graph** topology (walked from the `parentSpanId` cross-service chain) | **Nothing** — it's just OTel | +| **1 — low friction** | + standard OTel **span links** (consumer → producer) where data-flow ≠ call-flow | The **data-flow** topology (fan-in, real producer, loops) | Standard OTel links; **no marker required** — an unmarked link is treated as a handoff | +| **2 — precision** | + `darkhunt.link.kind = agent_handoff` on those links | Same, disambiguated from non-handoff link uses (batch/messaging) | The marker attribute | +| **3 — ergonomics** | The SDK's `trace.handoffToken()` / `handoffFrom` | Same, with fan-in arrays + token plumbing handled for you | The SDK helpers | + +**Principle: minimum divergence.** Nesting (Level 0) is _standard OTel context propagation_, not a +Darkhunt invention — a normally-instrumented app already produces it, and for a **linear** pipeline +the call graph **is** the data-flow graph, so you're done with zero custom code. Reach for **links** +(Level 1) only where the data flow genuinely differs from the call flow — fan-in from several +services, a loop, an async producer→consumer. The **marker** (Level 2) and the **SDK helpers** +(Level 3) are optional sugar: the marker only to disambiguate handoff links from other OTel link +uses; the helpers for fan-in-array + token ergonomics. Don't push an integrator up the ladder further +than their graph requires. + +### ⚠️ Each agent's entry span must SURVIVE ingestion — nest, or carry a link (verified against the pipeline, 2026-07) + +> **This is the single most important thing to get right.** Reconstruction is **links-first with a +> `parentSpanId`-chain fallback**: an `agent_handoff` link draws the edge if the span has one, else +> the nearest cross-service ancestor along the parent chain does. **Either way the edge only forms if +> the upstream span it points at still exists** — and ingestion **drops a contentless span** (an +> agent's root span usually is — its generations/tools are child spans) **unless** it is a +> _cross-service entry_ (has a `parentSpanId` into another service). So the failure mode is: +> +> - **Nest** (standard OTel context propagation) → each agent root is a cross-service entry → kept → +> the parent chain (and any links) connect it. Robust, zero-config default. +> - **Don't nest AND don't link** (each agent opens its own root trace, no links) → the contentless +> roots are **dropped** → nothing to connect → **disconnected islands** (a real bug we hit: +> ingestion looked perfect — per-agent generations/tools/models/cost all correct — but the Topology +> tab showed unconnected cards). +> +> Nesting is the safe path because it satisfies **both** the parent-chain reconstruction **and** span +> retention at once. _(An earlier version of this note said "the builder uses parent-child, not +> links." That was imprecise — the builder **does** honor `agent_handoff` links; the islands came +> from the content filter dropping the link **targets**, which nesting prevents. Links-only can work +> if the link targets are retained — but that leans on ingestion keeping link-carrying roots, so +> nesting stays the recommended default.)_ + +**The simplest way to connect the graph — and the closest to vanilla OTel — is to NEST each agent's +trace under its caller.** Two things make that happen: + +1. **A global OTel context manager + propagator must be registered** — otherwise `context.with()` + is a no-op and every `dh.trace()` starts a fresh root with no parent. **As of SDK ≥ 0.5.4 the SDK + does this for you automatically** when you construct `new DarkhuntTelemetry()` (it registers the + global context manager + W3C propagator, without registering its TracerProvider globally, so it + won't hijack a host's OTel). So on current versions there is **nothing to wire up** — just + construct the client before the first `client.trace(...)` call. + + Only if the target app **manages its own OTel context** (or is pinned to SDK < 0.5.4) do you + register it yourself, once, before any client/trace — and opt the SDK out with + `new DarkhuntTelemetry({ registerContextManager: false })` (or `DARKHUNT_REGISTER_CONTEXT_MANAGER=false`): ```ts + // ONLY needed on SDK < 0.5.4, or when you deliberately manage OTel yourself: import { context, propagation } from '@opentelemetry/api'; import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks'; import { W3CTraceContextPropagator } from '@opentelemetry/core'; @@ -529,39 +803,26 @@ When the service is one agent in a **multi-agent system**, the platform reconstr propagation.setGlobalPropagator(new W3CTraceContextPropagator()); ``` - (Yes, this means you DO take `@opentelemetry/{api,context-async-hooks,core}` as direct deps.) - -2. **Create each agent's trace inside its caller's context**, so the agent's root span gets a - `parentSpanId` and shares the caller's `trace_id`. The caller's token is just its - `handoffToken()` (a W3C traceparent). `Trace.span()/generation()` parent under the trace's - **own** root (`parentContext: this.rootContext`), so ONLY the agent root needs the caller - context — a one-liner wrapper does it: - - ```ts - // openTrace = drop-in for dh.trace(args), but nested under args.handoffFrom[0]. - export function openTrace(args) { - const parent = args?.handoffFrom?.[0]; // the DIRECT upstream token - if (typeof parent !== 'string' || !parent) return dh.trace(args); - const ctx = propagation.extract(context.active(), { traceparent: parent }); - return context.with(ctx, () => dh.trace(args)); // agent root → child of caller - } - ``` - - Use `openTrace(...)` instead of `dh.trace(...)` in every agent. `handoffFrom[0]` becomes the - **parent edge** (the topology arrow); any further `handoffFrom` entries stay as supplementary - links (fan-in). A whole task then lands in **one trace**, nested — verify with: all of a - task's spans share a single `trace_id`. +2. **Nesting is automatic — just pass `handoffFrom` to `client.trace()`.** + `client.trace({ handoffFrom: [callerToken] })` makes the agent's root span a **child** of + `handoffFrom[0]` on its own (`parentSpanId` set, shared `trace_id`) AND keeps it as an + `agent_handoff` link. The caller's token is its `handoffToken()` (a W3C traceparent). + `Trace.span()/generation()` already parent under the trace's own root, so the SDK only has to + parent the root — which it does from `handoffFrom[0]`. You don't wrap `client.trace()` in anything; + the context plumbing is inside the SDK. `handoffFrom[0]` is the **parent edge** (the topology + arrow); any further entries stay supplementary links (fan-in). A whole task lands in **one trace**, + nested — verify with: all of a task's spans share a single `trace_id`. -**Emit the handoff token** the same two-call way (it's what feeds `openTrace`'s parent): +**Emit the handoff token** the same way (it's what feeds the downstream's `handoffFrom`): ```ts -// Upstream agent: expose its entry-span token. -const trace = openTrace({ name: 'research-agent', sessionId, userId, handoffFrom }); +// Upstream agent: nest under its own caller, then expose its entry-span token. +const trace = client.trace({ name: 'research-agent', sessionId, userId, handoffFrom }); // ...work... return { ...result, handoff: trace.handoffToken() }; // opaque W3C-traceparent string -// Downstream agent: its DIRECT upstream is handoffFrom[0] (the parent); more = fan-in links. -const trace = openTrace({ +// Downstream agent: handoffFrom[0] is the parent (nests); more entries = fan-in links. +const trace = client.trace({ name: 'analyst-agent', handoffFrom: [research.handoff, quant.handoff], sessionId, @@ -569,30 +830,136 @@ const trace = openTrace({ }); ``` -Cross-process (Temporal / queue / HTTP): the token is a string — carry it in the workflow arg / -message / header. The reference integration is `temporal-demo` -(`/Users/sergey/proj/darkhunt/temporal-demo`: `src/telemetry.ts` `openTrace`/`spanHandoff`, -`src/otel.ts` globals, `src/domains/*/workflows/coordinator.ts` threading). +### Keep the token out of business signatures — carry it in ambient context -### The orchestrator/gateway node needs CONTENT or it disappears +The token is observability plumbing, so it should never be a typed `handoff` **parameter** on your +agent functions, entry inputs, message/return types, or graph state. Threading it there couples +business code to telemetry — the tell is that _removing_ telemetry later forces edits to domain +signatures across every agent. Instead, carry it the way OTel carries trace context: **out-of-band, +in an ambient async-context store**, so agents read the upstream token and publish their own without +it ever appearing in a signature. -The trace-hub keeps a span only if it has a `generation` (input/outputMessages) **or a `tool`** -name — otherwise it's dropped (unless it's a cross-service boundary). A gateway/orchestrator that -opens a **contentless root trace** gets filtered out, so its node vanishes AND its children lose -their root edge. Fix: emit a **tool span** on it (e.g. a `dispatch` tool span with the task as -`input`) and hand off from THAT span, not the root — build the token from the span's context: +```ts +// handoff-context.ts — a tiny AsyncLocalStorage carrier (mirrors Temporal's `currentHandoff()`). +import { AsyncLocalStorage } from 'node:async_hooks'; +const als = new AsyncLocalStorage<{ token?: string }>(); +export const withHandoff = (token: string | undefined, fn: () => T): T => als.run({ token }, fn); +export const currentHandoff = (): string | undefined => als.getStore()?.token; +export const publishHandoff = (token: string): void => { + const s = als.getStore(); + if (s) s.token = token; +}; +``` ```ts -const root = dh.trace({ name, sessionId, userId }); -const dispatch = root.span('dispatch', { - observationType: 'tool', - toolName: 'dispatch', - input: { task }, +// The gateway seeds the scope with its root token; agents pass only DATA (no `handoff` arg). +await withHandoff(root.handoffToken(), async () => { + const plan = await coordinator(task); + const weather = await geodata(plan); + return advisor(weather); }); -// spanHandoff: like handoffToken() but for a child span (uses otTrace.getSpanContext(span.context)). -const handoff = spanHandoff(dispatch); // pass into the first agent's handoffFrom + +// Each agent reads its upstream from ambient context and publishes its own for whatever runs next: +function coordinator(task) { + const parent = currentHandoff(); + const trace = client.trace({ + name: 'coordinator-agent', + sessionId, + userId, + handoffFrom: parent ? [parent] : [], + }); + publishHandoff(trace.handoffToken()); + // ...business work; returns business data only... +} ``` +**Cross-process is the same idea one level up:** the token rides the transport's metadata channel +(next section), and the consumer lifts it off the header/field into `handoffFrom` (or an ambient +store) _on entry_ — it still never becomes a business field. The Temporal path already does exactly +this: an activity interceptor reads the Temporal Header into `currentHandoff()`, and the activity +passes that to `client.trace({ handoffFrom })`. + +### Carrying the handoff token across each transport + +**The token is an opaque W3C `traceparent` STRING, and it belongs in the transport's METADATA / +HEADER channel — NOT in the business payload (args / body / message data).** Producer mints it with +`trace.handoffToken()` (or `span.handoffToken()`); consumer nests under it with +`client.trace({ handoffFrom: [token] })`. Carry it **out of band** on every transport, exactly like the +HTTP `traceparent` header — smuggling trace context into your domain args/body couples business data +to observability plumbing. The transport is otherwise irrelevant; the only thing that changes is +which metadata channel carries the string. Every transport still needs the two universals above +(a global OTel context manager — the SDK auto-registers it; and one `service.name` per agent). + +**Don't hand-roll the metadata plumbing — the SDK ships it.** Instead of reading/writing header/field +strings yourself, use the official helpers: + +- **HTTP** — `@darkhunt-security/telemetry/transports`: `handoffToHttpHeaders(token, headers?)` (producer) + and `handoffFromHttpHeaders(headers)` (consumer; case-insensitive, accepts Express `req.headers` or a + WHATWG `Headers`). +- **Queue** — `@darkhunt-security/telemetry/transports`: `handoffToMessageMeta(token, meta?)` (producer, + keyed by `HANDOFF_MESSAGE_META_KEY`, kept out of `data`), `handoffFromMessageMeta(meta)` and + `handoffsFromMessages(metas)` (consumer; the latter is the **fan-in** reader → an ordered, de-duped array). +- **Temporal** — `@darkhunt-security/telemetry/temporal`: `handoffWorkflowInterceptors` + + `handoffActivityInterceptors()` + `currentHandoff()` carry the token in a **Temporal Header**; + `childArgs(input, handoffFrom)` authors a per-edge override. Register the workflow interceptor from the + **sandbox-safe** subpath `@darkhunt-security/telemetry/temporal/workflow` (the worker-side barrel pulls + in `node:async_hooks` and must never be imported from workflow code). See the per-transport notes below. + +| Transport | Metadata / header channel (NOT the payload) | Producer | Consumer | +| --------------------------------- | ----------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- | ----------------------------------------------------------------------- | +| **Temporal** | a **Temporal Header** (via `handoffWorkflowInterceptors` / `handoffActivityInterceptors`) — NOT the workflow args | interceptors set the header; `childArgs` for a per-edge override | `currentHandoff()` → `client.trace({ handoffFrom })` | +| **HTTP** | the standard **`traceparent` header** (W3C Trace Context) | `handoffToHttpHeaders(handoff, headers)` | `handoffFromHttpHeaders(req.headers)` → `client.trace({ handoffFrom })` | +| **Queue** (Redis/Kafka/…) | a **message header / attribute** kept out of `data` (`HANDOFF_MESSAGE_META_KEY`) | `handoffToMessageMeta(handoff)` | `handoffsFromMessages(metas)` → `client.trace({ handoffFrom })` | +| **In-process graph** (LangGraph…) | a field in the **graph state** | node writes `state.handoff` | node reads `state.handoff` → `client.trace({ handoffFrom })` | + +- **HTTP** — use the **standard `traceparent` header**, NOT a body field. It's the W3C convention, so + any OTel-instrumented service propagates it for free, and the request body stays pure business data. + **Fan-in** is natural: each incoming request carries its own `traceparent`; the consumer collects + one per caller. +- **Queue** — carry the token in the **message header / attribute, not the body**. Kafka has native + record headers; SQS has message attributes; NATS/AMQP have headers; a raw Redis stream has no header + concept, so use a **dedicated stream field** kept separate from the serialized payload + (`XADD … traceparent data ` — never inside `data`). The stream is durable, so + ordering/timing don't matter (a consumer that runs first just waits); **read the upstream + message(s) first** — you need the token before you open the nested trace. Fan-in = an array of + tokens. The queue itself is invisible to the graph (only `publish`/`consume` tool spans); edges stay + agent→agent. +- **Temporal** — the token belongs in a **Temporal Header**, not the workflow args (your business + inputs). The SDK ships the interceptors: register `handoffWorkflowInterceptors` (from the sandbox-safe + `@darkhunt-security/telemetry/temporal/workflow` subpath) as a `workflowModules` entry and + `handoffActivityInterceptors()` on the activity side; the activity reads its upstream token via + `currentHandoff()` and passes it as `handoffFrom` to `client.trace(...)`. For a deliberate agent→agent + edge that differs from the call graph (link to the real producer; self-loops vs back-edges — see below), + the coordinator authors a **per-edge override** with `childArgs(input, [chosenToken])` on the + `executeChild` call — the workflow interceptor relocates it into the header and strips it from the + child's args. And **never instrument workflow code** (deterministic sandbox — no network/timers, so no + SDK): telemetry lives in activities + the gateway; an activity retry re-runs its LLM+tool loop and + re-emits spans. +- **In-process graph** (LangGraph, etc.) — there's no wire header in-process, so the token rides in + the graph **state** (or ambient OTel context). Still give each node **its own `service.name` + client** (`agentClient('domain.node')`) and thread the token node→node (a node writes its + `handoffToken()` into state; the next reads it as `handoffFrom`), or the nodes collapse into one + undifferentiated blob. + + (The reference demo now uses these SDK helpers on every transport — the Temporal Header via + `handoffWorkflowInterceptors`/`handoffActivityInterceptors`, the HTTP `traceparent` header via + `handoffToHttpHeaders`/`handoffFromHttpHeaders`, and a dedicated Redis-stream field via + `handoffToMessageMeta`/`handoffsFromMessages` — so the token never rides in the business payload.) + +### The orchestrator/gateway node — hand off from its root + +Ingestion **retains a trace ROOT span even when it's contentless** (the root anchors the topology), so +a gateway/orchestrator that only fans work out survives on its own — just open the root and hand off +from it. Put the task on the root's `input` so the node still carries content in the dashboard: + +```ts +const root = dh.trace({ name, sessionId, userId, input: { task } }); +const handoff = root.handoffToken(); // pass into the first agent's handoffFrom +``` + +A `dispatch` **tool span** on the gateway is optional — add one only to record an explicit dispatch +action; to hand off from that span instead of the root, use `dispatch.handoffToken()`. + ### Link to the REAL producing agent, not the orchestrator Thread the token **wherever one agent's output becomes the next agent's input** — that is the @@ -602,6 +969,42 @@ bug: an `advisor` that consumes the `geodata` agent's forecast was linked to the it rendered as a parallel sibling of `geodata` instead of downstream of it. Fix: `advisor`'s `handoffFrom` = `[geodata.handoff]` → `coordinator → geodata → advisor`. +### Logical coupling through a datastore/queue ≠ a drawn edge — flag it to the user EXPLICITLY + +When two services are related only through an **indirect medium** — a shared database, vector +store, object bucket, cache, or a queue/file/table the handoff token does **not** ride on — there +is **no `parentSpanId` chain between them**, so the topology renders them as **disjointed islands** +even though a human sees an obvious data dependency. This is frequently _correct_: they're separate +processes, often run at different times. The canonical case is a **RAG app**: an `ingest` service +scrapes → embeds → **writes** the vector store, and an `answer` service later **reads** it. Real +dependency, but **not a live handoff** — nothing carries a trace context from one to the other, so +the SDK has nothing to nest. (Verified live: a RAG demo's `ingest` WORKER and `answer` AGENT +rendered as two independent cards, linked in reality only by the Astra collection across runs.) + +**Do NOT silently synthesize an edge, and do NOT let the user assume it will auto-connect.** If you +detect a logical connection that the current code does not thread a token through, you MUST call it +out — say plainly that linking the agents is an **architecture change, not a telemetry tweak**: + +- `handoffToken()` returns a **plain string**; to draw the edge you have to **carry it across the + medium** — persist it next to the data (an extra column/field on the row the producer writes and + the consumer reads back), put it in the queue message / file metadata / HTTP header, etc. — and + have the downstream service pass it as `handoffFrom[0]` (see the ⚠️ nesting block above). +- If the architecture has **nowhere to thread that token** (e.g. producer and consumer run in + unrelated invocations with only a datastore between them), the nodes **will stay disjointed, and + that is the honest picture** — surface it, don't fake a link. + +Put it to the user in concrete terms, e.g.: + +> "`ingest` and `answer` share the vector DB but never hand off in one live flow, so the graph shows +> two independent nodes. Connecting them isn't a telemetry setting — it needs an **architecture +> change**: store the ingest run's `handoffToken()` alongside the vectors (or in whatever record +> `answer` reads) and have `answer` pass it back as `handoffFrom`. Otherwise these agents stay +> disjointed. Want that change, or is independent the truthful shape here?" + +Make this **explicit** every time you spot the pattern — a shared store, a message the token isn't +on, a cron/batch boundary. The user should never be surprised later that "obviously related" +services show up unconnected; you flagged it and named the architectural work required. + **Other span rules:** - **Agent vs Worker** — a node with ≥1 `generation` renders as an **Agent** (model, cost, AI-risk @@ -622,16 +1025,23 @@ it rendered as a parallel sibling of `geodata` instead of downstream of it. Fix: **Gotchas (each was a real bug):** -1. **Nodes disconnected → you forgot to nest** (register globals + `openTrace`). Links alone don't - draw edges on this backend; the `parentSpanId` chain does. See the ⚠️ block above. -2. **Gateway/orchestrator node missing → its span had no generation/tool content** and was filtered. - Give it a `dispatch` tool span and hand off from that span. +1. **Nodes disconnected → each agent opened its own root trace with no nesting.** Nest by passing the + caller's token as `handoffFrom` to `client.trace(...)` (the SDK auto-nests + registers the context + manager for you). See the ⚠️ block above. +2. **Gateway/orchestrator node missing** — hand off from `root.handoffToken()` (the root is retained + even when contentless). A `dispatch` tool span is optional — add one only to record an explicit + dispatch action. 3. **Wrong arrows → linked to the orchestrator instead of the real producer.** Link where output→input. 4. **Never link to a throwaway span.** `handoffToken()` targets the always-exported root span; a helper span created and `.end()`-ed immediately can be dropped on fast agents → dangling link. 5. **Fan-in is `handoffFrom: [a, b, c]`** — `[0]` is the parent edge; the rest are links. 6. **Don't infer handoffs from the call graph** — the orchestrator calls everyone (a star). Declare the causal edges via `handoffFrom`. +7. **Logical link but no token threaded → nodes stay disjointed, and you MUST say so.** If services + are coupled only through a datastore / queue / batch boundary (classic RAG `ingest`→`answer`), + there's no `parentSpanId` chain to nest, so they render as islands. Tell the user explicitly that + connecting them is an **architecture change** (carry `handoffToken()` across that medium), not a + telemetry setting — see "Logical coupling through a datastore/queue" above. ## Verification @@ -643,7 +1053,32 @@ npm run test # if integration has unit-test coverage ``` Then exercise a real path that emits a span and check trace-hub for the -incoming trace. The dashboard should show: +incoming trace. + +> **The Darkhunt MCP cannot read traces back — don't promise a server-side +> confirmation you can't do.** The MCP toolset is red-team oriented +> (`scan` / `playground` / `targets` / `policies` / `datasets` / `corpora` / app +> +> - workspace management) — there is **no tracing-query / read-trace tool**. So +> from the agent side you have exactly two checks: **(1)** the curl empty-body +> probe below, which confirms **auth + routing only** (a 400), and **(2)** the +> human opening the dashboard. There is no API to assert "span X landed." Tell +> the user that final confirmation is theirs to eyeball; don't claim you verified +> ingestion programmatically. + +> **Verify through the real integration, not a throwaway probe script.** A probe +> that emits a span under a _different_ `serviceName` (e.g. `ingest-verify`) mints +> a **whole separate node in the Topology view** — and it renders as its own Agent +> with a `↻ ×N` self-loop for each time you ran it. There's no delete-trace API, so +> that node is **permanent noise** in the OBSERVABILITY app (verified this run: a +> `ingest-verify` probe left a standing `↻ ×2` agent card next to the real one). +> Prefer the two clean checks: **(1)** the curl endpoint probe above (server-side, +> emits nothing), and **(2)** running the _actual_ instrumented code path and +> reading its node. If you truly must emit a span from a script, give it the +> **same `serviceName` as the real integration** so it folds into that node +> instead of creating a phantom agent. + +The dashboard should show: - One trace per `assessmentRunId` (sessionId-grouped) - Each generation showing `inputMessages` / `outputMessages` rendered as @@ -652,10 +1087,92 @@ incoming trace. The dashboard should show: visible on the span detail panel - Token usage / model name / cost on generation spans +> **A clean `flush()` / `shutdown()` is NOT proof the span was ingested.** The +> BatchSpanProcessor exports in the background and **swallows export failures** +> — a 401 (wrong-environment key), 404 (missing `/trace-hub`), or dropped batch +> surfaces only on the OTel **diag** channel, never as a thrown error. So "the +> script ran without throwing" tells you nothing about ingestion. To confirm +> server-side without the dashboard, probe the exact ingest endpoint the +> exporter uses and read the HTTP status: +> +> ```bash +> # good key + routing + (empty body) → 400 (reached the handler: auth+routing OK) +> # wrong/absent key → 401 (auth failed — check key↔environment) +> # missing /trace-hub in baseUrl → 404 (routing/path wrong) +> curl -s -o /dev/null -w '%{http_code}\n' -X POST \ +> -H "Authorization: Bearer $DARKHUNT_API_KEY" \ +> -H 'Content-Type: application/x-protobuf' \ +> -H "X-Workspace-Id: $DARKHUNT_WORKSPACE_ID" \ +> -H "X-Application-Id: $DARKHUNT_APPLICATION_ID" \ +> --data-binary '' \ +> "$DARKHUNT_BASE_URL/otlp/t/$DARKHUNT_TENANT_ID/v1/traces" +> ``` +> +> A **400 on the empty-body probe is the success signal** (the request +> authenticated and routed; only the empty protobuf was rejected — the real SDK +> payload would be a 2xx). This curl probe is the **reliable** server-side check. +> +> **Don't count on the in-process OTel diag logger for a success signal.** +> Registering `diag.setLogger(new DiagConsoleLogger(), DiagLogLevel.DEBUG)` +> before constructing the client sounds like it should print the export result, +> but with the current exporter stack (`@opentelemetry/otlp-transformer` 0.218 / +> `sdk-trace` 2.9, verified) it emits **only** the `Registered a global for diag` +> line and **nothing on a successful export** — the OTLP exporter is silent on +> 2xx and logs only on error. A quiet diag channel therefore proves nothing about +> ingestion; treat its silence as "no error seen," not "span landed." Confirm with +> the curl probe above or the dashboard, not the diag logger. + If spans don't appear: check (1) routing fields are populated, (2) baseUrl -points at the right environment, (3) for `internal: false`, the apiKey is -valid, (4) the process actually exits gracefully so `flush()` runs (a `kill -9` -will lose the in-memory batch). +points at the right environment **and ends in `/trace-hub`**, (3) for +`internal: false`, the apiKey is valid **and belongs to the same environment as +the baseUrl/tenant** (a wrong-env key → 401), (4) the process actually exits +gracefully so `flush()` runs (a `kill -9` will lose the in-memory batch). + +## On completion, report the topology shape to the user + +**Finishing the wiring is not the last step — telling the user what their +Topology view will (and won't) show is.** The dashboard's Topology tab is the +first thing most users open, and a graph of **disconnected nodes is a perfectly +correct result** for many architectures. But an unconnected graph handed over +_without explanation_ reads as a broken integration — the user assumes the +handoff/nesting silently failed and files it as a mistake. So close every +integration with an explicit, one-paragraph statement of the topology shape and +the reason for it. Do this proactively; don't wait to be asked. + +Decide which case you're in and say so: + +- **Connected graph (real handoffs wired).** A global OTel context manager is registered (automatic + on SDK ≥ 0.5.4) and you threaded `handoffToken()` / `handoffFrom` so agents nest via `parentSpanId`. + Tell the user which edges to expect (`coordinator → geodata → advisor`, self-loops, + etc.) so they can confirm the graph matches the intended causal DAG. + +- **Disconnected nodes (no handoffs — and that's correct).** The services are + independent processes with no live agent→agent handoff (a repo of standalone + single-agent scripts; a producer/consumer pair coupled only through a + datastore, queue, or batch boundary; a set of unrelated example entry points). + There is **no `parentSpanId` chain**, so trace-hub has nothing to nest and the + nodes render as islands. **State this before the user sees it**, name the + reason, and make clear that connecting them would be an **architecture change, + not a telemetry setting** (carry a `handoffToken()` across the boundary — see + the ⚠️ nesting block and "Logical coupling through a datastore/queue"). Offer + the change if it's actually wanted; otherwise confirm that independent is the + honest shape. + +Concrete wording for the disconnected case (adapt to the repo): + +> "These six examples are independent single-agent scripts — each runs as its own +> process and none hands off to another, so the Topology view will show them as +> **separate, unconnected nodes**. That's the correct picture, not a +> misconfiguration: Darkhunt draws edges from the cross-service `parentSpanId` +> chain, and there is no such chain here (the shared budget PDF in a few of them +> is a data source across separate runs, not a live handoff). Connecting them +> would require real handoffs with token threading — an architecture change, not +> a telemetry tweak. Want that, or is independent the truthful shape here?" + +If the repo will be run by others (an OSS / examples repo), also **persist this +note in the repo** (a short "Why the topology shows separate nodes (expected)" +subsection in the README's telemetry section), so whoever runs it later reaches +the same understanding without you in the loop. ## Common pitfalls @@ -684,9 +1201,23 @@ inputMessages, systemInstructions })` (known at start) → `.end({ outputMessage (also a `tsc` error). To record an error, set `level: 'ERROR'` + `statusMessage` on a **span** or generation, not on the trace. 9. **Nodes disconnected / gateway node missing / wrong arrows** — see the ⚠️ topology block above. - These were the biggest real bugs: you must NEST via `parentSpanId` (register OTel globals + - `openTrace`), give the orchestrator a tool span, link to the real producer, and use self-loops - (not per-round back-edges) for deep loops. + You must NEST via `parentSpanId` — pass the caller's token as `handoffFrom` to `client.trace(...)` + (the SDK registers the context manager and auto-nests), hand off from the orchestrator's root, + link to the real producer, and use self-loops (not per-round back-edges) for deep loops. +10. **Reusing an existing app / reaching for raw REST.** Two setup mistakes from a real run + (see step 2b): (a) grabbing a pre-existing `applicationId` instead of **creating a new, + dedicated OBSERVABILITY app** — the integration's traces end up in the wrong scope; (b) + listing/creating apps by curling `…/workflow-manager/api/…` when the **Darkhunt MCP** (or, failing + that, `darkhunt-cli`) is the intended interface. Use the MCP first; if it isn't connected, offer + to wire it up — don't silently route around it to raw REST. +11. **Handing over a disconnected topology without explaining it.** When the finished integration + correctly renders as independent, unconnected nodes (a repo of standalone single-agent scripts; + services coupled only through a datastore/queue), the user opens the Topology tab, sees islands, + and assumes the integration is broken. You must proactively state — before they see it — that the + disconnected graph is the _correct_ shape and why (no agent→agent handoff → no `parentSpanId` + chain to nest), and that connecting them is an architecture change, not a telemetry setting. See + "On completion, report the topology shape to the user." For OSS/example repos, persist that note + in the README too. ## Reference files in attack-discovery diff --git a/README.md b/README.md index 1d73870..a78a472 100644 --- a/README.md +++ b/README.md @@ -150,40 +150,36 @@ Two things build the graph: **nesting** (draws the edges) and **handoff tokens** > agent's entry span must be a **child of its caller's span**. If every agent opens its own root > trace and you only wire span links, the nodes render as **disconnected islands**. So you must nest. -The SDK builds a `TracerProvider` but doesn't register the global OTel context manager/propagator, -so `context.with()` is a no-op by default. Register them **once**, before creating the client: - -```ts -// src/otel.ts — run at startup, before src/telemetry.ts loads -import { context, propagation } from '@opentelemetry/api'; -import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks'; -import { W3CTraceContextPropagator } from '@opentelemetry/core'; - -context.setGlobalContextManager(new AsyncLocalStorageContextManager().enable()); -propagation.setGlobalPropagator(new W3CTraceContextPropagator()); -``` - -Then create each agent's trace **inside its caller's context**. The caller's token is its -`handoffToken()` (a W3C traceparent). `Trace.span()/generation()` parent under the trace's own -root, so only the root needs the caller's context — a one-line `openTrace` wrapper does it: +Nesting relies on `context.with()`, which needs a global OTel context manager. The SDK builds its +own `TracerProvider` (and never registers it globally, so it won't hijack a host app's OTel), so +**as of v0.5.4 constructing a `DarkhuntTelemetry` client automatically registers the global context +manager + W3C propagator for you** — nothing to wire up. (It's idempotent and won't override a +context manager your app already installed.) + +If your app manages its own OTel context, opt out with `new DarkhuntTelemetry({ registerContextManager: false })` +(or `DARKHUNT_REGISTER_CONTEXT_MANAGER=false`) and register your own — or call the SDK's helper +explicitly: `import { registerOtelContextGlobals } from '@darkhunt-security/telemetry'`. + +Each agent's trace must be created **as a child of its caller**. The caller's token is its +`handoffToken()` (a W3C traceparent). **As of v0.5.6 `dh.trace(...)` does this for you: when you +pass `handoffFrom`, the root span is automatically parented under `handoffFrom[0]`** (the direct +upstream) — it gets a `parentSpanId`, shares the caller's trace, and `handoffFrom[1..]` stay as +fan-in links. No `context.with(...)` wrapper needed; just thread the tokens (§2). `handoffFrom[0]` +also remains an `agent_handoff` link, so both the parent chain and the markers are present. ```ts // src/telemetry.ts -import { context, propagation } from '@opentelemetry/api'; import { DarkhuntTelemetry } from '@darkhunt-security/telemetry'; export const dh = new DarkhuntTelemetry(); - -// Drop-in for dh.trace(args), NESTED under args.handoffFrom[0] (the direct upstream), so the -// agent's entry span gets a parentSpanId. handoffFrom[1..] stay as extra links (fan-in). -export function openTrace(args) { - const parent = args?.handoffFrom?.[0]; - if (typeof parent !== 'string' || !parent) return dh.trace(args); // gateway root / disabled - const ctx = propagation.extract(context.active(), { traceparent: parent }); - return context.with(ctx, () => dh.trace(args)); -} +// dh.trace({ handoffFrom: [callerToken] }) already nests under the caller — call it directly. ``` +> **Upgrading from an earlier version?** If you wrote an `openTrace` wrapper that did the +> `propagation.extract` + `context.with` dance by hand, you can delete it and call `dh.trace(...)` +> directly — the SDK now absorbs the nesting. Keeping the wrapper is harmless (it just re-extracts +> the same context), but it's no longer required. + ### 2. Thread the handoff tokens along your data flow Expose a token upstream, consume it downstream. `handoffFrom[0]` is the **parent edge**; the rest @@ -192,11 +188,11 @@ are **fan-in** links: ```ts // Upstream agent — return its handoff token so downstream agents nest under it. function research(input) { - const trace = openTrace({ + const trace = dh.trace({ name: 'research', sessionId: input.taskId, userId: input.userId, - handoffFrom: input.handoffFrom, + handoffFrom: input.handoffFrom, // auto-nests under handoffFrom[0] }); // ...tool spans / generations... return { facts, handoff: trace.handoffToken() }; // opaque, serialisable string @@ -204,7 +200,7 @@ function research(input) { // Downstream agent — declare who handed off to it. function analyst(input, research, quant) { - const trace = openTrace({ + const trace = dh.trace({ name: 'analyst', handoffFrom: [research.handoff, quant.handoff], // [0] research → parent edge; quant → fan-in link sessionId: input.taskId, @@ -233,15 +229,9 @@ const dispatch = root.span('dispatch', { toolName: 'dispatch', input: { task }, }); -const handoff = spanHandoff(dispatch); // → pass into the first agent's handoffFrom - -// spanHandoff: like handoffToken() but for a child span — build the traceparent from its context. -import { trace as otTrace } from '@opentelemetry/api'; -export function spanHandoff(span) { - const sc = otTrace.getSpanContext(span.context); - if (!sc) return ''; - return `00-${sc.traceId}-${sc.spanId}-${(sc.traceFlags & 0xff).toString(16).padStart(2, '0')}`; -} +// Hand off from the SPAN, not the trace root — `Span.handoffToken()` (≥ 0.5.5), the +// span-level counterpart to `Trace.handoffToken()`, both produced via the OTel propagator. +const handoff = dispatch.handoffToken(); // → pass into the first agent's handoffFrom ``` ### What the graph shows — and how each shows up @@ -256,8 +246,9 @@ export function spanHandoff(span) { ### Best practices -- **Nest, don't just link.** Register the OTel globals + use `openTrace` (§1). Span links **alone** - leave nodes disconnected — the `parentSpanId` chain is what draws the edges. +- **Nest, don't just link.** Pass `handoffFrom` to `dh.trace(...)` — it auto-nests under + `handoffFrom[0]` (§1). Span links **alone** leave nodes disconnected — the `parentSpanId` chain is + what draws the edges. - **Give the orchestrator/gateway span content** (a tool span, §3) or its node is dropped. - **Link to the REAL producer, not the orchestrator.** Thread the token where output→input. Linking a downstream agent back to the orchestrator (because it _spawned_ it) draws a plausible-but-wrong @@ -270,8 +261,9 @@ export function spanHandoff(span) { root span; a short-lived helper span you `.end()` immediately can be dropped → **dangling link**. > **Reference integration.** The [`temporal-demo`](https://github.com/darkhunt-security) multi-agent -> example wires all of this across 7 domains (fan-out, fan-in, retry/debate cycles, deep loops) — -> see its `src/telemetry.ts` (`openTrace`/`spanHandoff`), `src/otel.ts`, and `src/domains/*/workflows/coordinator.ts`. +> example wires all of this across 6 domains (fan-out, fan-in, retry/debate cycles, deep loops) over +> several transports (Temporal, HTTP, Redis queue, LangGraph, in-process) — see its `src/telemetry.ts` +> (`openTrace`) and `src/domains/*/` (per-transport handoff threading). ## Configuration diff --git a/package-lock.json b/package-lock.json index 3d8fee7..49509c0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,18 @@ { "name": "@darkhunt-security/telemetry", - "version": "0.5.3", + "version": "0.5.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@darkhunt-security/telemetry", - "version": "0.5.3", + "version": "0.5.5", "license": "Apache-2.0", "dependencies": { "@noble/hashes": "^1.8.0", "@opentelemetry/api": "^1.9.1", + "@opentelemetry/context-async-hooks": "^2.0.0", + "@opentelemetry/core": "^2.0.0", "@opentelemetry/otlp-transformer": "^0.218.0", "@opentelemetry/resources": "^2.0.0", "@opentelemetry/sdk-trace-base": "^2.0.0", @@ -19,6 +21,9 @@ }, "devDependencies": { "@eslint/js": "^9.0.0", + "@temporalio/common": "^1.20.2", + "@temporalio/worker": "^1.20.2", + "@temporalio/workflow": "^1.20.2", "@types/node": "^25.0.0", "c8": "^11.0.0", "eslint": "^9.0.0", @@ -630,6 +635,39 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@grpc/grpc-js": { + "version": "1.14.4", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.4.tgz", + "integrity": "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@grpc/proto-loader": "^0.8.0", + "@js-sdsl/ordered-map": "^4.4.2" + }, + "engines": { + "node": ">=12.10.0" + } + }, + "node_modules/@grpc/proto-loader": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.1.tgz", + "integrity": "sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.5.5", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -706,6 +744,17 @@ "node": ">=8" } }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -716,6 +765,17 @@ "node": ">=6.0.0" } }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -734,190 +794,1130 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@noble/hashes": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", - "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "node_modules/@js-sdsl/ordered-map": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", + "integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==", + "dev": true, "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/js-sdsl" + } + }, + "node_modules/@jsonjoy.com/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-q6XAnWQDIMA3+FTiOYajoYqySkO+JSat0ytXGSuRdq9uXE7o92gzuQwQM14xaCRlBLGq3v5miDGC4vkVTn54xA==", + "dev": true, + "license": "Apache-2.0", "engines": { - "node": "^14.21.3 || >=16" + "node": ">=10.0" }, "funding": { - "url": "https://paulmillr.com/funding/" + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" } }, - "node_modules/@opentelemetry/api": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", - "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", + "node_modules/@jsonjoy.com/buffers": { + "version": "17.67.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/buffers/-/buffers-17.67.0.tgz", + "integrity": "sha512-tfExRpYxBvi32vPs9ZHaTjSP4fHAfzSmcahOfNxtvGHcyJel+aibkPlGeBB+7AoC6hL7lXIE++8okecBxx7lcw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/codegen": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/codegen/-/codegen-1.0.0.tgz", + "integrity": "sha512-E8Oy+08cmCf0EK/NMxpaJZmOxPqM+6iSe2S4nlSBrPZOORoDJILxtbSUEDKQyTamm/BVAhIGllOBNU79/dwf0g==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-core": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-core/-/fs-core-4.64.0.tgz", + "integrity": "sha512-zs2TAq7Six5jgMuoMNjpspAvOP3mhtgq/k1UyQodEzCtQi/N83y2/y+zcvnZSGp/Rxq96DBN+bValOBQAyn/ew==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/fs-node-builtins": "4.64.0", + "@jsonjoy.com/fs-node-utils": "4.64.0", + "thingies": "^2.5.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-fsa": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-fsa/-/fs-fsa-4.64.0.tgz", + "integrity": "sha512-nMWOVbkLFyEgmXZih3wyvxA9XpgyyqyfrINMHvEFqhi7uqfRl7c9ERJt6yX7vgMPrB9Uo+OJO+Spa0cFzPD01w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/fs-core": "4.64.0", + "@jsonjoy.com/fs-node-builtins": "4.64.0", + "@jsonjoy.com/fs-node-utils": "4.64.0", + "thingies": "^2.5.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-node": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node/-/fs-node-4.64.0.tgz", + "integrity": "sha512-dO+NNkODbUli4uV42bcNrrLvq5rE7SNpdZ5TNd0dtbLsAaNK3MDiIC9lUi+brboGoIjW6vd2fB1qao60nrk5xA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/fs-core": "4.64.0", + "@jsonjoy.com/fs-node-builtins": "4.64.0", + "@jsonjoy.com/fs-node-utils": "4.64.0", + "@jsonjoy.com/fs-print": "4.64.0", + "@jsonjoy.com/fs-snapshot": "4.64.0", + "glob-to-regex.js": "^1.0.0", + "thingies": "^2.5.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-node-builtins": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-builtins/-/fs-node-builtins-4.64.0.tgz", + "integrity": "sha512-/o7WRFhUWaM/fOrslwLZGnzn4RmRILykn+lAL+mNObqqRNw+CQSiij6hpCeZ+C7buhdoVo7go/OYqzaSUfDYmA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-node-to-fsa": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-to-fsa/-/fs-node-to-fsa-4.64.0.tgz", + "integrity": "sha512-WDD9WVs0hb7UAEKTgZW2f66WDrbj7gIIWwpP3spbLyXa0rghtUaFTB8L4gdR3ZCWwiKIsj38/CNijpVmpnuPUw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/fs-fsa": "4.64.0", + "@jsonjoy.com/fs-node-builtins": "4.64.0", + "@jsonjoy.com/fs-node-utils": "4.64.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-node-utils": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-utils/-/fs-node-utils-4.64.0.tgz", + "integrity": "sha512-k5Indsx9hWW9xSF7Y6oSKKwtCUNhzZxadub3owhIlitc+iMRVlPPdX2duTKQWBL3qNWpXya8jykgaaWpheeS4w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/fs-node-builtins": "4.64.0", + "glob-to-regex.js": "^1.0.1" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-print": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-print/-/fs-print-4.64.0.tgz", + "integrity": "sha512-PHZFccchvkhWrwPWHjmVAhbC3vSHCtyZvlZfJJ3ho2bnzl450hXri6/8e6pbkWdH+SkmLXNml0sV8e5HDAfxKw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/fs-node-utils": "4.64.0", + "tree-dump": "^1.1.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-snapshot": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-snapshot/-/fs-snapshot-4.64.0.tgz", + "integrity": "sha512-oM7UDeL83q6NBzzsfKAsYKXKVXlykKFqqOLh4xZZKAzzROTlInkPbc6LTDGThEOnPiFiUzA7tYziHG9xavd76Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/buffers": "^17.65.0", + "@jsonjoy.com/fs-node-utils": "4.64.0", + "@jsonjoy.com/json-pack": "^17.65.0", + "@jsonjoy.com/util": "^17.65.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-snapshot/node_modules/@jsonjoy.com/base64": { + "version": "17.67.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/base64/-/base64-17.67.0.tgz", + "integrity": "sha512-5SEsJGsm15aP8TQGkDfJvz9axgPwAEm98S5DxOuYe8e1EbfajcDmgeXXzccEjh+mLnjqEKrkBdjHWS5vFNwDdw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-snapshot/node_modules/@jsonjoy.com/codegen": { + "version": "17.67.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/codegen/-/codegen-17.67.0.tgz", + "integrity": "sha512-idnkUplROpdBOV0HMcwhsCUS5TRUi9poagdGs70A6S4ux9+/aPuKbh8+UYRTLYQHtXvAdNfQWXDqZEx5k4Dj2Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-snapshot/node_modules/@jsonjoy.com/json-pack": { + "version": "17.67.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pack/-/json-pack-17.67.0.tgz", + "integrity": "sha512-t0ejURcGaZsn1ClbJ/3kFqSOjlryd92eQY465IYrezsXmPcfHPE/av4twRSxf6WE+TkZgLY+71vCZbiIiFKA/w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/base64": "17.67.0", + "@jsonjoy.com/buffers": "17.67.0", + "@jsonjoy.com/codegen": "17.67.0", + "@jsonjoy.com/json-pointer": "17.67.0", + "@jsonjoy.com/util": "17.67.0", + "hyperdyperid": "^1.2.0", + "thingies": "^2.5.0", + "tree-dump": "^1.1.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-snapshot/node_modules/@jsonjoy.com/json-pointer": { + "version": "17.67.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pointer/-/json-pointer-17.67.0.tgz", + "integrity": "sha512-+iqOFInH+QZGmSuaybBUNdh7yvNrXvqR+h3wjXm0N/3JK1EyyFAeGJvqnmQL61d1ARLlk/wJdFKSL+LHJ1eaUA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/util": "17.67.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-snapshot/node_modules/@jsonjoy.com/util": { + "version": "17.67.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/util/-/util-17.67.0.tgz", + "integrity": "sha512-6+8xBaz1rLSohlGh68D1pdw3AwDi9xydm8QNlAFkvnavCJYSze+pxoW2VKP8p308jtlMRLs5NTHfPlZLd4w7ew==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/buffers": "17.67.0", + "@jsonjoy.com/codegen": "17.67.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/json-pack": { + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pack/-/json-pack-1.21.0.tgz", + "integrity": "sha512-+AKG+R2cfZMShzrF2uQw34v3zbeDYUqnQ+jg7ORic3BGtfw9p/+N6RJbq/kkV8JmYZaINknaEQ2m0/f693ZPpg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/base64": "^1.1.2", + "@jsonjoy.com/buffers": "^1.2.0", + "@jsonjoy.com/codegen": "^1.0.0", + "@jsonjoy.com/json-pointer": "^1.0.2", + "@jsonjoy.com/util": "^1.9.0", + "hyperdyperid": "^1.2.0", + "thingies": "^2.5.0", + "tree-dump": "^1.1.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/json-pack/node_modules/@jsonjoy.com/buffers": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/buffers/-/buffers-1.2.1.tgz", + "integrity": "sha512-12cdlDwX4RUM3QxmUbVJWqZ/mrK6dFQH4Zxq6+r1YXKXYBNgZXndx2qbCJwh3+WWkCSn67IjnlG3XYTvmvYtgA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/json-pointer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pointer/-/json-pointer-1.0.2.tgz", + "integrity": "sha512-Fsn6wM2zlDzY1U+v4Nc8bo3bVqgfNTGcn6dMgs6FjrEnt4ZCe60o6ByKRjOGlI2gow0aE/Q41QOigdTqkyK5fg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/codegen": "^1.0.0", + "@jsonjoy.com/util": "^1.9.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/util": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/util/-/util-1.9.0.tgz", + "integrity": "sha512-pLuQo+VPRnN8hfPqUTLTHk126wuYdXVxE6aDmjSeV4NCAgyxWbiOIeNJVtID3h1Vzpoi9m4jXezf73I6LgabgQ==", + "dev": true, "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/buffers": "^1.0.0", + "@jsonjoy.com/codegen": "^1.0.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/util/node_modules/@jsonjoy.com/buffers": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/buffers/-/buffers-1.2.1.tgz", + "integrity": "sha512-12cdlDwX4RUM3QxmUbVJWqZ/mrK6dFQH4Zxq6+r1YXKXYBNgZXndx2qbCJwh3+WWkCSn67IjnlG3XYTvmvYtgA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@opentelemetry/api": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", + "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/api-logs": { + "version": "0.218.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.218.0.tgz", + "integrity": "sha512-fmEWp5kXlGEc3i/lR698Hz41DfGyN4Tbe4g7L1AxSc7fF8Xeh/FQ9Quqpa9dVA413Q1Ad43QOLzU4JoXgbFPWw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.3.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/context-async-hooks": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.7.1.tgz", + "integrity": "sha512-OPFBYuXEn1E4ja3Y6eeA7O+ZnLBNcXTV5Cgsn1VaqBZ6hC5FnpZPLBNme1LJY8ZtF4aOujPKFoeWN4ik487KuQ==", + "license": "Apache-2.0", + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/core": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.7.1.tgz", + "integrity": "sha512-QAqIj32AtK6+pEVNG7EOVxHdE06RP+FM5qpiEJ4RtDcFIqKUZHYhl7/7UY5efhwmwNAg7j8QbJVBLxMerc0+gw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/otlp-transformer": { + "version": "0.218.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.218.0.tgz", + "integrity": "sha512-CFaKH87WAzjuJ4awowTTLzUvMfaRfiOFG5+qm5S5ncyalRtN4ecQ+YmuANJSCrVPuvZFEkUgKhBPBndxi3rHsQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.218.0", + "@opentelemetry/core": "2.7.1", + "@opentelemetry/resources": "2.7.1", + "@opentelemetry/sdk-logs": "0.218.0", + "@opentelemetry/sdk-metrics": "2.7.1", + "@opentelemetry/sdk-trace-base": "2.7.1" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/resources": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.7.1.tgz", + "integrity": "sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.7.1", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-logs": { + "version": "0.218.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.218.0.tgz", + "integrity": "sha512-QvnNdugatFTVCJXH0Mcu7GOOJSylA9j127kIezOE4YwTI4YbowRons2K4WZTv5FMS8T4q9P0NdaRHdkSmeAIag==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.218.0", + "@opentelemetry/core": "2.7.1", + "@opentelemetry/resources": "2.7.1", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.4.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-metrics": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.7.1.tgz", + "integrity": "sha512-MpDJdkiFDs3Pm1RHO3KByuZbuBdJEXEAkiC0+yJdsZGVCdf1RpHR6n+LHDcS7ffmfrt5kVCzJSCfm4z2C7v0uQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.7.1", + "@opentelemetry/resources": "2.7.1" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.9.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-base": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.7.1.tgz", + "integrity": "sha512-NAYIlsF8MPUsKqJMiDQJTMPOmlbawC1Iz/omMLygZ1C9am8fTKYjTaI+OZM+WTY3t3Glo0wnOg/6/pac6RGPPw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.7.1", + "@opentelemetry/resources": "2.7.1", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-node": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-node/-/sdk-trace-node-2.7.1.tgz", + "integrity": "sha512-pCpQxU68lV+I9s9svqMyVu5iHdDDUnqUpSxqwyCU8A9ejEsSnMPCbearwsUO4yk08ZJzAIUCFuReMdVQvHrdvg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/context-async-hooks": "2.7.1", + "@opentelemetry/core": "2.7.1", + "@opentelemetry/sdk-trace-base": "2.7.1" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.41.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.41.1.tgz", + "integrity": "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@pkgr/core": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.3.6.tgz", + "integrity": "sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/pkgr" + } + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", + "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@swc/core": { + "version": "1.15.43", + "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.43.tgz", + "integrity": "sha512-1CuKjFkPxIgGdeHVuNbkxmBxkcbdc08u0aiI43pFq6yY1tTVKmXT9hFEooyyKs/sJ3xf1GPHyEwTtk9Xl8dvQw==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@swc/counter": "^0.1.3", + "@swc/types": "^0.1.27" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/swc" + }, + "optionalDependencies": { + "@swc/core-darwin-arm64": "1.15.43", + "@swc/core-darwin-x64": "1.15.43", + "@swc/core-linux-arm-gnueabihf": "1.15.43", + "@swc/core-linux-arm64-gnu": "1.15.43", + "@swc/core-linux-arm64-musl": "1.15.43", + "@swc/core-linux-ppc64-gnu": "1.15.43", + "@swc/core-linux-s390x-gnu": "1.15.43", + "@swc/core-linux-x64-gnu": "1.15.43", + "@swc/core-linux-x64-musl": "1.15.43", + "@swc/core-win32-arm64-msvc": "1.15.43", + "@swc/core-win32-ia32-msvc": "1.15.43", + "@swc/core-win32-x64-msvc": "1.15.43" + }, + "peerDependencies": { + "@swc/helpers": ">=0.5.17" + }, + "peerDependenciesMeta": { + "@swc/helpers": { + "optional": true + } + } + }, + "node_modules/@swc/core-darwin-arm64": { + "version": "1.15.43", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.43.tgz", + "integrity": "sha512-v1aVuvXdo/BHxJzco9V2xpHrvwWmhfS8t6gziY5wJxd+Z2h8AeJRnAwPD8itCDaGXVBwJ/CaKfxEzTkG0Va0OA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-darwin-x64": { + "version": "1.15.43", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.43.tgz", + "integrity": "sha512-lp3d4Lamc8dt5huYdGLSR+9hLxmfr1jb0l+4XXG2zPqZwYWRN9R0U2qYoTrggiU2RWW0oV9VbWM3kBnqIc2kdQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm-gnueabihf": { + "version": "1.15.43", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.43.tgz", + "integrity": "sha512-JWTQQELtsG5GgphDrr/XqqmM2pDN3cZqbMS0Mrg+iTiXL3F74sn/S2IyYE/5u4h2KLkTf9qQ7dXyxsbx7YzkeA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-gnu": { + "version": "1.15.43", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.43.tgz", + "integrity": "sha512-B4otJRdPWIsmiSBf0uG7Z/+vMWmkufjz5MmYxubwKuZazDW14Zd3symga1N62QR4RT+kEFeHEgsXfZGyn/w0hw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-musl": { + "version": "1.15.43", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.43.tgz", + "integrity": "sha512-6zB6OnpViBxYy4tgY3v2i6AZY9fwkcHZ032UOwtwUuW1d19sdT07qF0kZe6/3UR1tUaK6jjg2rmVcUIBCEYVjQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-ppc64-gnu": { + "version": "1.15.43", + "resolved": "https://registry.npmjs.org/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.43.tgz", + "integrity": "sha512-coxE1ZWdB3uSDVNoEtYNrRi/1epvckZx9cTJ8ICUxTMTxGk+yvQ/Twacp3ruZSaMPGCriUjP86C37VhaT6nyRg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-s390x-gnu": { + "version": "1.15.43", + "resolved": "https://registry.npmjs.org/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.43.tgz", + "integrity": "sha512-lXfLhs+LpBsD5inuYx+YDH5WsPPBQ95KPUiy8P5wq9ob9xKDZFqwNfU2QW6bGO8NqRO/H9JQomTSt5Yyh+FGfA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-gnu": { + "version": "1.15.43", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.43.tgz", + "integrity": "sha512-07XnKwTmKy8TGOZG3D9fRnLWGynxPjwQnZLVmBFbo6F+7vHYzBIOuwXEhemrChBWb6yDNZsVCcMWCPX6FDD2xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-musl": { + "version": "1.15.43", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.43.tgz", + "integrity": "sha512-TJc+bsSIaBh+hZvZ5GRtW/K1bw66TJ9vsUwvVIsZdiWxU5ObLwZvfcnZ3UpgVfMnFibRes9uriJrQNBHEEogRQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-arm64-msvc": { + "version": "1.15.43", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.43.tgz", + "integrity": "sha512-jfd7s2/bUQYkOHLs+LWQNKZdmDa8+sufKLllhpWAhVQ2GDCwsHe3vR/j+OSiItZNtkzFuaawa3+SAKz9y5gYfw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-ia32-msvc": { + "version": "1.15.43", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.43.tgz", + "integrity": "sha512-rLAE8JvucqEW1ZGohxPQrQWPBQeJG4+ypKbWfdlU/qmKScvCkxf9/Jxnzki1dkUQCQ7P5Enp13RlvqOlvx/32g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-x64-msvc": { + "version": "1.15.43", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.43.tgz", + "integrity": "sha512-h8MLDHZcfIukwQWj03rIJZx1I0E81AYj2X7J/nGErG4nz+QAv6G1Z+peotvinL3lqpbo32tLYSMFo32/ySzxKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=8.0.0" + "node": ">=10" } }, - "node_modules/@opentelemetry/api-logs": { - "version": "0.218.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.218.0.tgz", - "integrity": "sha512-fmEWp5kXlGEc3i/lR698Hz41DfGyN4Tbe4g7L1AxSc7fF8Xeh/FQ9Quqpa9dVA413Q1Ad43QOLzU4JoXgbFPWw==", + "node_modules/@swc/counter": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", + "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@swc/types": { + "version": "0.1.27", + "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.27.tgz", + "integrity": "sha512-K6h3iUlqeM946U4sXFYeahefR1YBbXJvko+hv8WS8/0BNJ4OHiHRywMnQUJCqkR7Y9+hqQ1TvEpiKqUhz7NEFg==", + "dev": true, "license": "Apache-2.0", "dependencies": { - "@opentelemetry/api": "^1.3.0" - }, - "engines": { - "node": ">=8.0.0" + "@swc/counter": "^0.1.3" } }, - "node_modules/@opentelemetry/context-async-hooks": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.7.1.tgz", - "integrity": "sha512-OPFBYuXEn1E4ja3Y6eeA7O+ZnLBNcXTV5Cgsn1VaqBZ6hC5FnpZPLBNme1LJY8ZtF4aOujPKFoeWN4ik487KuQ==", - "license": "Apache-2.0", - "engines": { - "node": "^18.19.0 || >=20.6.0" + "node_modules/@temporalio/activity": { + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/@temporalio/activity/-/activity-1.20.2.tgz", + "integrity": "sha512-j2WAFsBrUcalJOt/GJ5bL+oD426KKNGR1M/O2noy94jdL9P0Q8QbkrQoixInHOPmQX0WY9cSVBdH+Ho6toe5BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@temporalio/client": "1.20.2", + "@temporalio/common": "1.20.2" }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" + "engines": { + "node": ">= 20.3.0" } }, - "node_modules/@opentelemetry/core": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.7.1.tgz", - "integrity": "sha512-QAqIj32AtK6+pEVNG7EOVxHdE06RP+FM5qpiEJ4RtDcFIqKUZHYhl7/7UY5efhwmwNAg7j8QbJVBLxMerc0+gw==", - "license": "Apache-2.0", + "node_modules/@temporalio/client": { + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/@temporalio/client/-/client-1.20.2.tgz", + "integrity": "sha512-z9CLE/K6yHQeI3ArF9Y7nao8urpZCrLF9FKPLr34TZQa6+UbuOABcKBaJOHUW/Ecjwtee0uFke/FF9oGx0A4Yw==", + "dev": true, + "license": "MIT", "dependencies": { - "@opentelemetry/semantic-conventions": "^1.29.0" + "@grpc/grpc-js": "^1.12.4", + "@temporalio/common": "1.20.2", + "@temporalio/proto": "1.20.2", + "abort-controller": "^3.0.0", + "long": "^5.2.3", + "nexus-rpc": "^0.0.2", + "uuid": "^11.1.0" }, "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" + "node": ">= 20.3.0" } }, - "node_modules/@opentelemetry/otlp-transformer": { - "version": "0.218.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.218.0.tgz", - "integrity": "sha512-CFaKH87WAzjuJ4awowTTLzUvMfaRfiOFG5+qm5S5ncyalRtN4ecQ+YmuANJSCrVPuvZFEkUgKhBPBndxi3rHsQ==", - "license": "Apache-2.0", + "node_modules/@temporalio/common": { + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/@temporalio/common/-/common-1.20.2.tgz", + "integrity": "sha512-MXWFfnb/1Y+pynT9d8DZaNeovMJwPgwaNLzCLJtLZf5Ufn/TtMZlrXkoiUPjr8ERLOs2v9K0jkq4g1XZT3CJgw==", + "dev": true, + "license": "MIT", "dependencies": { - "@opentelemetry/api-logs": "0.218.0", - "@opentelemetry/core": "2.7.1", - "@opentelemetry/resources": "2.7.1", - "@opentelemetry/sdk-logs": "0.218.0", - "@opentelemetry/sdk-metrics": "2.7.1", - "@opentelemetry/sdk-trace-base": "2.7.1" + "@temporalio/proto": "1.20.2", + "long": "^5.2.3", + "ms": "3.0.0-canary.1", + "nexus-rpc": "^0.0.2", + "proto3-json-serializer": "^2.0.0" }, "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" + "node": ">= 20.3.0" } }, - "node_modules/@opentelemetry/resources": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.7.1.tgz", - "integrity": "sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.7.1", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, + "node_modules/@temporalio/common/node_modules/ms": { + "version": "3.0.0-canary.1", + "resolved": "https://registry.npmjs.org/ms/-/ms-3.0.0-canary.1.tgz", + "integrity": "sha512-kh8ARjh8rMN7Du2igDRO9QJnqCb2xYTJxyQYK7vJJS4TvLLmsbyhiKpSW+t+y26gyOyMd0riphX0GeWKU3ky5g==", + "dev": true, + "license": "MIT", "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" + "node": ">=12.13" } }, - "node_modules/@opentelemetry/sdk-logs": { - "version": "0.218.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.218.0.tgz", - "integrity": "sha512-QvnNdugatFTVCJXH0Mcu7GOOJSylA9j127kIezOE4YwTI4YbowRons2K4WZTv5FMS8T4q9P0NdaRHdkSmeAIag==", - "license": "Apache-2.0", + "node_modules/@temporalio/core-bridge": { + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/@temporalio/core-bridge/-/core-bridge-1.20.2.tgz", + "integrity": "sha512-Jw1WTQeAnfLONDpDZIGfs3K4JL8W29SOOqzzIUDBiz9oxLd+qHQljqfJowP7vUDQYUG9hFA8pw8VT45SVszUUQ==", + "dev": true, + "license": "MIT", "dependencies": { - "@opentelemetry/api-logs": "0.218.0", - "@opentelemetry/core": "2.7.1", - "@opentelemetry/resources": "2.7.1", - "@opentelemetry/semantic-conventions": "^1.29.0" + "@grpc/grpc-js": "^1.12.4", + "@temporalio/common": "1.20.2" }, "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.4.0 <1.10.0" + "node": ">= 20.3.0" } }, - "node_modules/@opentelemetry/sdk-metrics": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.7.1.tgz", - "integrity": "sha512-MpDJdkiFDs3Pm1RHO3KByuZbuBdJEXEAkiC0+yJdsZGVCdf1RpHR6n+LHDcS7ffmfrt5kVCzJSCfm4z2C7v0uQ==", - "license": "Apache-2.0", + "node_modules/@temporalio/nexus": { + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/@temporalio/nexus/-/nexus-1.20.2.tgz", + "integrity": "sha512-NzFZOdvzfPChXGg0JbeqGW47nkyRNSmKc1l9xN2LFMBv68fHFhFe5OqWay5LyuQyM4zFXJcY7G45oXJby6xVEg==", + "dev": true, + "license": "MIT", "dependencies": { - "@opentelemetry/core": "2.7.1", - "@opentelemetry/resources": "2.7.1" + "@temporalio/client": "1.20.2", + "@temporalio/common": "1.20.2", + "@temporalio/proto": "1.20.2", + "long": "^5.2.3", + "nexus-rpc": "^0.0.2" }, "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.9.0 <1.10.0" + "node": ">= 20.3.0" } }, - "node_modules/@opentelemetry/sdk-trace-base": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.7.1.tgz", - "integrity": "sha512-NAYIlsF8MPUsKqJMiDQJTMPOmlbawC1Iz/omMLygZ1C9am8fTKYjTaI+OZM+WTY3t3Glo0wnOg/6/pac6RGPPw==", - "license": "Apache-2.0", + "node_modules/@temporalio/proto": { + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/@temporalio/proto/-/proto-1.20.2.tgz", + "integrity": "sha512-m/lfRAV4u0RQ9FszvH0c9+GEvRXZPLCQgSFaTb9BrLhuJFxUHgqc8gwbWhOyRG1pgAilRZtfkhOi/OLAUFDfNA==", + "dev": true, + "license": "MIT", "dependencies": { - "@opentelemetry/core": "2.7.1", - "@opentelemetry/resources": "2.7.1", - "@opentelemetry/semantic-conventions": "^1.29.0" + "long": "^5.2.3", + "protobufjs": "^7.6.4" }, "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" + "node": ">= 20.3.0" } }, - "node_modules/@opentelemetry/sdk-trace-node": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-node/-/sdk-trace-node-2.7.1.tgz", - "integrity": "sha512-pCpQxU68lV+I9s9svqMyVu5iHdDDUnqUpSxqwyCU8A9ejEsSnMPCbearwsUO4yk08ZJzAIUCFuReMdVQvHrdvg==", - "license": "Apache-2.0", + "node_modules/@temporalio/worker": { + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/@temporalio/worker/-/worker-1.20.2.tgz", + "integrity": "sha512-p0wd8Ga7OIijL7wqV4FqcZMTn64QdhASJSf8pq+92spOlhF16gOElaEmM2ZqgMKgO73kWrh/HjooMNVoLvPtow==", + "dev": true, + "license": "MIT", "dependencies": { - "@opentelemetry/context-async-hooks": "2.7.1", - "@opentelemetry/core": "2.7.1", - "@opentelemetry/sdk-trace-base": "2.7.1" + "@grpc/grpc-js": "^1.12.4", + "@swc/core": "^1.3.102", + "@temporalio/activity": "1.20.2", + "@temporalio/client": "1.20.2", + "@temporalio/common": "1.20.2", + "@temporalio/core-bridge": "1.20.2", + "@temporalio/nexus": "1.20.2", + "@temporalio/proto": "1.20.2", + "@temporalio/workflow": "1.20.2", + "heap-js": "^2.6.0", + "memfs": "^4.6.0", + "nexus-rpc": "^0.0.2", + "protobufjs": "^7.6.4", + "rxjs": "^7.8.1", + "source-map": "^0.7.4", + "source-map-loader": "^5.0.0", + "supports-color": "^8.1.1", + "swc-loader": "^0.2.3", + "unionfs": "^4.5.1", + "webpack": "^5.106.2" + }, + "engines": { + "node": ">= 20.3.0" + } + }, + "node_modules/@temporalio/worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" }, "engines": { - "node": "^18.19.0 || >=20.6.0" + "node": ">=10" }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/semantic-conventions": { - "version": "1.41.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.41.1.tgz", - "integrity": "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==", - "license": "Apache-2.0", - "engines": { - "node": ">=14" + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" } }, - "node_modules/@pkgr/core": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.3.6.tgz", - "integrity": "sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==", + "node_modules/@temporalio/workflow": { + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/@temporalio/workflow/-/workflow-1.20.2.tgz", + "integrity": "sha512-n7yusxqs4xcRUsHBqSkt4qXXCZ0xrmkEY67NiVyIBAlqUB1pRFt1OMF/oayDH9G1jRYNlzyCbv4nbMJthMSVmg==", "dev": true, "license": "MIT", - "engines": { - "node": "^14.18.0 || >=16.0.0" + "dependencies": { + "@temporalio/common": "1.20.2", + "@temporalio/proto": "1.20.2", + "nexus-rpc": "^0.0.2" }, - "funding": { - "url": "https://opencollective.com/pkgr" + "engines": { + "node": ">= 20.3.0" } }, "node_modules/@types/estree": { @@ -996,6 +1996,7 @@ "integrity": "sha512-sPhE4iHuJDSvoAiec+Ro8JyXw8f0ql13HFR82P99nCm9GwTEKG0KYLvDe6REk8BCXuit6vJAv/Yxg5ABaNS2rA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.62.1", "@typescript-eslint/types": "8.62.1", @@ -1185,52 +2186,240 @@ "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.62.1", - "@typescript-eslint/types": "8.62.1", - "@typescript-eslint/typescript-estree": "8.62.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.62.1", + "@typescript-eslint/types": "8.62.1", + "@typescript-eslint/typescript-estree": "8.62.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.62.1.tgz", + "integrity": "sha512-4g3BLxfdTMy8iZG0MaBkadnlRrCJ74cQiFbyEVMrkwIoqdyaXXQM22cotDvrl4x28wgIZ9rEJRoM+mmhSJpJ1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.62.1", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@webassemblyjs/ast": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", + "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/helper-numbers": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2" + } + }, + "node_modules/@webassemblyjs/floating-point-hex-parser": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", + "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-api-error": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", + "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-buffer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", + "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-numbers": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", + "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/floating-point-hex-parser": "1.13.2", + "@webassemblyjs/helper-api-error": "1.13.2", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/helper-wasm-bytecode": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", + "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-wasm-section": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", + "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/wasm-gen": "1.14.1" + } + }, + "node_modules/@webassemblyjs/ieee754": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", + "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@xtuc/ieee754": "^1.2.0" + } + }, + "node_modules/@webassemblyjs/leb128": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", + "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/utf8": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", + "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/wasm-edit": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", + "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/helper-wasm-section": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-opt": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1", + "@webassemblyjs/wast-printer": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-gen": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", + "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wasm-opt": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", + "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-parser": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", + "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-api-error": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" } }, - "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.62.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.62.1.tgz", - "integrity": "sha512-4g3BLxfdTMy8iZG0MaBkadnlRrCJ74cQiFbyEVMrkwIoqdyaXXQM22cotDvrl4x28wgIZ9rEJRoM+mmhSJpJ1g==", + "node_modules/@webassemblyjs/wast-printer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", + "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.62.1", - "eslint-visitor-keys": "^5.0.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "@webassemblyjs/ast": "1.14.1", + "@xtuc/long": "4.2.2" } }, - "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", - "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "node_modules/@xtuc/ieee754": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", + "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "license": "BSD-3-Clause" + }, + "node_modules/@xtuc/long": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", + "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "dev": true, + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" }, - "funding": { - "url": "https://opencollective.com/eslint" + "engines": { + "node": ">=6.5" } }, "node_modules/acorn": { @@ -1239,6 +2428,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1246,6 +2436,19 @@ "node": ">=0.4.0" } }, + "node_modules/acorn-import-phases": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", + "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + }, + "peerDependencies": { + "acorn": "^8.14.0" + } + }, "node_modules/acorn-jsx": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", @@ -1273,6 +2476,48 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -1303,6 +2548,19 @@ "dev": true, "license": "MIT" }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.43", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.43.tgz", + "integrity": "sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/brace-expansion": { "version": "1.1.14", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", @@ -1314,6 +2572,48 @@ "concat-map": "0.0.1" } }, + "node_modules/browserslist": { + "version": "4.28.6", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.6.tgz", + "integrity": "sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "baseline-browser-mapping": "^2.10.42", + "caniuse-lite": "^1.0.30001803", + "electron-to-chromium": "^1.5.389", + "node-releases": "^2.0.51", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, "node_modules/c8": { "version": "11.0.0", "resolved": "https://registry.npmjs.org/c8/-/c8-11.0.0.tgz", @@ -1358,6 +2658,27 @@ "node": ">=6" } }, + "node_modules/caniuse-lite": { + "version": "1.0.30001805", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001805.tgz", + "integrity": "sha512-52noaS3DubycKSXaU30TwPGIp+POyQSUVa5jBEq3vkRkY0kjyb3LQgvhU6WGyCcyXqVLWO0Cw0Q6BSdD0kUfVA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -1375,6 +2696,16 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/chrome-trace-event": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", + "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0" + } + }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -1473,6 +2804,13 @@ "dev": true, "license": "MIT" }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true, + "license": "MIT" + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -1527,6 +2865,34 @@ "dev": true, "license": "MIT" }, + "node_modules/electron-to-chromium": { + "version": "1.5.389", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.389.tgz", + "integrity": "sha512-cEto7aeOqBfU1D+c5py5pE+ooscKE75JifxLBdFUZsqAxRS6y7kebtxAZvICszSl05gPjYHDTjY+lXpyGvpJbg==", + "dev": true, + "license": "ISC" + }, + "node_modules/enhanced-resolve": { + "version": "5.24.2", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.2.tgz", + "integrity": "sha512-rpsZEGT1jFuve6QlpyRp9ckQ+kN61hvF9BzCPyMdaKTm8UJce96KBn3sorXOFXlzjPrs3Vc4T1NsSroZ3PxlFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.0.tgz", + "integrity": "sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", @@ -1598,6 +2964,7 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -1658,6 +3025,7 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", + "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -1793,6 +3161,26 @@ "node": ">=0.10.0" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -1821,6 +3209,23 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-uri": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", + "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1907,6 +3312,13 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/fs-monkey": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fs-monkey/-/fs-monkey-1.1.0.tgz", + "integrity": "sha512-QMUezzXWII9EV5aTFXW1UBVUO77wYPpjqIF8/AviUCThNeSYZykpoTixUeaNNBwmCev0AMDWMAni+f8Hxb1IFw==", + "dev": true, + "license": "Unlicense" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1963,6 +3375,23 @@ "node": ">=10.13.0" } }, + "node_modules/glob-to-regex.js": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/glob-to-regex.js/-/glob-to-regex.js-1.2.0.tgz", + "integrity": "sha512-QMwlOQKU/IzqMUOAZWubUOT8Qft+Y0KQWnX9nK3ch0CJg0tTp4TvGZsTfudYKv2NzoQSyPcnA6TYeIQ3jGichQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, "node_modules/glob/node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -2015,6 +3444,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -2025,6 +3461,16 @@ "node": ">=8" } }, + "node_modules/heap-js": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/heap-js/-/heap-js-2.7.1.tgz", + "integrity": "sha512-EQfezRg0NCZGNlhlDR3Evrw1FVL2G3LhU7EgPoxufQKruNBSYA8MiRPHeWbU+36o+Fhel0wMwM+sLEiBAlNLJA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -2032,6 +3478,29 @@ "dev": true, "license": "MIT" }, + "node_modules/hyperdyperid": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/hyperdyperid/-/hyperdyperid-1.2.0.tgz", + "integrity": "sha512-Y93lCzHYgGWdrJ66yIktxiaGULYc6oGiABxhcO5AufBeOyoIdZF7bIfLaOrbM0iGIOXQQgxxRrFEnb+Y6w1n4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.18" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -2148,6 +3617,37 @@ "node": ">=8" } }, + "node_modules/jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, "node_modules/js-yaml": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", @@ -2206,6 +3706,20 @@ "node": ">= 0.8.0" } }, + "node_modules/loader-runner": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.2.tgz", + "integrity": "sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.11.5" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -2222,6 +3736,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -2229,6 +3750,13 @@ "dev": true, "license": "MIT" }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/lru-cache": { "version": "11.3.6", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.6.tgz", @@ -2255,6 +3783,53 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/memfs": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/memfs/-/memfs-4.64.0.tgz", + "integrity": "sha512-Kw72fgY7Wn+sD8KmtNWSafl1dz0UvAsE/PHs3YVfLiaZuA3HxNm9sRLqAu0ATiBGJvME1PxZXbBZPv5GycDeAw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/fs-core": "4.64.0", + "@jsonjoy.com/fs-fsa": "4.64.0", + "@jsonjoy.com/fs-node": "4.64.0", + "@jsonjoy.com/fs-node-builtins": "4.64.0", + "@jsonjoy.com/fs-node-to-fsa": "4.64.0", + "@jsonjoy.com/fs-node-utils": "4.64.0", + "@jsonjoy.com/fs-print": "4.64.0", + "@jsonjoy.com/fs-snapshot": "4.64.0", + "@jsonjoy.com/json-pack": "^1.11.0", + "@jsonjoy.com/util": "^1.9.0", + "glob-to-regex.js": "^1.0.1", + "thingies": "^2.5.0", + "tree-dump": "^1.0.3", + "tslib": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -2268,6 +3843,67 @@ "node": "*" } }, + "node_modules/minimizer-webpack-plugin": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/minimizer-webpack-plugin/-/minimizer-webpack-plugin-5.6.1.tgz", + "integrity": "sha512-DoeAZz8Q1C1znwsUzej1fdoi4jCf7/+Em27ouLqfK/+3m8G+D7yDhUwrc3CNhjSzGUN1kn7Iv4sWmjflQHenpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "jest-worker": "^27.4.5", + "schema-utils": "^4.3.0", + "terser": "^5.31.1" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + }, + "peerDependenciesMeta": { + "@minify-html/node": { + "optional": true + }, + "@swc/core": { + "optional": true + }, + "@swc/css": { + "optional": true + }, + "@swc/html": { + "optional": true + }, + "clean-css": { + "optional": true + }, + "cssnano": { + "optional": true + }, + "csso": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "html-minifier-terser": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "postcss": { + "optional": true + }, + "uglify-js": { + "optional": true + } + } + }, "node_modules/minipass": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", @@ -2292,6 +3928,33 @@ "dev": true, "license": "MIT" }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true, + "license": "MIT" + }, + "node_modules/nexus-rpc": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/nexus-rpc/-/nexus-rpc-0.0.2.tgz", + "integrity": "sha512-IWjIExdVYlmwXuzHdY/Q3lXCv1gbqoAXPazQhy2w4Xgtgha3H0OOujEESVPQcFUFMWm+pAk2gKnb57g8S41JZg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20.0.0" + } + }, + "node_modules/node-releases": { + "version": "2.0.51", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", + "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -2392,12 +4055,20 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, "node_modules/picomatch": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -2421,6 +4092,7 @@ "integrity": "sha512-yWG/o/4oJfo036EKAfK6ACAoDOfHeRHx4tuxkfBZiauURiaSmYwlpOr5LQqKtIkRD2z1PLteme2WoxEnj4tHTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -2444,6 +4116,43 @@ "node": ">=6.0.0" } }, + "node_modules/proto3-json-serializer": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/proto3-json-serializer/-/proto3-json-serializer-2.0.2.tgz", + "integrity": "sha512-SAzp/O4Yh02jGdRc+uIrGoe87dkN/XtwxfZ4ZyafJHymd79ozp5VG5nyZ7ygqPM5+cpLDjjGnYFUkngonyDPOQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "protobufjs": "^7.2.5" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/protobufjs": { + "version": "7.6.5", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz", + "integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==", + "dev": true, + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -2464,6 +4173,16 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -2474,6 +4193,81 @@ "node": ">=4" } }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/schema-utils": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", + "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/schema-utils/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/schema-utils/node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/schema-utils/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, "node_modules/semver": { "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", @@ -2523,6 +4317,68 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-loader": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/source-map-loader/-/source-map-loader-5.0.0.tgz", + "integrity": "sha512-k2Dur7CbSLcAH73sBcIkV5xjPV4SzqO1NJ7+XaQl8if3VODDUj3FNchNGpqgJSKbvUfJuhVdv8K2Eu8/TNl2eA==", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "^0.6.3", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.72.1" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/source-map-support/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -2549,6 +4405,20 @@ "node": ">=8" } }, + "node_modules/swc-loader": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/swc-loader/-/swc-loader-0.2.7.tgz", + "integrity": "sha512-nwYWw3Fh9ame3Rtm7StS9SBLpHRRnYcK7bnpF3UKZmesAK0gw2/ADvlURFAINmPvKtDLzp+GBiP9yLoEjg6S9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@swc/counter": "^0.1.3" + }, + "peerDependencies": { + "@swc/core": "^1.2.147", + "webpack": ">=2" + } + }, "node_modules/synckit": { "version": "0.11.13", "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.13.tgz", @@ -2565,6 +4435,39 @@ "url": "https://opencollective.com/synckit" } }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/terser": { + "version": "5.49.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.49.0.tgz", + "integrity": "sha512-SNiDnXyHSrxVcIOtVbULzcTmniUiwcV7Nwdyj1twVubeTmbjoa8p69KKDpfkdoOavuM4/GRm1+ykI8qqnavHoA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/test-exclude": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-8.0.0.tgz", @@ -2619,6 +4522,23 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/thingies": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/thingies/-/thingies-2.6.0.tgz", + "integrity": "sha512-rMHRjmlFLM1R96UYPvpmnc3LYtdFrT33JIB7L9hetGue1qAPfn1N2LJeEjxUSidu1Iku+haLZXDuEXUHNGO/lg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "^2" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -2636,6 +4556,23 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tree-dump": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/tree-dump/-/tree-dump-1.1.0.tgz", + "integrity": "sha512-rMuvhU4MCDbcbnleZTFezWsaZXRFemSqAM+7jPnzUl1fo9w3YEKOxAeui0fz3OI4EU4hf23iyA7uQRVko+UaBA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -2649,6 +4586,14 @@ "typescript": ">=4.8.4" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "peer": true + }, "node_modules/tsx": { "version": "4.23.0", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.0.tgz", @@ -2687,6 +4632,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -2726,6 +4672,46 @@ "dev": true, "license": "MIT" }, + "node_modules/unionfs": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/unionfs/-/unionfs-4.6.0.tgz", + "integrity": "sha512-fJAy3gTHjFi5S3TP5EGdjs/OUMFFvI/ady3T8qVuZfkv8Qi8prV/Q8BuFEgODJslhZTT2z2qdD2lGdee9qjEnA==", + "dev": true, + "dependencies": { + "fs-monkey": "^1.0.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -2736,6 +4722,20 @@ "punycode": "^2.1.0" } }, + "node_modules/uuid": { + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz", + "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, "node_modules/v8-to-istanbul": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", @@ -2751,6 +4751,100 @@ "node": ">=10.12.0" } }, + "node_modules/watchpack": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.2.tgz", + "integrity": "sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webpack": { + "version": "5.108.4", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.108.4.tgz", + "integrity": "sha512-yur8LyJoeiWh47dErD+Ok7vlbmDsJ3UbbRPAoxbGJ54WpE2y5yVo5G/inUzujnYgw3tPmBRdn+G7PoxXaYC33w==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/estree": "^1.0.8", + "@types/json-schema": "^7.0.15", + "@webassemblyjs/ast": "^1.14.1", + "@webassemblyjs/wasm-edit": "^1.14.1", + "@webassemblyjs/wasm-parser": "^1.14.1", + "acorn": "^8.16.0", + "acorn-import-phases": "^1.0.3", + "browserslist": "^4.28.1", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.22.2", + "es-module-lexer": "^2.1.0", + "eslint-scope": "5.1.1", + "events": "^3.2.0", + "graceful-fs": "^4.2.11", + "loader-runner": "^4.3.2", + "mime-db": "^1.54.0", + "minimizer-webpack-plugin": "^5.6.1", + "neo-async": "^2.6.2", + "schema-utils": "^4.3.3", + "tapable": "^2.3.0", + "watchpack": "^2.5.2", + "webpack-sources": "^3.5.0" + }, + "bin": { + "webpack": "bin/webpack.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependenciesMeta": { + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-sources": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.5.1.tgz", + "integrity": "sha512-jyuiGJdtvY434z5bUZrjz67v76/ePNvFZTp9Mdz29IlH4+GPsgyGjiv0fKI+M7BdkU6ADjulUcKAd3tUK3WlEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webpack/node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/webpack/node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index 501fc45..342e6d5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@darkhunt-security/telemetry", - "version": "0.5.3", + "version": "0.5.5", "description": "TypeScript SDK for sending LLM traces, generations, and observations to the Darkhunt platform for persistence and security data enrichment. Built on OpenTelemetry primitives, with built-in client-side data masking.", "type": "module", "license": "Apache-2.0", @@ -24,6 +24,29 @@ }, "main": "dist/index.js", "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + }, + "./transports": { + "types": "./dist/transports/index.d.ts", + "import": "./dist/transports/index.js", + "default": "./dist/transports/index.js" + }, + "./temporal": { + "types": "./dist/temporal/index.d.ts", + "import": "./dist/temporal/index.js", + "default": "./dist/temporal/index.js" + }, + "./temporal/workflow": { + "types": "./dist/temporal/workflow-interceptors.d.ts", + "import": "./dist/temporal/workflow-interceptors.js", + "default": "./dist/temporal/workflow-interceptors.js" + }, + "./package.json": "./package.json" + }, "files": [ "dist/", "LICENSE", @@ -55,6 +78,8 @@ "dependencies": { "@noble/hashes": "^1.8.0", "@opentelemetry/api": "^1.9.1", + "@opentelemetry/context-async-hooks": "^2.0.0", + "@opentelemetry/core": "^2.0.0", "@opentelemetry/otlp-transformer": "^0.218.0", "@opentelemetry/resources": "^2.0.0", "@opentelemetry/sdk-trace-base": "^2.0.0", @@ -63,6 +88,9 @@ }, "devDependencies": { "@eslint/js": "^9.0.0", + "@temporalio/common": "^1.20.2", + "@temporalio/worker": "^1.20.2", + "@temporalio/workflow": "^1.20.2", "@types/node": "^25.0.0", "c8": "^11.0.0", "eslint": "^9.0.0", @@ -73,5 +101,21 @@ "typescript": "^5.9.0", "typescript-eslint": "^8.0.0", "yaml": "^2.8.4" + }, + "peerDependencies": { + "@temporalio/common": ">=1.11.0 <2", + "@temporalio/worker": ">=1.11.0 <2", + "@temporalio/workflow": ">=1.11.0 <2" + }, + "peerDependenciesMeta": { + "@temporalio/common": { + "optional": true + }, + "@temporalio/worker": { + "optional": true + }, + "@temporalio/workflow": { + "optional": true + } } } diff --git a/src/client.ts b/src/client.ts index d977566..794c2dd 100644 --- a/src/client.ts +++ b/src/client.ts @@ -5,6 +5,7 @@ import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from '@opentelemetry/semantic import pkg from '../package.json' with { type: 'json' }; import { DarkhuntSpanExporter } from './exporter.js'; import { Sanitizer, type CustomPattern } from './masking/index.js'; +import { registerOtelContextGlobals } from './otel-globals.js'; import { Trace, type TraceArgs } from './trace.js'; const LIB_NAME = 'darkhunt-telemetry'; @@ -81,6 +82,16 @@ export interface DarkhuntTelemetryOptions { internal?: boolean; /** Client-side data masking. Enabled by default. */ mask?: MaskingOptions; + /** + * Register the global OTel context manager + W3C propagator so `context.with(...)` + * nests spans (required for cross-service / multi-agent trace stitching). Enabled + * by default. Set `false` (or `DARKHUNT_REGISTER_CONTEXT_MANAGER=false`) only if + * your app already registers its own OTel context manager. The SDK never registers + * its TracerProvider globally, so this touches only the context plumbing — it will + * not override a context manager the host already installed. See + * {@link registerOtelContextGlobals}. + */ + registerContextManager?: boolean; /** * Default routing fields applied to every trace from this client. Set them * once here and {@link DarkhuntTelemetry.trace} calls only need to pass @@ -156,6 +167,13 @@ export class DarkhuntTelemetry { LIB_NAME; if (this._enabled) { + // Ensure `context.with()` actually nests spans — the SDK doesn't register its + // TracerProvider globally, so without this cross-service spans never nest. + const registerCtx = + options.registerContextManager ?? + (process.env.DARKHUNT_REGISTER_CONTEXT_MANAGER ?? 'true').toLowerCase() !== 'false'; + if (registerCtx) registerOtelContextGlobals(); + this.setupProvider({ baseUrl, apiKey, diff --git a/src/index.ts b/src/index.ts index c1551dc..e5ffd1b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,5 @@ export { DarkhuntTelemetry, type DarkhuntTelemetryOptions, type MaskingOptions } from './client.js'; +export { registerOtelContextGlobals } from './otel-globals.js'; export { Sanitizer, type CustomPattern } from './masking/index.js'; export { Trace, type TraceArgs, type TraceUpdateArgs, type HandoffToken } from './trace.js'; export { @@ -13,3 +14,20 @@ export { type GenerationEndOptions, } from './span.js'; export type { ObservationType, ObservationLevel, Usage, Cost, Metadata } from './types.js'; +// Dependency-free transport helpers for carrying a handoff token across a service +// boundary (HTTP `traceparent` header / queue message metadata). Also available at +// the `./transports` subpath. The optional Temporal interceptors live at the +// separate `./temporal` subpath and are intentionally NOT re-exported here, so the +// core entry loads with zero Temporal packages installed. +export { + TRACEPARENT_HEADER, + handoffToHttpHeaders, + handoffFromHttpHeaders, + type HttpHeadersLike, + HANDOFF_MESSAGE_META_KEY, + handoffToMessageMeta, + handoffFromMessageMeta, + handoffsFromMessages, + type MessageMeta, + type MessageMetaValue, +} from './transports/index.js'; diff --git a/src/otel-globals.ts b/src/otel-globals.ts new file mode 100644 index 0000000..bac0f40 --- /dev/null +++ b/src/otel-globals.ts @@ -0,0 +1,46 @@ +import { context, propagation, diag } from '@opentelemetry/api'; +import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks'; +import { W3CTraceContextPropagator } from '@opentelemetry/core'; + +let registered = false; + +/** + * Register the global OTel **context manager** + **W3C propagator** for this process. + * + * Why the SDK needs this: it builds its OWN `TracerProvider` and deliberately does + * NOT register it as the global tracer provider (so it never hijacks a host app's + * OpenTelemetry setup). But without a global *context manager*, `context.with(...)` + * is a silent no-op — so every trace starts a fresh root and cross-service spans + * never nest (agent-topology reconstruction breaks: nodes render as disconnected + * islands). This installs just the context plumbing — not a tracer provider — so + * `context.with()` works and `traceparent` extract/inject can nest spans. + * + * Safe + idempotent: + * - runs at most once per process (guarded); + * - `setGlobalContextManager` will NOT override a context manager the host already + * registered — in that case we leave the host's in place and do nothing. + * + * Called automatically by the {@link DarkhuntTelemetry} constructor unless you pass + * `registerContextManager: false` (or set `DARKHUNT_REGISTER_CONTEXT_MANAGER=false`) + * — disable it only if your app already registers an OTel context manager itself. + * + * @returns `true` if this call registered the context manager, `false` if it was + * skipped (already registered here, or a global one was already present). + */ +export function registerOtelContextGlobals(): boolean { + if (registered) return false; + registered = true; + + const setCtx = context.setGlobalContextManager(new AsyncLocalStorageContextManager().enable()); + // W3C is OTel's default propagator; set it so traceparent inject/extract works + // even if the host never configured one. Won't override an existing global. + propagation.setGlobalPropagator(new W3CTraceContextPropagator()); + + if (!setCtx) { + diag.debug( + 'darkhunt-telemetry: a global OTel ContextManager was already registered; ' + + 'leaving the existing one in place.' + ); + } + return setCtx; +} diff --git a/src/span.ts b/src/span.ts index 01e9665..c4b129c 100644 --- a/src/span.ts +++ b/src/span.ts @@ -1,18 +1,37 @@ import { diag, context as otContext, + propagation, + ROOT_CONTEXT, trace as otTrace, SpanStatusCode, type Context, type Link, type Span as OtelSpan, + type SpanContext, type SpanOptions as OtelSpanOptions, type TimeInput, type Tracer, } from '@opentelemetry/api'; import { ATTR, GEN_AI } from './attributes.js'; import type { Sanitizer } from './masking/index.js'; -import type { Trace } from './trace.js'; +import type { HandoffToken, Trace } from './trace.js'; + +/** + * Serialize a span context into a W3C `traceparent` string via the **global OTel + * propagator** (`propagation.inject`) — the idiomatic, symmetric counterpart to the + * `propagation.extract` on the consuming side. Falls back to building the traceparent + * directly only if no global propagator is registered (e.g. the SDK was constructed + * with `registerContextManager: false` and the host set none). + */ +export function spanContextToToken(sc: SpanContext | undefined): HandoffToken { + if (!sc?.traceId || !sc?.spanId) return ''; + const carrier: Record = {}; + propagation.inject(otTrace.setSpanContext(ROOT_CONTEXT, sc), carrier); + if (carrier.traceparent) return carrier.traceparent; + const flags = (sc.traceFlags & 0xff).toString(16).padStart(2, '0'); + return `00-${sc.traceId}-${sc.spanId}-${flags}`; +} import type { Cost, Metadata, ObservationLevel, ObservationType, Usage } from './types.js'; /** @@ -173,7 +192,123 @@ export function toOtelLinks(contexts?: Context[]): Link[] { return links; } -export class Span { +/** Thenable check for the active-span runner: decides whether to end the span + * now (synchronous return) or after the returned promise settles. */ +function isPromiseLike(value: unknown): value is PromiseLike { + return ( + (typeof value === 'object' || typeof value === 'function') && + value !== null && + typeof (value as { then?: unknown }).then === 'function' + ); +} + +/** Best-effort message for the ERROR status set when an active-span callback throws. */ +function errorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +/** + * Run `fn` with `span` set ACTIVE in the ambient OTel context (via + * `context.with(span.context, ...)`), so in-process children created without an + * explicit parent — and third-party OTel auto-instrumentation — nest under it. + * The span is ended automatically when `fn` settles: synchronously on return, or + * when the returned promise resolves/rejects. On a thrown error or rejection the + * span is marked ERROR (with the message) and the error re-thrown. `span.end()` + * is idempotent, so a callback that ends the span itself (e.g. a `Generation` + * that needs to record usage/cost) is fully supported — its end wins. + * + * Shared by the `startActiveSpan` / `startActiveGeneration` helpers on both + * {@link Trace} and {@link Span}. + */ +export function runWithActiveSpan(span: S, fn: (span: S) => T): T { + return otContext.with(span.context, () => { + let result: T; + try { + result = fn(span); + } catch (err) { + span.end({ level: 'ERROR', statusMessage: errorMessage(err) }); + throw err; + } + if (isPromiseLike(result)) { + return result.then( + (value) => { + span.end(); + return value; + }, + (err) => { + span.end({ level: 'ERROR', statusMessage: errorMessage(err) }); + throw err; + } + ) as unknown as T; + } + span.end(); + return result; + }); +} + +/** + * Resolve the overloaded `(name, fn)` / `(name, options, fn)` argument shape shared + * by every `startActiveSpan` / `startActiveGeneration` entry point, open the child + * via `factory`, and run it active with {@link runWithActiveSpan}. Both {@link Trace} + * and {@link Span} delegate their bodies here so the resolution lives in one place. + */ +function runActiveChild( + factory: (name: string, options?: O) => C, + name: string, + optionsOrFn: O | ((child: C) => T), + maybeFn?: (child: C) => T +): T { + const fn = (typeof optionsOrFn === 'function' ? optionsOrFn : maybeFn) as (child: C) => T; + const options = typeof optionsOrFn === 'function' ? undefined : (optionsOrFn as O); + return runWithActiveSpan(factory(name, options), fn); +} + +/** + * Shared base for the two things you can open active children under — a {@link Trace} + * (children nest under its root) and a {@link Span} (children nest under it). Each + * subclass supplies the plain {@link span} / {@link generation} factories; this base + * adds the `startActive*` sugar once so it is not copied between the two classes. + */ +export abstract class ActiveChildHost { + abstract span(name: string, options?: SpanOptions): Span; + abstract generation(name: string, options?: GenerationOptions): Generation; + + /** + * Opt-in ergonomic wrapper: open a child {@link Span} under this node, run `fn` + * with that child ACTIVE in the ambient OTel context, and end it when `fn` + * settles. Because the child is active, in-process spans opened without an + * explicit parent and third-party OTel auto-instrumentation nest under it. + * Mirrors OTel's `tracer.startActiveSpan`. On a thrown error / rejected promise + * the child is marked ERROR and the error re-thrown. Additive — the plain + * {@link span} factory is unchanged and does NOT touch the ambient context. + */ + startActiveSpan(name: string, fn: (span: Span) => T): T; + startActiveSpan(name: string, options: SpanOptions, fn: (span: Span) => T): T; + startActiveSpan( + name: string, + optionsOrFn: SpanOptions | ((span: Span) => T), + maybeFn?: (span: Span) => T + ): T { + return runActiveChild((n, o) => this.span(n, o), name, optionsOrFn, maybeFn); + } + + /** Active-context counterpart of {@link generation} — see {@link startActiveSpan}. */ + startActiveGeneration(name: string, fn: (generation: Generation) => T): T; + startActiveGeneration( + name: string, + options: GenerationOptions, + fn: (generation: Generation) => T + ): T; + startActiveGeneration( + name: string, + optionsOrFn: GenerationOptions | ((generation: Generation) => T), + maybeFn?: (generation: Generation) => T + ): T { + return runActiveChild((n, o) => this.generation(n, o), name, optionsOrFn, maybeFn); + } +} + +export class Span extends ActiveChildHost { protected readonly tracer: Tracer; protected readonly traceRef: Trace; protected readonly otelSpan: OtelSpan; @@ -181,6 +316,7 @@ export class Span { protected ended = false; constructor(args: SpanCtorArgs) { + super(); this.tracer = args.tracer; this.traceRef = args.trace; const parentCtx = args.parentContext ?? otContext.active(); @@ -212,6 +348,16 @@ export class Span { return this.ctx; } + /** + * A serializable {@link HandoffToken} for THIS span (a W3C `traceparent`, produced via + * the global propagator). Hand it to a downstream agent's {@link TraceArgs.handoffFrom} + * to record a handoff from this span specifically — e.g. an orchestrator/gateway hands + * off from its `dispatch` tool span (a contentless root span would be filtered out). + */ + handoffToken(): HandoffToken { + return spanContextToToken(otTrace.getSpanContext(this.ctx)); + } + get trace(): Trace { return this.traceRef; } diff --git a/src/temporal/activity-interceptors.ts b/src/temporal/activity-interceptors.ts new file mode 100644 index 0000000..bff0e8a --- /dev/null +++ b/src/temporal/activity-interceptors.ts @@ -0,0 +1,62 @@ +// Temporal ACTIVITY-side reader for the handoff token carried in a Temporal Header. +// +// The workflow propagates the handoff into a Temporal Header on the activity call +// (see the workflow interceptors); this activity interceptor reads it back and +// exposes it to the activity function via {@link currentHandoff} — an +// AsyncLocalStorage, valid because activities are NOT sandboxed. Activities then +// pass `handoffFrom: currentHandoff()` to `client.trace({ ... })`. +// +// Runs on the WORKER (Node) side: imports `@temporalio/worker` + +// `@temporalio/common` (OPTIONAL peer dependencies) and `node:async_hooks`. Do NOT +// import this from workflow code — use `@darkhunt-security/telemetry/temporal/workflow` +// there instead. + +import { AsyncLocalStorage } from 'node:async_hooks'; +import { defaultPayloadConverter } from '@temporalio/common'; +import type { + ActivityExecuteInput, + ActivityInboundCallsInterceptor, + ActivityInterceptors, + Next, +} from '@temporalio/worker'; +import { HANDOFF_HEADER } from './handoff-header.js'; + +const store = new AsyncLocalStorage(); + +/** + * The upstream handoff token(s) this activity should nest under, read from the + * Temporal Header the workflow propagated. Returns `undefined` outside an + * activity, or when no handoff was propagated. Pass it straight to + * `client.trace({ handoffFrom: currentHandoff() })`. + */ +export function currentHandoff(): string[] | undefined { + return store.getStore(); +} + +/** + * Worker activity-interceptor factory: read the handoff Header into the + * AsyncLocalStorage exposed by {@link currentHandoff} for the duration of the + * activity. Register it on the worker's `interceptors.activity` list. A malformed + * or absent header is ignored (the activity simply sees no current handoff). + */ +export function handoffActivityInterceptors(): ActivityInterceptors { + const inbound: ActivityInboundCallsInterceptor = { + execute( + input: ActivityExecuteInput, + next: Next + ): Promise { + const p = input.headers[HANDOFF_HEADER]; + let handoff: string[] | undefined; + if (p) { + try { + const v = defaultPayloadConverter.fromPayload(p); + if (Array.isArray(v)) handoff = v as string[]; + } catch { + /* ignore malformed header */ + } + } + return handoff ? store.run(handoff, () => next(input)) : next(input); + }, + }; + return { inbound }; +} diff --git a/src/temporal/handoff-header.ts b/src/temporal/handoff-header.ts new file mode 100644 index 0000000..4350305 --- /dev/null +++ b/src/temporal/handoff-header.ts @@ -0,0 +1,32 @@ +// Handoff-over-Temporal-Header — shared constants + a workflow-safe helper. +// +// A Darkhunt handoff token an agent nests under travels in a TEMPORAL HEADER +// (out-of-band metadata), NOT in the business args. A coordinator authors a +// per-edge choice by attaching it as hidden metadata on a child's input via +// {@link childArgs}; the workflow OUTBOUND interceptor relocates that to the +// header and strips it before the child sees it. Every other hop is +// context-propagated (a workflow forwards its own incoming header to its +// children + activities). +// +// This module is workflow-sandbox-safe: pure JS + constants, no imports. It is +// imported by the workflow interceptors, the activity interceptor, and any +// workflow/gateway code that needs the key. + +/** Temporal Header key carrying the handoff token array (a Payload of `string[]`). */ +export const HANDOFF_HEADER = 'x-darkhunt-handoff'; + +/** Reserved input key a coordinator uses to hand a per-edge override to the + * outbound interceptor; stripped from the child's args before the wire. */ +export const HANDOFF_META = '__dhHandoff'; + +/** + * Build the single-element args tuple `executeChild` / `startChild` want, attaching + * the chosen upstream handoff token(s) as hidden metadata. The workflow outbound + * interceptor moves them into the Temporal Header and removes this key, so the child + * workflow receives ONLY its business input. With no `handoffFrom`, the child inherits + * the parent workflow's own incoming header (plain propagation) — return the input untouched. + */ +export function childArgs(input: T, handoffFrom?: string[]): [T] { + if (!handoffFrom || handoffFrom.length === 0) return [input]; + return [{ ...input, [HANDOFF_META]: handoffFrom } as T]; +} diff --git a/src/temporal/index.ts b/src/temporal/index.ts new file mode 100644 index 0000000..606718f --- /dev/null +++ b/src/temporal/index.ts @@ -0,0 +1,13 @@ +// Optional Temporal transport for Darkhunt handoff propagation, behind its own +// subpath export (`@darkhunt-security/telemetry/temporal`) and OPTIONAL peer +// dependencies on `@temporalio/*`. The core package (`@darkhunt-security/telemetry`) +// never imports this module, so it loads with zero Temporal packages installed. +// +// This barrel is for WORKER-side setup code, where importing both the workflow and +// activity pieces is fine. WORKFLOW-sandbox code must instead import the +// workflow-only subpath `@darkhunt-security/telemetry/temporal/workflow` (it pulls +// only `@temporalio/workflow`), which is what `workflowInterceptorModules` points at. + +export { HANDOFF_HEADER, HANDOFF_META, childArgs } from './handoff-header.js'; +export { handoffWorkflowInterceptors } from './workflow-interceptors.js'; +export { currentHandoff, handoffActivityInterceptors } from './activity-interceptors.js'; diff --git a/src/temporal/workflow-interceptors.ts b/src/temporal/workflow-interceptors.ts new file mode 100644 index 0000000..4664591 --- /dev/null +++ b/src/temporal/workflow-interceptors.ts @@ -0,0 +1,126 @@ +// Temporal WORKFLOW interceptors that carry the Darkhunt handoff token in a +// TEMPORAL HEADER (context propagation, with a per-edge override) — so the +// business args stay pure. +// +// • inbound.execute — capture the incoming handoff header for this run. +// • outbound.startChildWorkflow — if the caller attached a per-edge override +// (HANDOFF_META in the child's input, via +// {@link childArgs}), relocate it to the header + +// strip it; else propagate this workflow's own +// incoming header. +// • outbound.scheduleActivity — propagate this workflow's incoming header to the +// activity (so the activity nests under the same token). +// +// WORKFLOW-SANDBOX-SAFE: the only runtime import is `@temporalio/workflow` (an +// OPTIONAL peer dependency). Point Temporal's `workflowInterceptorModules` at +// `@darkhunt-security/telemetry/temporal/workflow` — this module exports +// `interceptors` (the name Temporal looks up), so it can be used as-is when the +// Darkhunt handoff is your only workflow interceptor. + +import { + defaultPayloadConverter, + type ActivityInput, + type Next, + type Payload, + type StartChildWorkflowExecutionInput, + type WorkflowExecuteInput, + type WorkflowInboundCallsInterceptor, + type WorkflowInterceptors, + type WorkflowInterceptorsFactory, + type WorkflowOutboundCallsInterceptor, +} from '@temporalio/workflow'; +import { HANDOFF_HEADER, HANDOFF_META } from './handoff-header.js'; + +const decode = (p: Payload | undefined): string[] | undefined => { + if (!p) return undefined; + try { + const v = defaultPayloadConverter.fromPayload(p); + return Array.isArray(v) ? (v as string[]) : undefined; + } catch { + return undefined; + } +}; + +const encode = (tokens: string[]): Payload => defaultPayloadConverter.toPayload(tokens) as Payload; + +/** + * The reusable Darkhunt handoff {@link WorkflowInterceptorsFactory}. Temporal runs + * this factory once per workflow execution, so `shared` is per-execution state + * linking the inbound capture to the outbound propagation. + * + * Compose it with your own workflow interceptors, or export it directly as + * `interceptors` (this module already does) and point `workflowInterceptorModules` + * at this subpath when it's the only one you need. + */ +export const handoffWorkflowInterceptors: WorkflowInterceptorsFactory = + (): WorkflowInterceptors => { + const shared: { incoming?: string[] } = {}; + + const inbound: WorkflowInboundCallsInterceptor = { + execute( + input: WorkflowExecuteInput, + next: Next + ): Promise { + const incoming = decode(input.headers[HANDOFF_HEADER]); + if (incoming?.length) shared.incoming = incoming; + return next(input); + }, + }; + + const outbound: WorkflowOutboundCallsInterceptor = { + startChildWorkflowExecution( + input: StartChildWorkflowExecutionInput, + next: Next + ): Promise<[Promise, Promise]> { + const args = input.options.args ?? []; + const first = args[0] as Record | undefined; + const override = first?.[HANDOFF_META] as string[] | undefined; + if (override?.length) { + // Per-edge override: relocate to the header and strip it from the child's args. + const { [HANDOFF_META]: _drop, ...clean } = first as Record; + return next({ + ...input, + options: { ...input.options, args: [clean, ...args.slice(1)] }, + headers: { ...input.headers, [HANDOFF_HEADER]: encode(override) }, + }); + } + if (shared.incoming?.length) { + return next({ + ...input, + headers: { ...input.headers, [HANDOFF_HEADER]: encode(shared.incoming) }, + }); + } + return next(input); + }, + + scheduleActivity( + input: ActivityInput, + next: Next + ): Promise { + if (shared.incoming?.length) { + return next({ + ...input, + headers: { ...input.headers, [HANDOFF_HEADER]: encode(shared.incoming) }, + }); + } + return next(input); + }, + }; + + return { inbound: [inbound], outbound: [outbound] }; + }; + +/** + * Temporal looks up the named export `interceptors` on each module listed in + * `workflowInterceptorModules`. Aliasing the factory to that name lets an app + * register this subpath directly when the Darkhunt handoff is its only workflow + * interceptor. Apps that also have their own interceptors should instead import + * {@link handoffWorkflowInterceptors} and compose it in their own module. + */ +export const interceptors: WorkflowInterceptorsFactory = handoffWorkflowInterceptors; + +// Re-export the sandbox-safe handoff helpers so workflow code (which may ONLY import the +// `/temporal/workflow` subpath — the worker-side barrel pulls in node:async_hooks and breaks +// the deterministic bundler) can author per-edge overrides with `childArgs` and reference the +// header/meta keys, without a local copy. `handoff-header.ts` is pure JS + constants. +export { childArgs, HANDOFF_HEADER, HANDOFF_META } from './handoff-header.js'; diff --git a/src/trace.ts b/src/trace.ts index f52f5c8..90132ca 100644 --- a/src/trace.ts +++ b/src/trace.ts @@ -1,6 +1,7 @@ import { ROOT_CONTEXT, context as otContext, + propagation, trace as otTrace, type Context, type Span as OtelSpan, @@ -11,10 +12,12 @@ import { import { ATTR } from './attributes.js'; import type { Sanitizer } from './masking/index.js'; import { + ActiveChildHost, applyMetadataAttrs, Generation, safeJsonStringify, Span, + spanContextToToken, toOtelLinks, type GenerationOptions, type SpanOptions, @@ -28,8 +31,13 @@ import type { Metadata, ObservationType } from './types.js'; */ export type HandoffToken = string; -/** Parse a {@link HandoffToken} back into an OTel context carrying its span context. */ +/** Parse a {@link HandoffToken} back into an OTel context carrying its span context — + * via the global propagator (`propagation.extract`), symmetric with the inject on the + * producing side. Falls back to direct parsing only if no global propagator is set. */ function tokenToContext(token: HandoffToken): Context | undefined { + const ctx = propagation.extract(ROOT_CONTEXT, { traceparent: token }); + const sc = otTrace.getSpanContext(ctx); + if (sc?.traceId && sc?.spanId) return ctx; const parts = token.split('-'); if (parts.length < 4) return undefined; const [, traceId, spanId, flags] = parts; @@ -144,7 +152,7 @@ export interface TraceUpdateArgs { output?: unknown; } -export class Trace { +export class Trace extends ActiveChildHost { private readonly tracer: Tracer; private readonly rootSpan: OtelSpan; private readonly rootContext: Context; @@ -166,6 +174,7 @@ export class Trace { private _output?: unknown; constructor(tracer: Tracer, args: TraceArgs, sanitizer?: Sanitizer) { + super(); this.tracer = tracer; this._sanitizer = sanitizer; this._name = args.name; @@ -189,13 +198,26 @@ export class Trace { const rootOptions: OtelSpanOptions = {}; if (args.startTime !== undefined) rootOptions.startTime = args.startTime; - const rootLinks = toOtelLinks([...(args.links ?? []), ...toHandoffContexts(args.handoffFrom)]); + const handoffContexts = toHandoffContexts(args.handoffFrom); + const rootLinks = toOtelLinks([...(args.links ?? []), ...handoffContexts]); if (rootLinks.length > 0) rootOptions.links = rootLinks; + // Auto-parent the root under handoffFrom[0] (the first RESOLVABLE handoff + // context) so a downstream agent's trace NESTS under its caller with no + // app-side `context.with(...)` wrapper — the cross-service parentSpanId + // chain is what the platform reconstructs the topology from. That same + // upstream also stays an `agent_handoff` LINK (above), so marker-based + // reconstruction and existing assertions still hold; handoffFrom[1..] and + // every `links` entry remain links only (fan-in), never a parent. A declared + // handoffFrom[0] wins over any ambient active span — it's the explicit causal + // edge. When handoffFrom is empty/unresolvable, the root falls back to the + // active context (behavior unchanged). + const parentContext = handoffContexts[0] ?? otContext.active(); this.rootSpan = tracer.startSpan( this.maskName(args.name ?? 'trace'), - Object.keys(rootOptions).length > 0 ? rootOptions : undefined + Object.keys(rootOptions).length > 0 ? rootOptions : undefined, + parentContext ); - this.rootContext = otTrace.setSpan(otContext.active(), this.rootSpan); + this.rootContext = otTrace.setSpan(parentContext, this.rootSpan); this.applyTraceAttrs(this.rootSpan); } @@ -223,10 +245,7 @@ export class Trace { * `agent_handoff` span link. The root span is always exported, so it resolves. */ handoffToken(): HandoffToken { - const sc = otTrace.getSpanContext(this.rootContext); - if (!sc) return ''; - const flags = (sc.traceFlags & 0xff).toString(16).padStart(2, '0'); - return `00-${sc.traceId}-${sc.spanId}-${flags}`; + return spanContextToToken(otTrace.getSpanContext(this.rootContext)); } get tenantId(): string { return this._tenantId; diff --git a/src/transports/http.ts b/src/transports/http.ts new file mode 100644 index 0000000..13fdba8 --- /dev/null +++ b/src/transports/http.ts @@ -0,0 +1,63 @@ +// Carry a Darkhunt handoff token across an HTTP boundary in the standard W3C +// `traceparent` request header. A {@link HandoffToken} already IS a `traceparent` +// string, so these helpers are thin — their job is to name the intent and +// centralize the header convention so every consuming app stops re-deriving it. +// +// Producing side (caller): merge the token onto the outbound request headers with +// {@link handoffToHttpHeaders}. Consuming side (callee): read it back with +// {@link handoffFromHttpHeaders} and pass it to `client.trace({ handoffFrom: [token] })`. +// +// Dependency-free: the only import is a type (erased at compile time), so this +// module is safe to load without any OTel runtime. + +import type { HandoffToken } from '../trace.js'; + +/** The standard W3C Trace Context header the handoff token travels in. */ +export const TRACEPARENT_HEADER = 'traceparent'; + +/** + * A read-only view over inbound HTTP headers. Accepts either a plain object + * (Node's `req.headers`, a fetch `HeadersInit` record) — where a value may be a + * string, a repeated-header array, or missing — or a WHATWG {@link Headers} + * instance (its own `.get` is already case-insensitive). + */ +export type HttpHeadersLike = + Record | { get(name: string): string | null }; + +function hasGet(headers: HttpHeadersLike): headers is { get(name: string): string | null } { + return typeof (headers as { get?: unknown }).get === 'function'; +} + +/** + * Merge a handoff token onto outbound request headers as `traceparent`, returning + * a new headers object (the input, if any, is not mutated). Pass the callee's + * result — `req.headers` / a fetch `HeadersInit` record — as `headers` to preserve + * existing entries. + * + * @example + * await fetch(url, { headers: handoffToHttpHeaders(trace.handoffToken(), baseHeaders) }); + */ +export function handoffToHttpHeaders( + token: HandoffToken, + headers?: Record +): Record { + return { ...headers, [TRACEPARENT_HEADER]: token }; +} + +/** + * Read a handoff token back out of inbound request headers — a case-insensitive + * lookup of `traceparent`. Returns `undefined` when the header is absent or empty. + * A repeated header (array value) resolves to its first entry. Feed the result to + * `client.trace({ handoffFrom: [token] })` to nest this agent's trace under its caller. + */ +export function handoffFromHttpHeaders(headers: HttpHeadersLike): HandoffToken | undefined { + if (hasGet(headers)) { + return headers.get(TRACEPARENT_HEADER) ?? undefined; + } + for (const [key, value] of Object.entries(headers)) { + if (key.toLowerCase() !== TRACEPARENT_HEADER) continue; + const raw = Array.isArray(value) ? value[0] : value; + return raw || undefined; + } + return undefined; +} diff --git a/src/transports/index.ts b/src/transports/index.ts new file mode 100644 index 0000000..9282f4f --- /dev/null +++ b/src/transports/index.ts @@ -0,0 +1,22 @@ +// Official, dependency-free transport helpers for carrying a Darkhunt handoff +// token across a service boundary — so consuming apps stop re-implementing the +// "carry the token across the wire" glue by hand. HTTP uses the standard W3C +// `traceparent` header; queue transports use out-of-band message metadata that +// keeps the token out of the business payload. Both are importable without any +// Temporal packages installed (see `@darkhunt-security/telemetry/temporal` for +// the optional Temporal interceptors). + +export { + TRACEPARENT_HEADER, + handoffToHttpHeaders, + handoffFromHttpHeaders, + type HttpHeadersLike, +} from './http.js'; +export { + HANDOFF_MESSAGE_META_KEY, + handoffToMessageMeta, + handoffFromMessageMeta, + handoffsFromMessages, + type MessageMeta, + type MessageMetaValue, +} from './queue.js'; diff --git a/src/transports/queue.ts b/src/transports/queue.ts new file mode 100644 index 0000000..190f8b6 --- /dev/null +++ b/src/transports/queue.ts @@ -0,0 +1,94 @@ +// Carry a Darkhunt handoff token across a QUEUE boundary in the message's +// out-of-band metadata — Kafka record headers, SQS message attributes, a Redis +// Stream field, etc. — keeping the token OUT of the business payload so the +// downstream consumer's message schema is untouched. +// +// Producing side: attach the token with {@link handoffToMessageMeta} onto the +// transport's header/attribute map. Consuming side: read it back with +// {@link handoffFromMessageMeta}, or — for a fan-in worker draining several +// upstream messages into one downstream trace — collect the tokens from all of +// them with {@link handoffsFromMessages} and pass the array straight to +// `client.trace({ handoffFrom: tokens })`. +// +// Dependency-free: the only import is a type (erased at compile time). + +import type { HandoffToken } from '../trace.js'; + +/** + * Stable, namespaced metadata key the handoff token travels under. Lower-case and + * limited to `[a-z0-9-]` so it is a legal key across Kafka header names, SQS + * message-attribute names, and Redis Stream field names alike. + */ +export const HANDOFF_MESSAGE_META_KEY = 'darkhunt-handoff'; + +/** + * A queue message's metadata value as seen on the consuming side. Transports + * hand these back in different shapes — a plain string, raw bytes (Kafka header + * buffers), or an object wrapper (SQS `{ StringValue }`) — so the readers below + * accept them all and coerce to a string. + */ +export type MessageMetaValue = string | Uint8Array | { StringValue?: string } | null | undefined; + +/** A queue message's metadata map (Kafka `headers`, SQS `MessageAttributes`, …). */ +export type MessageMeta = Record; + +/** Best-effort coercion of a transport-specific metadata value to a string. */ +function metaValueToString(value: MessageMetaValue): string | undefined { + if (value === null || value === undefined) return undefined; + if (typeof value === 'string') return value || undefined; + if (value instanceof Uint8Array) { + const s = new TextDecoder().decode(value); + return s || undefined; + } + // SQS-style `{ DataType: 'String', StringValue: '...' }` attribute wrapper. + const sv = value.StringValue; + return typeof sv === 'string' && sv ? sv : undefined; +} + +/** Case-insensitive lookup of the handoff key in a metadata map. */ +function readHandoffKey(meta: MessageMeta): MessageMetaValue { + const direct = meta[HANDOFF_MESSAGE_META_KEY]; + if (direct !== undefined) return direct; + for (const [key, value] of Object.entries(meta)) { + if (key.toLowerCase() === HANDOFF_MESSAGE_META_KEY) return value; + } + return undefined; +} + +/** + * Merge a handoff token onto a message's metadata under {@link HANDOFF_MESSAGE_META_KEY}, + * returning a new metadata object (the input, if any, is not mutated). The value is a + * plain string — Kafka accepts string header values directly; for SQS wrap it as + * `{ DataType: 'String', StringValue: meta[HANDOFF_MESSAGE_META_KEY] }` at publish time. + */ +export function handoffToMessageMeta( + token: HandoffToken, + meta?: Record +): Record { + return { ...meta, [HANDOFF_MESSAGE_META_KEY]: token }; +} + +/** + * Read a single handoff token back out of one message's metadata. Returns + * `undefined` when the key is absent or empty. Feed the result to + * `client.trace({ handoffFrom: [token] })` to nest under the producing agent. + */ +export function handoffFromMessageMeta(meta: MessageMeta | undefined): HandoffToken | undefined { + if (!meta) return undefined; + return metaValueToString(readHandoffKey(meta)); +} + +/** + * Fan-in variant: collect the handoff tokens from several upstream messages' + * metadata (skipping any without one), preserving order and de-duplicating. + * Pass the result straight to `client.trace({ handoffFrom: tokens })` so a worker + * draining a batch records a handoff link back to every producer that fed it. + */ +export function handoffsFromMessages(metas: Array): HandoffToken[] { + const seen = new Set(); + for (const meta of metas) { + const token = handoffFromMessageMeta(meta); + if (token) seen.add(token); + } + return [...seen]; +} diff --git a/test/temporal.test.ts b/test/temporal.test.ts new file mode 100644 index 0000000..46d99fa --- /dev/null +++ b/test/temporal.test.ts @@ -0,0 +1,108 @@ +/** + * Light tests for the OPTIONAL Temporal transport (`@darkhunt-security/telemetry/temporal`). + * Full Temporal-runtime E2E is out of scope (no server here) — this asserts the + * module typechecks + imports with the `@temporalio/*` peer deps installed, that the + * interceptor factories return the expected Temporal shapes, that the shared header + * constant is exported, and does one light header-propagation round-trip through the + * factory's inbound-capture → outbound-propagate wiring. + * + * Importing this file at all proves the `/temporal` subpath resolves; the CORE entry + * (`../src/index.js`) is proven to load WITHOUT Temporal separately (see README / + * the load-without-temporal check), and never imports this module. + */ + +import assert from 'node:assert/strict'; +import { AsyncLocalStorage } from 'node:async_hooks'; +import { describe, it } from 'node:test'; +import { defaultPayloadConverter } from '@temporalio/common'; + +import { + HANDOFF_HEADER, + HANDOFF_META, + childArgs, + handoffWorkflowInterceptors, + handoffActivityInterceptors, + currentHandoff, +} from '../src/temporal/index.js'; +import { interceptors } from '../src/temporal/workflow-interceptors.js'; + +const TOKEN = '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01'; + +describe('Temporal handoff constants + childArgs', () => { + it('exports the shared header + meta keys', () => { + assert.equal(HANDOFF_HEADER, 'x-darkhunt-handoff'); + assert.equal(HANDOFF_META, '__dhHandoff'); + }); + + it('childArgs attaches the override as hidden meta, or leaves input untouched', () => { + assert.deepEqual(childArgs({ q: 'x' }, [TOKEN]), [{ q: 'x', [HANDOFF_META]: [TOKEN] }]); + assert.deepEqual(childArgs({ q: 'x' }), [{ q: 'x' }]); + assert.deepEqual(childArgs({ q: 'x' }, []), [{ q: 'x' }]); + }); +}); + +describe('Workflow interceptors factory', () => { + it('returns the { inbound, outbound } shape with the expected methods', () => { + const result = handoffWorkflowInterceptors(); + assert.equal(result.inbound?.length, 1); + assert.equal(result.outbound?.length, 1); + assert.equal(typeof result.inbound?.[0]?.execute, 'function'); + assert.equal(typeof result.outbound?.[0]?.startChildWorkflowExecution, 'function'); + assert.equal(typeof result.outbound?.[0]?.scheduleActivity, 'function'); + }); + + it('exposes the Temporal-convention `interceptors` alias on the workflow subpath', () => { + assert.equal(interceptors, handoffWorkflowInterceptors); + }); + + it('captures an inbound header and propagates it onto a scheduled activity', async () => { + const { inbound, outbound } = handoffWorkflowInterceptors(); + + // Inbound: a workflow started with the handoff header captures it. + const inboundHeaders = { [HANDOFF_HEADER]: defaultPayloadConverter.toPayload([TOKEN]) }; + await inbound![0]!.execute!({ headers: inboundHeaders } as never, (i) => { + void i; + return Promise.resolve(undefined); + }); + + // Outbound: scheduling an activity now carries the captured header. + let activityHeaders: Record = {}; + await outbound![0]!.scheduleActivity!({ headers: {} } as never, (input) => { + activityHeaders = (input as { headers: Record }).headers; + return Promise.resolve(undefined); + }); + + const propagated = defaultPayloadConverter.fromPayload( + activityHeaders[HANDOFF_HEADER] as never + ); + assert.deepEqual(propagated, [TOKEN]); + }); +}); + +describe('Activity interceptors factory + currentHandoff', () => { + it('returns an { inbound } shape with an execute interceptor', () => { + const result = handoffActivityInterceptors(); + assert.equal(typeof result.inbound?.execute, 'function'); + }); + + it('currentHandoff() is undefined outside an activity execution', () => { + assert.equal(currentHandoff(), undefined); + }); + + it('reads the handoff header into currentHandoff() for the activity run', async () => { + const { inbound } = handoffActivityInterceptors(); + const headers = { [HANDOFF_HEADER]: defaultPayloadConverter.toPayload([TOKEN]) }; + + let seen: string[] | undefined; + await inbound!.execute!({ headers } as never, () => { + seen = currentHandoff(); + return Promise.resolve(undefined); + }); + + assert.deepEqual(seen, [TOKEN]); + // AsyncLocalStorage is unwound once the activity settles. + assert.equal(currentHandoff(), undefined); + // Sanity: the accessor is backed by AsyncLocalStorage. + assert.ok(AsyncLocalStorage); + }); +}); diff --git a/test/trace-handoff-parent.test.ts b/test/trace-handoff-parent.test.ts new file mode 100644 index 0000000..61f0ab6 --- /dev/null +++ b/test/trace-handoff-parent.test.ts @@ -0,0 +1,273 @@ +/** + * Tests for automatic root-span parenting under `handoffFrom[0]` (the SDK's + * cross-service nesting). A downstream agent's trace must become a CHILD of its + * caller's span — same traceId, parentSpanId == the upstream span's spanId — so + * the platform can reconstruct the topology from the parentSpanId chain WITHOUT + * any app-side `context.with(...)` wrapper. handoffFrom[0] is the parent edge AND + * stays an `agent_handoff` link; handoffFrom[1..] and `links` remain links only. + */ + +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { context as otContext, trace as otTrace } from '@opentelemetry/api'; +import { + BasicTracerProvider, + InMemorySpanExporter, + SimpleSpanProcessor, + type ReadableSpan, +} from '@opentelemetry/sdk-trace-base'; + +import { Trace } from '../src/trace.js'; +import { HANDOFF_LINK_KIND, LINK_KIND_ATTR } from '../src/span.js'; +import { registerOtelContextGlobals } from '../src/otel-globals.js'; + +// The active-span helpers (Deliverable B) rely on a global context manager for +// `context.with(...)` to actually nest. Idempotent; safe to call here. +registerOtelContextGlobals(); + +const ROUTING = { tenantId: 't1', workspaceId: 'ws1', applicationId: 'app1' }; + +/** Fresh in-memory tracer per test (SimpleSpanProcessor → sync export on end). */ +function setup() { + const exporter = new InMemorySpanExporter(); + const provider = new BasicTracerProvider({ + spanProcessors: [new SimpleSpanProcessor(exporter)], + }); + const tracer = provider.getTracer('test'); + return { exporter, tracer }; +} + +/** The exported (ended) span with the given name. */ +function spanByName(exporter: InMemorySpanExporter, name: string): ReadableSpan { + const span = exporter.getFinishedSpans().find((s) => s.name === name); + assert.ok(span, `expected an exported span named "${name}"`); + return span; +} + +/** spanId of a Trace's root span, read from its OTel context. */ +function rootSpanId(trace: Trace): string { + const sc = otTrace.getSpanContext(trace.context); + assert.ok(sc?.spanId, 'expected a resolvable root span context'); + return sc.spanId; +} +function rootTraceId(trace: Trace): string { + const sc = otTrace.getSpanContext(trace.context); + assert.ok(sc?.traceId, 'expected a resolvable root trace context'); + return sc.traceId; +} + +describe('Trace auto-parenting under handoffFrom[0]', () => { + it('nests the root under the upstream token: parentSpanId + shared traceId + link', () => { + const { exporter, tracer } = setup(); + + const upstream = new Trace(tracer, { ...ROUTING, name: 'up' }); + const token = upstream.handoffToken(); + + const downstream = new Trace(tracer, { ...ROUTING, name: 'down', handoffFrom: [token] }); + downstream.end(); + upstream.end(); + + const down = spanByName(exporter, 'down'); + // Parent edge: the downstream root is a child of the upstream root span. + assert.equal(down.parentSpanContext?.spanId, rootSpanId(upstream), 'parentSpanId == upstream'); + assert.equal(down.spanContext().traceId, rootTraceId(upstream), 'shares upstream traceId'); + // …and the same upstream is STILL emitted as an agent_handoff link (kept for + // marker-based reconstruction / existing assertions). + assert.equal(down.links.length, 1, 'exactly one handoff link'); + assert.equal(down.links[0]?.context.spanId, rootSpanId(upstream)); + assert.equal(down.links[0]?.attributes?.[LINK_KIND_ATTR], HANDOFF_LINK_KIND); + }); + + it('accepts a raw OTel Context (not just a token) as handoffFrom[0]', () => { + const { exporter, tracer } = setup(); + + const upstream = new Trace(tracer, { ...ROUTING, name: 'up' }); + const downstream = new Trace(tracer, { + ...ROUTING, + name: 'down', + handoffFrom: [upstream.context], + }); + downstream.end(); + + const down = spanByName(exporter, 'down'); + assert.equal(down.parentSpanContext?.spanId, rootSpanId(upstream)); + assert.equal(down.spanContext().traceId, rootTraceId(upstream)); + }); + + it('fan-in: handoffFrom[0] is the parent; [1..] are links only', () => { + const { exporter, tracer } = setup(); + + const a = new Trace(tracer, { ...ROUTING, name: 'a' }); + const b = new Trace(tracer, { ...ROUTING, name: 'b' }); + + const merged = new Trace(tracer, { + ...ROUTING, + name: 'merged', + handoffFrom: [a.handoffToken(), b.handoffToken()], + }); + merged.end(); + + const span = spanByName(exporter, 'merged'); + // Parent is a (handoffFrom[0]); it shares a's trace. + assert.equal(span.parentSpanContext?.spanId, rootSpanId(a), 'parent is handoffFrom[0]'); + assert.equal(span.spanContext().traceId, rootTraceId(a)); + // Both upstreams are links (fan-in) — a AND b. + const linkedSpanIds = span.links.map((l) => l.context.spanId).sort(); + assert.deepEqual(linkedSpanIds, [rootSpanId(a), rootSpanId(b)].sort()); + for (const link of span.links) { + assert.equal(link.attributes?.[LINK_KIND_ATTR], HANDOFF_LINK_KIND); + } + }); + + it('no handoff: root is a fresh top-level span (behavior unchanged)', () => { + const { exporter, tracer } = setup(); + + const t = new Trace(tracer, { ...ROUTING, name: 'root' }); + t.end(); + + const span = spanByName(exporter, 'root'); + assert.equal(span.parentSpanContext?.spanId, undefined, 'no parent → top-level root'); + assert.equal(span.links.length, 0, 'no links'); + }); + + it('unresolvable handoffFrom token → unchanged (no parent, no link)', () => { + const { exporter, tracer } = setup(); + + const t = new Trace(tracer, { ...ROUTING, name: 'root', handoffFrom: ['not-a-traceparent'] }); + t.end(); + + const span = spanByName(exporter, 'root'); + assert.equal(span.parentSpanContext?.spanId, undefined); + assert.equal(span.links.length, 0); + }); + + it('links (never a parent) stay links only; handoffFrom[0] still parents', () => { + const { exporter, tracer } = setup(); + + const linked = new Trace(tracer, { ...ROUTING, name: 'linked' }); + const parent = new Trace(tracer, { ...ROUTING, name: 'parent' }); + + const t = new Trace(tracer, { + ...ROUTING, + name: 'child', + links: [linked.context], + handoffFrom: [parent.handoffToken()], + }); + t.end(); + + const span = spanByName(exporter, 'child'); + // handoffFrom[0] parents; the `links` entry never becomes a parent. + assert.equal(span.parentSpanContext?.spanId, rootSpanId(parent)); + const linkedSpanIds = span.links.map((l) => l.context.spanId).sort(); + assert.deepEqual(linkedSpanIds, [rootSpanId(linked), rootSpanId(parent)].sort()); + }); +}); + +describe('startActiveSpan / startActiveGeneration (active-context helpers)', () => { + it('runs the callback with the child span ACTIVE in the ambient context', () => { + const { exporter, tracer } = setup(); + const trace = new Trace(tracer, { ...ROUTING, name: 'root' }); + + let activeInside: string | undefined; + const result = trace.startActiveSpan('work', (span) => { + // The child span is the active span → auto-instrumentation / bare + // tracer.startSpan would nest under it. + activeInside = otTrace.getSpanContext(otContext.active())?.spanId; + const childId = otTrace.getSpanContext(span.context)?.spanId; + assert.equal(activeInside, childId, 'child is the active span inside fn'); + return 42; + }); + + assert.equal(result, 42, 'returns the callback value'); + // Context is restored after the call. + assert.equal(otTrace.getSpanContext(otContext.active())?.spanId, undefined); + // The child span was auto-ended (exported). + const work = spanByName(exporter, 'work'); + assert.equal(work.parentSpanContext?.spanId, rootSpanId(trace), 'child nests under root'); + }); + + it('a bare tracer.startSpan inside the callback nests under the active span', () => { + const { exporter, tracer } = setup(); + const trace = new Trace(tracer, { ...ROUTING, name: 'root' }); + + trace.startActiveSpan('outer', () => { + // No explicit parent — relies on the active context set by startActiveSpan. + const inner = tracer.startSpan('inner-auto'); + inner.end(); + }); + + const outer = spanByName(exporter, 'outer'); + const inner = spanByName(exporter, 'inner-auto'); + assert.equal(inner.parentSpanContext?.spanId, outer.spanContext().spanId, 'auto span nests'); + }); + + it('awaits a promise-returning callback and ends the span after it settles', async () => { + const { exporter, tracer } = setup(); + const trace = new Trace(tracer, { ...ROUTING, name: 'root' }); + + const value = await trace.startActiveSpan('async-work', async (span) => { + // Not yet ended while the promise is in flight. + assert.equal(exporter.getFinishedSpans().length, 0); + span.update({ output: 'done' }); + return 'ok'; + }); + + assert.equal(value, 'ok'); + const span = spanByName(exporter, 'async-work'); + assert.ok(span, 'span ended after the promise settled'); + }); + + it('marks the span ERROR and re-throws when the callback throws', () => { + const { exporter, tracer } = setup(); + const trace = new Trace(tracer, { ...ROUTING, name: 'root' }); + + assert.throws( + () => + trace.startActiveSpan('boom', () => { + throw new Error('kaboom'); + }), + /kaboom/ + ); + const span = spanByName(exporter, 'boom'); + // OTel SpanStatusCode.ERROR === 2. + assert.equal(span.status.code, 2, 'ERROR status set'); + assert.equal(span.status.message, 'kaboom'); + }); + + it('passes options through and supports the options overload', () => { + const { exporter, tracer } = setup(); + const trace = new Trace(tracer, { ...ROUTING, name: 'root' }); + + trace.startActiveSpan('tool-call', { observationType: 'tool', toolName: 'lookup' }, () => {}); + + const span = spanByName(exporter, 'tool-call'); + assert.equal(span.attributes['darkhunt.observation.type'], 'tool'); + assert.equal(span.attributes['gen_ai.tool.name'], 'lookup'); + }); + + it('startActiveGeneration nests a generation and lets the callback record usage', () => { + const { exporter, tracer } = setup(); + const trace = new Trace(tracer, { ...ROUTING, name: 'root' }); + + trace.startActiveGeneration('llm', { model: 'gpt-x' }, (gen) => { + gen.end({ usage: { input_tokens: 3, output_tokens: 5 } }); + }); + + const gen = spanByName(exporter, 'llm'); + assert.equal(gen.attributes['darkhunt.observation.type'], 'generation'); + assert.equal(gen.attributes['gen_ai.usage.input_tokens'], 3); + assert.equal(gen.parentSpanContext?.spanId, rootSpanId(trace)); + }); + + it('Span.startActiveSpan nests under the span, not the trace root', () => { + const { exporter, tracer } = setup(); + const trace = new Trace(tracer, { ...ROUTING, name: 'root' }); + const parent = trace.span('parent'); + + parent.startActiveSpan('child', () => {}); + parent.end(); + + const child = spanByName(exporter, 'child'); + assert.equal(child.parentSpanContext?.spanId, otTrace.getSpanContext(parent.context)?.spanId); + }); +}); diff --git a/test/transports.test.ts b/test/transports.test.ts new file mode 100644 index 0000000..10df480 --- /dev/null +++ b/test/transports.test.ts @@ -0,0 +1,116 @@ +/** + * Tests for the dependency-free transport helpers that carry a handoff token + * across a service boundary. The token IS a W3C `traceparent`, so the helpers are + * thin — these assert the round-trips hold and the reads are liberal about the + * shapes real transports hand back: + * - HTTP: token → headers → token, case-insensitive read, WHATWG Headers, + * repeated-header arrays, missing header → undefined, non-mutating merge. + * - Queue: token → meta → token under the namespaced key, bytes/SQS-wrapper + * value coercion, and the fan-in array (order-preserving, de-duplicating). + */ + +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { + TRACEPARENT_HEADER, + handoffToHttpHeaders, + handoffFromHttpHeaders, + HANDOFF_MESSAGE_META_KEY, + handoffToMessageMeta, + handoffFromMessageMeta, + handoffsFromMessages, +} from '../src/transports/index.js'; + +const TOKEN = '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01'; +const TOKEN2 = '00-1111111111111111111111111111aaaa-2222222222bbbb33-01'; + +describe('HTTP transport helpers', () => { + it('round-trips a token through traceparent headers', () => { + const headers = handoffToHttpHeaders(TOKEN); + assert.equal(headers[TRACEPARENT_HEADER], TOKEN); + assert.equal(handoffFromHttpHeaders(headers), TOKEN); + }); + + it('merges onto existing headers without mutating the input', () => { + const base = { authorization: 'Bearer x' }; + const merged = handoffToHttpHeaders(TOKEN, base); + assert.deepEqual(base, { authorization: 'Bearer x' }, 'input must not be mutated'); + assert.equal(merged.authorization, 'Bearer x'); + assert.equal(merged[TRACEPARENT_HEADER], TOKEN); + }); + + it('reads case-insensitively (Node lower-cases, some proxies do not)', () => { + assert.equal(handoffFromHttpHeaders({ TraceParent: TOKEN }), TOKEN); + assert.equal(handoffFromHttpHeaders({ TRACEPARENT: TOKEN }), TOKEN); + }); + + it('reads from a WHATWG Headers instance (its .get is case-insensitive)', () => { + const h = new Headers(); + h.set('traceparent', TOKEN); + assert.equal(handoffFromHttpHeaders(h), TOKEN); + }); + + it('takes the first value of a repeated (array) header', () => { + assert.equal(handoffFromHttpHeaders({ traceparent: [TOKEN, TOKEN2] }), TOKEN); + }); + + it('returns undefined when the header is absent or empty', () => { + assert.equal(handoffFromHttpHeaders({}), undefined); + assert.equal(handoffFromHttpHeaders({ authorization: 'Bearer x' }), undefined); + assert.equal(handoffFromHttpHeaders({ traceparent: '' }), undefined); + assert.equal(handoffFromHttpHeaders(new Headers()), undefined); + }); +}); + +describe('Queue transport helpers', () => { + it('round-trips a token through message metadata under the namespaced key', () => { + const meta = handoffToMessageMeta(TOKEN); + assert.equal(meta[HANDOFF_MESSAGE_META_KEY], TOKEN); + assert.equal(handoffFromMessageMeta(meta), TOKEN); + }); + + it('merges onto existing metadata without mutating the input', () => { + const base = { 'content-type': 'application/json' }; + const merged = handoffToMessageMeta(TOKEN, base); + assert.deepEqual(base, { 'content-type': 'application/json' }); + assert.equal(merged['content-type'], 'application/json'); + assert.equal(merged[HANDOFF_MESSAGE_META_KEY], TOKEN); + }); + + it('coerces a bytes value (Kafka header buffer) back to a string', () => { + const meta = { [HANDOFF_MESSAGE_META_KEY]: new TextEncoder().encode(TOKEN) }; + assert.equal(handoffFromMessageMeta(meta), TOKEN); + }); + + it('coerces an SQS-style { StringValue } attribute wrapper', () => { + const meta = { [HANDOFF_MESSAGE_META_KEY]: { DataType: 'String', StringValue: TOKEN } }; + assert.equal(handoffFromMessageMeta(meta), TOKEN); + }); + + it('reads the key case-insensitively', () => { + assert.equal(handoffFromMessageMeta({ 'Darkhunt-Handoff': TOKEN }), TOKEN); + }); + + it('returns undefined for missing / empty / undefined metadata', () => { + assert.equal(handoffFromMessageMeta(undefined), undefined); + assert.equal(handoffFromMessageMeta({}), undefined); + assert.equal(handoffFromMessageMeta({ other: 'x' }), undefined); + assert.equal(handoffFromMessageMeta({ [HANDOFF_MESSAGE_META_KEY]: '' }), undefined); + }); + + it('fan-in: collects tokens across messages, order-preserving + de-duplicated', () => { + const metas = [ + handoffToMessageMeta(TOKEN), + { other: 'no-handoff-here' }, + undefined, + handoffToMessageMeta(TOKEN2), + handoffToMessageMeta(TOKEN), // duplicate of the first + ]; + assert.deepEqual(handoffsFromMessages(metas), [TOKEN, TOKEN2]); + }); + + it('fan-in: empty input → empty array', () => { + assert.deepEqual(handoffsFromMessages([]), []); + assert.deepEqual(handoffsFromMessages([undefined, {}]), []); + }); +});