diff --git a/README.md b/README.md index 947b03a..9142201 100644 --- a/README.md +++ b/README.md @@ -60,11 +60,11 @@ vx mcp # Model Context Protocol server (stdio vx coordinator build test --workers 4 # start a distributed-CI coordinator vx run --worker ws://coord:5180 # pull tasks from a coordinator and execute them -vx insights serve # localhost Solid+DuckDB-WASM SPA over cache.db - # — historical run flamegraphs, no backend +vx insights # localhost Solid SPA over vx serve's /v1/* API + # — historical run flamegraphs, cache stats -vx serve # WebSocket + SSE + NDJSON event stream - # GET /version /events /stream — `curl` works +vx serve # the unified backend — local OR Docker + # /v1/* JSON, SSE events, WS run protocol, CORS * ``` ### Open platform highlights @@ -84,15 +84,16 @@ vx serve # WebSocket + SSE + NDJSON event strea - **Distributed CI.** `vx coordinator` + `vx run --worker` over the same protocol. Content-addressed: any worker producing `` satisfies every consumer of ``. -- **OTel CI/CD spans.** Set `OTEL_EXPORTER_OTLP_ENDPOINT` and - install `@vzn/vx-otel-bridge` — every event flows to Grafana / - Honeycomb / Datadog / Tempo with zero config. -- **vx Cloud (Cloudflare-native).** `apps/cloud/` is a Wrangler - project: `bun wrangler deploy` from your fork gives you a private - hosted vx in your CF account in ~5 minutes — Workers + R2 + D1 + - Durable Objects + Queues + KV, with HMAC artifact signing. -- **`vx insights serve`** — Solid + UnoCSS + DuckDB-WASM SPA that - reads the workspace's `cache.db` directly. No backend, no daemon. +- **OTel CI/CD spans (native).** Set `OTEL_EXPORTER_OTLP_ENDPOINT`, + install the three `@opentelemetry/*` peer deps — core speaks OTel + natively; every event flows to Grafana / Honeycomb / Datadog / + Tempo with zero bridge package. +- **Self-host vx serve.** Same backend everywhere — laptop, Docker, + any container runtime. JSON `/v1/*` insights API + WebSocket run + protocol + SSE event stream + permissive CORS. One stack. +- **`vx insights`** — Solid SPA that talks to `vx serve` over HTTP. + Connection picker switches between local and hosted backends; + same UI for both. No DuckDB-WASM, no 30MB payload. Each lives behind a one-paragraph design doc under `docs/design/*-2026-06.md`. Phase-by-phase implementation log: @@ -184,8 +185,8 @@ traces · `vx cache prune` with TTL and size caps. | Per-task sandbox | **Yes** — kernel-level, opt-in | No | No | | MCP server for AI agents | **Yes** (`vx mcp`, stdio) | No | No | | Distributed CI execution | **Yes** — OSS, self-hostable (`vx coordinator` + `vx run --worker`) | No | Paid (Nx Cloud DTE) | -| Local-only dashboard SPA | **Yes** (`vx insights serve`, DuckDB-WASM) | No | Paid | -| Cloudflare-template cloud | **Yes** (`apps/cloud/`, `wrangler deploy`) | Vercel-only | No (proprietary) | +| Dashboard SPA | **Yes** (`vx insights`, Solid, talks HTTP to `vx serve`) | No | Paid | +| Self-hosted cloud | **Yes** — same `vx serve` in Docker; one stack | Vercel-only | No (proprietary) | | Plugin API | **Yes** — Vite-style lifecycle hooks | No | Yes (TS-tied) | | Predictive scheduling | **Yes** (opt-in: `predictive: true`) | No | No | | OTel CI/CD spans | **Yes** (`OTEL_EXPORTER_OTLP_ENDPOINT`) | No | Paid | @@ -285,7 +286,7 @@ Full technical docs live under [`docs/`](./docs/): - [`architecture-review-2026-06.md`](./docs/design/architecture-review-2026-06.md) — review + applied checklist - [`wire-protocol-2026-06.md`](./docs/design/wire-protocol-2026-06.md) — JSON-RPC 2.0 + OTel envelope (shipped) - [`distributed-ci-2026-06.md`](./docs/design/distributed-ci-2026-06.md) — coordinator + worker (Phase A-B shipped) -- [`vx-cloud-2026-06.md`](./docs/design/vx-cloud-2026-06.md) — Cloudflare cloud (Phases A-C shipped) +- [`vx-cloud-2026-06.md`](./docs/design/vx-cloud-2026-06.md) — original CF cloud (superseded; vx serve now runs in Docker) - [`extension-protocol-2026-06.md`](./docs/design/extension-protocol-2026-06.md) — subscriber/inspector/driver/plugin (Phase 1 shipped) - [`predictive-execution-2026-06.md`](./docs/design/predictive-execution-2026-06.md) — history-aware scheduling (Phase A-B shipped) - [`docs/progress/implementation-log-2026-06.md`](./docs/progress/implementation-log-2026-06.md) — phase-by-phase narrative @@ -302,17 +303,16 @@ published versions on npm). Production readiness for the **2026-06 platform layer**: -| Surface | Maturity | Notes | -| -------------------------------------------- | -------------------------------- | ------------------------------------------------------------------------ | -| Core task runner + caching | **production-ready** | dogfooded continuously; 836 tests pre-existing, all green | -| `vx mcp` | **shippable** | live cache.db tools, stdio; agents work today | -| `vx serve` (WS + SSE + NDJSON, JSON-RPC 2.0) | **shippable** | accepts both legacy + new envelope; `curl` works | -| `vx coordinator` + `vx run --worker` | **shippable for self-hosted CI** | content-addressed assignment, disconnect recovery | -| Plugin API | **shippable** | crash-isolated, lifecycle hooks fire end-to-end | -| Predictive scheduling | **shippable as opt-in** | gated on `predictive: true` + observed data | -| `apps/cloud/` (Cloudflare deployment) | **shippable scaffold** | HMAC verify + queue→D1 + DO submit live; OAuth deferred | -| `apps/insights/` (Solid SPA) | **scaffold** | DuckDB-WASM cache.db read works; the SPA pages need real-world iteration | -| `packages/otel-bridge/` | **shippable** | env-var auto-attach in `run()`; ships event stream to any OTLP backend | +| Surface | Maturity | Notes | +| -------------------------------------------------- | -------------------------------- | ---------------------------------------------------------------------- | +| Core task runner + caching | **production-ready** | dogfooded continuously; 836 tests pre-existing, all green | +| `vx mcp` | **shippable** | live cache.db tools, stdio; agents work today | +| `vx serve` (WS + SSE + NDJSON, JSON-RPC 2.0) | **shippable** | accepts both legacy + new envelope; `curl` works | +| `vx coordinator` + `vx run --worker` | **shippable for self-hosted CI** | content-addressed assignment, disconnect recovery | +| Plugin API | **shippable** | crash-isolated, lifecycle hooks fire end-to-end | +| Predictive scheduling | **shippable as opt-in** | gated on `predictive: true` + observed data | +| `apps/insights/` (Solid SPA → vx serve HTTP) | **scaffold** | connection picker, HTTP /v1/\* reads; pages need real-world iteration | +| OTel native emit (`src/orchestrator/otel-emit.ts`) | **shippable** | env-var auto-attach in `run()`; ships event stream to any OTLP backend | ## Development diff --git a/apps/cloud/README.md b/apps/cloud/README.md deleted file mode 100644 index 8e20029..0000000 --- a/apps/cloud/README.md +++ /dev/null @@ -1,103 +0,0 @@ -# @vzn/vx-cloud - -vx Cloud is a Cloudflare Workers reference deployment for hosted -observability, cache, and execution for `@vzn/vx`. Same code runs the -hosted SaaS at `cloud.vx.dev`; clone it, deploy into your own CF -account, and you have a private hosted vx for your team. - -See [`docs/design/vx-cloud-2026-06.md`](../../docs/design/vx-cloud-2026-06.md) -for the architecture rationale and -[`docs/design/wire-protocol-2026-06.md`](../../docs/design/wire-protocol-2026-06.md) -for the JSON-RPC 2.0 envelope every endpoint speaks. - -## Bindings at a glance - -| Binding | Type | Purpose | -| ----------------- | ----------------------- | ------------------------------------------------------------- | -| `DB` | D1 | orgs, members, api_tokens, runs, run_tasks, run_events | -| `ARTIFACTS` | R2 | content-addressed cache artifacts, per-org key prefix | -| `TOKEN_CACHE` | KV | bearer-token → (org, role) lookup cache (TTL 60s) | -| `EVENT_INGEST` | Queue | buffer for `/v1/events/ingest`, consumer batches into D1 | -| `RUN_COORDINATOR` | Durable Object | one DO per live run; holds graph + WS subscribers | -| `INFLIGHT_DEDUP` | Durable Object | one DO per task hash; first-claim wins, others wait | - -## Routes - -| Method | Path | Purpose | -| ------ | ----------------------------- | ------------------------------------------------------ | -| GET | `/` | minimal status landing page | -| GET | `/health` | `{ok: true}` | -| GET | `/version` | protocol version + supported channels + RPC methods | -| PUT | `/v8/artifacts/:hash` | Turbo-wire cache PUT (HMAC-validated when key set) | -| GET | `/v8/artifacts/:hash` | Turbo-wire cache GET (HMAC-verified when key set) | -| HEAD | `/v8/artifacts/:hash` | R2 head check | -| POST | `/v1/events/ingest` | batched WireEvent uploader → queue → D1 | -| GET | `/v1/runs` | list org runs (most-recent first) | -| GET | `/v1/runs/:runId` | single run + tasks | -| GET | `/v1/runs/:runId/events` | SSE stream of WireEvents for a run | -| GET | `/v1/ws` | WS upgrade, delegates to `RunCoordinatorDO` | - -All `/v8/*` and `/v1/*` routes require `Authorization: Bearer `. -Loopback (`localhost`, `127.0.0.1`) bypasses auth for local development. - -## Deploy - -```sh -# From this directory. -bun install - -# One-time auth. -bun wrangler login - -# Provision the bindings (each command prints an id — paste into wrangler.toml). -bun wrangler d1 create vx_cloud -# → copy `database_id` into [[d1_databases]] block - -bun wrangler r2 bucket create vx-cloud-artifacts -# → no id needed; bucket_name is enough - -bun wrangler kv namespace create TOKEN_CACHE -# → copy `id` into [[kv_namespaces]] block - -bun wrangler queues create vx-event-ingest -bun wrangler queues create vx-event-ingest-dlq - -# Apply the D1 schema. -bun wrangler d1 migrations apply vx_cloud - -# (Optional) HMAC signing key for cache artifacts — Turbo-wire-compatible. -bun wrangler secret put VX_REMOTE_CACHE_SIGNATURE_KEY - -# Deploy. -bun wrangler deploy -``` - -The deploy URL prints at the end: `https://vx-cloud-.workers.dev`. -Point a `vx run` at it via: - -```sh -export VX_REMOTE_CACHE_URL=https://vx-cloud-.workers.dev/v8/artifacts -export VX_REMOTE_CACHE_TOKEN= -vx run build test -``` - -## Hyperdrive escape hatch - -D1 caps a single database at 10 GB. When your team outgrows it, swap -the `[[d1_databases]]` block for a [Hyperdrive](https://developers.cloudflare.com/hyperdrive/) -binding pointing at an external Postgres (RDS, Neon, Supabase). The -schema in `migrations/0001_init.sql` is compatible Postgres syntax -modulo `STRICT` keywords — port directly. - -## Status - -This is a **scaffold** (2026-06-21). Handlers are wired and persist to -the right backends, but several pieces are TODO-marked: - -- HMAC signing/verification on cache PUT/GET -- per-run monotonic `seq` allocation in the queue consumer -- `submit.run` graph fan-out in `RunCoordinatorDO` -- waiter broadcast from `InflightDedupDO` -- GitHub OAuth login flow - -Track progress in [`docs/progress/`](../../docs/progress/). diff --git a/apps/cloud/migrations/0001_init.sql b/apps/cloud/migrations/0001_init.sql deleted file mode 100644 index d27ae9d..0000000 --- a/apps/cloud/migrations/0001_init.sql +++ /dev/null @@ -1,86 +0,0 @@ --- vx cloud — initial schema (D1 / SQLite). --- Mirrors docs/design/vx-cloud-2026-06.md §3, adapted to D1. --- All bigint columns (wallclock ns, cpu ms) stored as INTEGER: --- SQLite handles 64-bit signed integers natively. - -CREATE TABLE IF NOT EXISTS orgs ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - created_at INTEGER NOT NULL -); - -CREATE TABLE IF NOT EXISTS members ( - org_id TEXT NOT NULL, - github_id TEXT NOT NULL, - role TEXT NOT NULL CHECK (role IN ('admin', 'member', 'ci')), - PRIMARY KEY (org_id, github_id), - FOREIGN KEY (org_id) REFERENCES orgs(id) ON DELETE CASCADE -); - -CREATE TABLE IF NOT EXISTS api_tokens ( - id TEXT PRIMARY KEY, - org_id TEXT NOT NULL, - token_hash TEXT NOT NULL UNIQUE, - role TEXT NOT NULL CHECK (role IN ('admin', 'member', 'ci')), - expires_at INTEGER, - created_at INTEGER NOT NULL, - FOREIGN KEY (org_id) REFERENCES orgs(id) ON DELETE CASCADE -); - -CREATE INDEX IF NOT EXISTS api_tokens_org_idx ON api_tokens (org_id); - -CREATE TABLE IF NOT EXISTS runs ( - run_id TEXT PRIMARY KEY, - org_id TEXT NOT NULL, - repo TEXT, - branch TEXT, - commit_sha TEXT, - pr_number INTEGER, - triggered_by TEXT, - ci_provider TEXT, - started_at INTEGER NOT NULL, - ended_at INTEGER, - exit_code INTEGER, - cpu_ms INTEGER, - peak_rss_bytes INTEGER, - wallclock_start_ns INTEGER, - wallclock_end_ns INTEGER, - FOREIGN KEY (org_id) REFERENCES orgs(id) ON DELETE CASCADE -); - --- Tenant isolation: every query reads (org_id, …) so org_id leads. -CREATE INDEX IF NOT EXISTS runs_org_started_idx ON runs (org_id, started_at DESC); -CREATE INDEX IF NOT EXISTS runs_org_branch_idx ON runs (org_id, branch, started_at DESC); -CREATE INDEX IF NOT EXISTS runs_org_pr_idx ON runs (org_id, pr_number) WHERE pr_number IS NOT NULL; - -CREATE TABLE IF NOT EXISTS run_tasks ( - run_id TEXT NOT NULL, - task_id TEXT NOT NULL, - task_hash TEXT, - status TEXT NOT NULL CHECK (status IN ('success', 'failed', 'skipped', 'aborted')), - cache_source TEXT CHECK (cache_source IN ('miss', 'fresh', 'local', 'remote')), - duration_ms INTEGER, - cpu_ms INTEGER, - peak_rss_bytes INTEGER, - span_start_ns INTEGER, - span_end_ns INTEGER, - worker_id TEXT, - stdout_artifact TEXT, - stderr_artifact TEXT, - PRIMARY KEY (run_id, task_id), - FOREIGN KEY (run_id) REFERENCES runs(run_id) ON DELETE CASCADE -); - -CREATE INDEX IF NOT EXISTS run_tasks_hash_idx ON run_tasks (task_hash); -CREATE INDEX IF NOT EXISTS run_tasks_status_idx ON run_tasks (status); - -CREATE TABLE IF NOT EXISTS run_events ( - run_id TEXT NOT NULL, - seq INTEGER NOT NULL, - ts_ns INTEGER NOT NULL, - event_json TEXT NOT NULL, - PRIMARY KEY (run_id, seq), - FOREIGN KEY (run_id) REFERENCES runs(run_id) ON DELETE CASCADE -); - -CREATE INDEX IF NOT EXISTS run_events_run_ts_idx ON run_events (run_id, ts_ns); diff --git a/apps/cloud/package.json b/apps/cloud/package.json deleted file mode 100644 index 1889592..0000000 --- a/apps/cloud/package.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "name": "@vzn/vx-cloud", - "version": "0.0.0", - "private": true, - "type": "module", - "description": "vx Cloud — Cloudflare Workers reference deployment for hosted observability, cache, and execution.", - "scripts": { - "dev": "wrangler dev", - "deploy": "wrangler deploy", - "tail": "wrangler tail", - "test": "bun test tests/" - }, - "devDependencies": { - "@cloudflare/workers-types": "^4.20260601.0", - "hono": "^4.7.0", - "wrangler": "^4.0.0" - } -} diff --git a/apps/cloud/src/auth.ts b/apps/cloud/src/auth.ts deleted file mode 100644 index 7aa311c..0000000 --- a/apps/cloud/src/auth.ts +++ /dev/null @@ -1,58 +0,0 @@ -import type { Context, MiddlewareHandler } from 'hono' -import type { AuthContext, Env, Variables } from './env.js' - -const TOKEN_CACHE_TTL = 60 - -export const bearerAuth = - (): MiddlewareHandler<{ Bindings: Env; Variables: Variables }> => async (c, next) => { - const token = extractBearer(c.req.header('authorization')) - if (!token) { - if (isLoopback(c)) return next() - return c.json({ error: 'unauthorized' }, 401) - } - - const auth = await resolveAuth(c.env, token) - if (!auth) return c.json({ error: 'unauthorized' }, 401) - - c.set('auth', auth) - return next() - } - -function extractBearer(header: string | undefined): string | null { - if (!header) return null - const [scheme, value] = header.split(' ') - if (scheme?.toLowerCase() !== 'bearer' || !value) return null - return value.trim() -} - -function isLoopback(c: Context<{ Bindings: Env }>): boolean { - const host = c.req.header('host') ?? '' - return host.startsWith('127.0.0.1') || host.startsWith('localhost') -} - -async function resolveAuth(env: Env, token: string): Promise { - const tokenHash = await hashToken(token) - const cached = await env.TOKEN_CACHE.get(`token:${tokenHash}`, 'json') - if (cached) return cached as AuthContext - - const row = await env.DB.prepare( - 'SELECT id, org_id, role, expires_at FROM api_tokens WHERE token_hash = ?1 LIMIT 1', - ) - .bind(tokenHash) - .first<{ id: string; org_id: string; role: AuthContext['role']; expires_at: number | null }>() - - if (!row) return null - if (row.expires_at && row.expires_at < Date.now()) return null - - const auth: AuthContext = { orgId: row.org_id, tokenId: row.id, role: row.role } - await env.TOKEN_CACHE.put(`token:${tokenHash}`, JSON.stringify(auth), { - expirationTtl: TOKEN_CACHE_TTL, - }) - return auth -} - -async function hashToken(token: string): Promise { - const data = new TextEncoder().encode(token) - const digest = await crypto.subtle.digest('SHA-256', data) - return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('') -} diff --git a/apps/cloud/src/env.ts b/apps/cloud/src/env.ts deleted file mode 100644 index 285f791..0000000 --- a/apps/cloud/src/env.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { - D1Database, - DurableObjectNamespace, - KVNamespace, - Queue, - R2Bucket, -} from '@cloudflare/workers-types' - -export type Env = { - DB: D1Database - ARTIFACTS: R2Bucket - TOKEN_CACHE: KVNamespace - EVENT_INGEST: Queue - RUN_COORDINATOR: DurableObjectNamespace - INFLIGHT_DEDUP: DurableObjectNamespace - VX_PROTOCOL_VERSION: string - VX_REMOTE_CACHE_SIGNATURE_KEY?: string -} - -export type QueuedEvent = { - orgId: string - runId: string - seq: number - tsNs: string - eventJson: string -} - -export type AuthContext = { - orgId: string - tokenId: string - role: 'admin' | 'member' | 'ci' -} - -export type Variables = { - auth: AuthContext -} diff --git a/apps/cloud/src/hmac.ts b/apps/cloud/src/hmac.ts deleted file mode 100644 index b8ad287..0000000 --- a/apps/cloud/src/hmac.ts +++ /dev/null @@ -1,67 +0,0 @@ -// HMAC-SHA256 over (hash || teamId || body) — Turbo wire compatible. -// Mirrors src/cache/remote-cache.ts (HMAC validation on PUT, verify on -// GET, hard-fail-on-missing-tag-when-key-is-set). - -const enc = new TextEncoder() - -async function importKey(secret: string): Promise { - return await crypto.subtle.importKey( - 'raw', - enc.encode(secret), - { name: 'HMAC', hash: 'SHA-256' }, - false, - ['sign', 'verify'], - ) -} - -function toBase64(bytes: ArrayBuffer): string { - const arr = new Uint8Array(bytes) - let bin = '' - for (let i = 0; i < arr.length; i++) bin += String.fromCharCode(arr[i]!) - return btoa(bin) -} - -function fromBase64(s: string): Uint8Array { - const bin = atob(s) - const out = new Uint8Array(bin.length) - for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i) - return out -} - -/** Compute the tag for `(hash, teamId, body)`. */ -export async function computeArtifactTag( - secret: string, - hash: string, - teamId: string, - body: ArrayBuffer, -): Promise { - const key = await importKey(secret) - const prefix = enc.encode(hash + teamId) - const buf = new Uint8Array(prefix.length + body.byteLength) - buf.set(prefix, 0) - buf.set(new Uint8Array(body), prefix.length) - const sig = await crypto.subtle.sign('HMAC', key, buf) - return toBase64(sig) -} - -/** Verify a tag in constant time. */ -export async function verifyArtifactTag( - secret: string, - hash: string, - teamId: string, - body: ArrayBuffer, - expectedTag: string, -): Promise { - const key = await importKey(secret) - const prefix = enc.encode(hash + teamId) - const buf = new Uint8Array(prefix.length + body.byteLength) - buf.set(prefix, 0) - buf.set(new Uint8Array(body), prefix.length) - let expectedBytes: Uint8Array - try { - expectedBytes = fromBase64(expectedTag) - } catch { - return false - } - return await crypto.subtle.verify('HMAC', key, expectedBytes, buf) -} diff --git a/apps/cloud/src/index.ts b/apps/cloud/src/index.ts deleted file mode 100644 index 4761bed..0000000 --- a/apps/cloud/src/index.ts +++ /dev/null @@ -1,295 +0,0 @@ -import { Hono } from 'hono' -import { bearerAuth } from './auth.js' -import type { Env, QueuedEvent, Variables } from './env.js' -import { computeArtifactTag, verifyArtifactTag } from './hmac.js' -import type { WireEvent } from './wire.js' - -export { InflightDedupDO } from './inflight-dedup-do.js' -export { RunCoordinatorDO } from './run-coordinator-do.js' - -const app = new Hono<{ Bindings: Env; Variables: Variables }>() - -app.get('/', (c) => - c.html(`vx cloud - -

vx cloud

-protocol ${c.env.VX_PROTOCOL_VERSION} ·
/version · /health -

Hosted observability, cache, and execution for @vzn/vx. -See apps/cloud README to deploy.

`), -) - -app.get('/health', (c) => c.json({ ok: true })) - -app.get('/version', (c) => - c.json({ - protocol: c.env.VX_PROTOCOL_VERSION, - vx: '0.0.0', - channels: ['vx:events', 'vx:state', 'vx:rpc', 'vx:submit'], - rpc: [ - 'runTasks', - 'getRunState', - 'getCacheStats', - 'explainCacheKey', - 'whyDidThisRerun', - 'getRunHistory', - ], - }), -) - -// --- Turbo-wire-compatible cache (Turbo's /v8/artifacts/ shape, verbatim). - -const cache = new Hono<{ Bindings: Env; Variables: Variables }>() -cache.use('*', bearerAuth()) - -cache.put('/:hash', async (c) => { - const hash = c.req.param('hash') - const orgId = c.get('auth').orgId - const body = await c.req.arrayBuffer() - - // HMAC: when VX_REMOTE_CACHE_SIGNATURE_KEY is set, every PUT must - // carry an x-artifact-tag header we can verify. This matches the - // policy on the client side (src/cache/remote-cache.ts: a tampered - // artifact surfaces as a hard error so the client falls back). - const secret = c.env.VX_REMOTE_CACHE_SIGNATURE_KEY - let tag = c.req.header('x-artifact-tag') ?? '' - if (secret) { - if (!tag) { - return c.json({ error: 'x-artifact-tag required when signing is enabled' }, 400) - } - const ok = await verifyArtifactTag(secret, hash, orgId, body, tag) - if (!ok) { - return c.json({ error: 'artifact tag verification failed' }, 401) - } - } else if (!tag) { - // No signing configured: still compute + store a tag so reads can - // self-verify if signing is enabled later (best-effort integrity). - tag = '' - } - - await c.env.ARTIFACTS.put(artifactKey(orgId, hash), body, { - httpMetadata: { contentType: 'application/octet-stream' }, - customMetadata: { - duration: c.req.header('x-artifact-duration') ?? '0', - tag, - }, - }) - - return c.json({ urls: [`/v8/artifacts/${hash}`] }) -}) - -cache.get('/:hash', async (c) => { - const hash = c.req.param('hash') - const orgId = c.get('auth').orgId - const obj = await c.env.ARTIFACTS.get(artifactKey(orgId, hash)) - if (!obj) return c.notFound() - - const secret = c.env.VX_REMOTE_CACHE_SIGNATURE_KEY - const tag = obj.customMetadata?.['tag'] ?? '' - if (secret) { - if (!tag) { - // Hard fail: a signing deployment must not silently serve unsigned. - return c.json({ error: 'cached artifact missing tag under signing policy' }, 500) - } - const body = await obj.arrayBuffer() - const ok = await verifyArtifactTag(secret, hash, orgId, body, tag) - if (!ok) { - return c.json({ error: 'cached artifact tag verification failed' }, 500) - } - const headers = new Headers({ 'content-type': 'application/octet-stream' }) - const duration = obj.customMetadata?.['duration'] - if (duration) headers.set('x-artifact-duration', duration) - headers.set('x-artifact-tag', tag) - return new Response(body, { headers }) - } - - const headers = new Headers({ 'content-type': 'application/octet-stream' }) - const duration = obj.customMetadata?.['duration'] - if (duration) headers.set('x-artifact-duration', duration) - if (tag) headers.set('x-artifact-tag', tag) - return new Response(obj.body, { headers }) -}) - -cache.on('HEAD', '/:hash', async (c) => { - const hash = c.req.param('hash') - const orgId = c.get('auth').orgId - const obj = await c.env.ARTIFACTS.head(artifactKey(orgId, hash)) - return obj ? c.body(null, 200) : c.body(null, 404) -}) - -app.route('/v8/artifacts', cache) - -// --- Insights ingest + read APIs. - -const v1 = new Hono<{ Bindings: Env; Variables: Variables }>() -v1.use('*', bearerAuth()) - -v1.post('/events/ingest', async (c) => { - const { events } = (await c.req.json()) as { events: WireEvent[] } - const orgId = c.get('auth').orgId - - // Push to the queue; the consumer (queue() entry below) batches into D1. - const messages: { body: QueuedEvent }[] = events.map((e) => ({ - body: { - orgId, - runId: e.traceId, - seq: 0, // TODO: assign per-run monotonic seq in the consumer (D1 RETURNING) - tsNs: e.timeUnixNano, - eventJson: JSON.stringify(e), - }, - })) - await c.env.EVENT_INGEST.sendBatch(messages) - return c.body(null, 202) -}) - -v1.get('/runs', async (c) => { - const orgId = c.get('auth').orgId - // TODO: support cursor/limit/repo/branch filters from query string. - const rows = await c.env.DB.prepare( - 'SELECT run_id, repo, branch, commit_sha, started_at, ended_at, exit_code FROM runs WHERE org_id = ?1 ORDER BY started_at DESC LIMIT 100', - ) - .bind(orgId) - .all() - return c.json({ runs: rows.results ?? [] }) -}) - -v1.get('/runs/:runId', async (c) => { - const orgId = c.get('auth').orgId - const runId = c.req.param('runId') - const run = await c.env.DB.prepare( - 'SELECT * FROM runs WHERE org_id = ?1 AND run_id = ?2 LIMIT 1', - ) - .bind(orgId, runId) - .first() - if (!run) return c.json(null, 404) - const tasks = await c.env.DB.prepare( - 'SELECT * FROM run_tasks WHERE run_id = ?1 ORDER BY span_start_ns', - ) - .bind(runId) - .all() - return c.json({ run, tasks: tasks.results ?? [] }) -}) - -v1.get('/runs/:runId/events', async (c) => { - const orgId = c.get('auth').orgId - const runId = c.req.param('runId') - - // Confirm the run belongs to this org before streaming. - const owns = await c.env.DB.prepare( - 'SELECT 1 FROM runs WHERE org_id = ?1 AND run_id = ?2 LIMIT 1', - ) - .bind(orgId, runId) - .first() - if (!owns) return c.json({ error: 'run not found' }, 404) - - const rows = await c.env.DB.prepare( - 'SELECT seq, ts_ns, event_json FROM run_events WHERE run_id = ?1 ORDER BY seq', - ) - .bind(runId) - .all<{ seq: number; ts_ns: number; event_json: string }>() - - const stream = new ReadableStream({ - start(ctrl) { - const enc = new TextEncoder() - for (const row of rows.results ?? []) { - const envelope = `data: {"jsonrpc":"2.0","method":"events.append","params":${row.event_json}}\n\n` - ctrl.enqueue(enc.encode(envelope)) - } - ctrl.close() - }, - }) - - return new Response(stream, { - headers: { - 'content-type': 'text/event-stream', - 'cache-control': 'no-cache', - 'x-accel-buffering': 'no', - }, - }) -}) - -v1.get('/ws', (c) => { - if (c.req.header('upgrade') !== 'websocket') { - return c.json({ error: 'expected websocket upgrade' }, 426) - } - const runId = c.req.query('runId') - if (!runId) return c.json({ error: 'missing runId query param' }, 400) - - const id = c.env.RUN_COORDINATOR.idFromName(runId) - const stub = c.env.RUN_COORDINATOR.get(id) - // Forward the upgrade request to the DO; it returns the 101 + WS pair. - return stub.fetch(new URL('/ws', c.req.url).toString(), { - headers: c.req.raw.headers, - }) -}) - -app.route('/v1', v1) - -app.notFound((c) => c.json({ error: 'not found' }, 404)) - -app.onError((e, c) => { - console.error('cloud error', e) - return c.json({ error: 'internal error' }, 500) -}) - -export default { - fetch: app.fetch, - - async queue(batch: MessageBatch, env: Env): Promise { - // Group by runId so we allocate seq once per run via a single - // SELECT + sequential offsets. D1's batch() executes statements in - // order under one transaction — atomic and fast. - const byRun = new Map() - for (const msg of batch.messages) { - const list = byRun.get(msg.body.runId) - if (list) list.push(msg) - else byRun.set(msg.body.runId, [msg]) - } - - for (const [runId, msgs] of byRun) { - try { - // Ensure the runs row exists so the FK on run_events holds. - // First event from a run inserts the parent; subsequent events - // are no-ops due to ON CONFLICT DO NOTHING. - const firstEvent = JSON.parse(msgs[0]!.body.eventJson) as WireEvent - const orgId = msgs[0]!.body.orgId - await env.DB.prepare( - 'INSERT INTO runs (run_id, org_id, started_at) VALUES (?1, ?2, ?3) ON CONFLICT(run_id) DO NOTHING', - ) - .bind(runId, orgId, Number(firstEvent.timeUnixNano ?? Date.now() * 1_000_000) / 1_000_000) - .run() - - // Allocate seqs. - const start = await env.DB.prepare( - 'SELECT COALESCE(MAX(seq), 0) AS m FROM run_events WHERE run_id = ?1', - ) - .bind(runId) - .first<{ m: number }>() - let nextSeq = (start?.m ?? 0) + 1 - const stmts = msgs.map((m) => - env.DB.prepare( - 'INSERT INTO run_events (run_id, seq, ts_ns, event_json) VALUES (?1, ?2, ?3, ?4)', - ).bind(runId, nextSeq++, m.body.tsNs, m.body.eventJson), - ) - await env.DB.batch(stmts) - for (const m of msgs) m.ack() - } catch (e) { - console.error('queue insert failed', e) - for (const m of msgs) m.retry() - } - } - }, -} - -function artifactKey(orgId: string, hash: string): string { - return `${orgId}/${hash}.tar.zst` -} - -type MessageBatch = { - readonly messages: ReadonlyArray<{ - readonly body: T - ack(): void - retry(): void - }> -} diff --git a/apps/cloud/src/inflight-dedup-do.ts b/apps/cloud/src/inflight-dedup-do.ts deleted file mode 100644 index 5d7b0cf..0000000 --- a/apps/cloud/src/inflight-dedup-do.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { DurableObject } from 'cloudflare:workers' -import type { Env } from './env.js' - -// Content-addressed dedup: one DO per task hash (DO id = the hash). -// The first submitter for a hash becomes the OWNER; subsequent submitters -// become WAITERS that join the in-flight promise instead of re-running. -// The owner reports the outcome on completion; waiters receive it via -// /poll (long-poll) or future WS fan-out from the RunCoordinatorDO. - -type ClaimResult = - | { owner: true; workerId: string } - | { owner: false; waitForResult: true; ownerWorkerId: string } - -type Outcome = { - status: 'success' | 'failed' | 'skipped' | 'aborted' - hash: string - reportedAt: number -} - -type State = { - ownerWorkerId: string | null - outcome: Outcome | null - claimedAt: number -} - -const STATE_KEY = 'state' - -export class InflightDedupDO extends DurableObject { - override async fetch(request: Request): Promise { - const url = new URL(request.url) - switch (url.pathname) { - case '/claim': - return this.handleClaim(request) - case '/report': - return this.handleReport(request) - case '/poll': - return this.handlePoll() - default: - return new Response('not found', { status: 404 }) - } - } - - private async handleClaim(request: Request): Promise { - const { workerId } = (await request.json()) as { workerId: string } - const state = await this.loadState() - - if (state.ownerWorkerId === null) { - const next: State = { ownerWorkerId: workerId, outcome: null, claimedAt: Date.now() } - await this.ctx.storage.put(STATE_KEY, next) - const result: ClaimResult = { owner: true, workerId } - return Response.json(result) - } - - const result: ClaimResult = { - owner: false, - waitForResult: true, - ownerWorkerId: state.ownerWorkerId, - } - return Response.json(result) - } - - private async handleReport(request: Request): Promise { - const outcome = (await request.json()) as Outcome - const state = await this.loadState() - const next: State = { ...state, outcome: { ...outcome, reportedAt: Date.now() } } - await this.ctx.storage.put(STATE_KEY, next) - // TODO: broadcast to waiters via the RunCoordinatorDO once wired. - return Response.json({ ok: true }) - } - - private async handlePoll(): Promise { - const state = await this.loadState() - return Response.json({ outcome: state.outcome }) - } - - private async loadState(): Promise { - const stored = await this.ctx.storage.get(STATE_KEY) - return stored ?? { ownerWorkerId: null, outcome: null, claimedAt: 0 } - } -} diff --git a/apps/cloud/src/run-coordinator-do.ts b/apps/cloud/src/run-coordinator-do.ts deleted file mode 100644 index b2d85b4..0000000 --- a/apps/cloud/src/run-coordinator-do.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { DurableObject } from 'cloudflare:workers' -import type { Env } from './env.js' -import { - err, - isRequest, - notify, - ok, - RpcErrorCode, - type Envelope, - type WireEvent, -} from './wire.js' - -// One DO per active run. Holds: -// - the graph + ready queue (TODO: lifted from src/orchestrator) -// - registered workers (their WS connections, via Hibernation) -// - the live WireEvent log (appended to D1 + R2 via the EVENT_INGEST queue) -// - a set of subscriber WS connections (UI + cloud insights) -// -// WS Hibernation pattern: we use ctx.acceptWebSocket(ws) instead of ws.accept() -// so the runtime can hibernate the DO between messages. When a frame arrives -// the runtime rehydrates this object and calls webSocketMessage. State lives -// in ctx.storage; in-memory fields are lazy caches. - -type RunMeta = { - runId: string - orgId: string - startedAt: number - status: 'pending' | 'running' | 'ended' -} - -export class RunCoordinatorDO extends DurableObject { - override async fetch(request: Request): Promise { - const url = new URL(request.url) - if (url.pathname === '/ws') { - if (request.headers.get('upgrade') !== 'websocket') { - return new Response('expected websocket', { status: 426 }) - } - const pair = new WebSocketPair() - const [client, server] = [pair[0], pair[1]] - // WS Hibernation: accept via ctx, not ws.accept(); the DO can sleep. - this.ctx.acceptWebSocket(server) - return new Response(null, { status: 101, webSocket: client }) - } - if (url.pathname === '/append' && request.method === 'POST') { - const event = (await request.json()) as WireEvent - await this.appendEvent(event) - return Response.json({ ok: true }) - } - if (url.pathname === '/snapshot') { - return Response.json(await this.snapshot()) - } - return new Response('not found', { status: 404 }) - } - - override async webSocketMessage(ws: WebSocket, raw: string | ArrayBuffer): Promise { - let envelope: Envelope - try { - envelope = JSON.parse(typeof raw === 'string' ? raw : new TextDecoder().decode(raw)) - } catch { - ws.send(JSON.stringify(err(null, RpcErrorCode.ParseError, 'invalid JSON'))) - return - } - - if (!isRequest(envelope)) return // notifications: nothing to reply - - const { id, method, params } = envelope - switch (method) { - case 'submit.run': { - // Persist the run in storage; the per-task fan-out via inflight- - // dedup DOs lands as a follow-up — the contract is here and - // matches the local in-process executor. - const p = (params ?? {}) as { runId?: string; orgId?: string; tasks?: string[] } - const runId = p.runId ?? this.ctx.id.toString() - const orgId = p.orgId ?? 'unknown' - await this.ctx.storage.put('meta', { - runId, - orgId, - startedAt: Date.now(), - status: 'running', - }) - ws.send(JSON.stringify(ok(id, { accepted: true, runId }))) - return - } - case 'state.snapshot': - ws.send(JSON.stringify(ok(id, await this.snapshot()))) - return - case 'events.append': { - await this.appendEvent(params as WireEvent) - ws.send(JSON.stringify(ok(id, { ok: true }))) - return - } - case 'run.end': { - const meta = await this.snapshot() - if (meta) { - await this.ctx.storage.put('meta', { ...meta, status: 'ended' }) - } - ws.send(JSON.stringify(ok(id, { ok: true }))) - return - } - default: - ws.send(JSON.stringify(err(id, RpcErrorCode.MethodNotFound, `unknown method: ${method}`))) - } - // WS Hibernation: returning from webSocketMessage allows the DO to sleep - // until the next frame. No backgrounded work on `this` survives. - } - - override async webSocketClose(_ws: WebSocket, _code: number, _reason: string): Promise { - // No-op: ctx.getWebSockets() reflects the disconnect automatically. - } - - private async appendEvent(event: WireEvent): Promise { - const seq = ((await this.ctx.storage.get('seq')) ?? 0) + 1 - await this.ctx.storage.put('seq', seq) - - const subscribers = this.ctx.getWebSockets() - const frame = JSON.stringify(notify('events.append', event)) - for (const ws of subscribers) ws.send(frame) - - // TODO: enqueue to EVENT_INGEST for durable D1 persistence; the queue - // consumer batches into run_events. - } - - private async snapshot(): Promise { - return (await this.ctx.storage.get('meta')) ?? null - } -} diff --git a/apps/cloud/src/wire.ts b/apps/cloud/src/wire.ts deleted file mode 100644 index a4d7c55..0000000 --- a/apps/cloud/src/wire.ts +++ /dev/null @@ -1,90 +0,0 @@ -// JSON-RPC 2.0 envelope + WireEvent shape per docs/design/wire-protocol-2026-06.md. -// Kept local so this app stays a self-contained compile unit; the canonical -// definitions live in src/orchestrator/events.ts once the consolidation lands. - -export type RpcId = number | string - -export type RpcRequest

= { - jsonrpc: '2.0' - id: RpcId - method: string - params?: P -} - -export type RpcNotification

= { - jsonrpc: '2.0' - method: string - params?: P -} - -export type RpcResponse = { - jsonrpc: '2.0' - id: RpcId - result: R -} - -export type RpcError = { - jsonrpc: '2.0' - id: RpcId | null - error: { code: number; message: string; data?: unknown } -} - -export type Envelope = - | RpcRequest - | RpcNotification - | RpcResponse - | RpcError - -export type RunEventKind = - | 'run:start' - | 'task:start' - | 'task:stdout' - | 'task:stderr' - | 'task:complete' - | 'run:status' - | 'run:end' - -export type WireEvent = { - timeUnixNano: string - severityNumber: number - severityText?: string - body: string - attributes: Record - traceId: string - spanId?: string - 'vx.kind': RunEventKind -} - -export const RpcErrorCode = { - ParseError: -32700, - InvalidRequest: -32600, - MethodNotFound: -32601, - InvalidParams: -32602, - InternalError: -32603, - UserError: -32000, - TaskHashUnknown: -32001, - RunNotFound: -32002, - Unauthorized: -32003, - RateLimited: -32004, -} as const - -export function ok(id: RpcId, result: R): RpcResponse { - return { jsonrpc: '2.0', id, result } -} - -export function err( - id: RpcId | null, - code: number, - message: string, - data?: unknown, -): RpcError { - return { jsonrpc: '2.0', id, error: data === undefined ? { code, message } : { code, message, data } } -} - -export function notify

(method: string, params: P): RpcNotification

{ - return { jsonrpc: '2.0', method, params } -} - -export function isRequest(e: Envelope): e is RpcRequest { - return 'method' in e && 'id' in e -} diff --git a/apps/cloud/tests/hmac.test.ts b/apps/cloud/tests/hmac.test.ts deleted file mode 100644 index 036ea61..0000000 --- a/apps/cloud/tests/hmac.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -// HMAC compute/verify against the Turbo-compatible scheme -// (hash || teamId || body). Pure Web Crypto — runs in Bun too. - -import { describe, expect, it } from 'bun:test' -import { computeArtifactTag, verifyArtifactTag } from '../src/hmac.js' - -const KEY = 'super-secret-test-key' -const HASH = 'deadbeefdeadbeef' -const TEAM = 'acme' - -describe('HMAC artifact tag (Turbo wire compatible)', () => { - it('compute → verify round-trips on the same inputs', async () => { - const body = new TextEncoder().encode('hello world').buffer as ArrayBuffer - const tag = await computeArtifactTag(KEY, HASH, TEAM, body) - expect(typeof tag).toBe('string') - expect(tag.length).toBeGreaterThan(0) - const ok = await verifyArtifactTag(KEY, HASH, TEAM, body, tag) - expect(ok).toBe(true) - }) - - it('verify rejects a tampered body', async () => { - const body = new TextEncoder().encode('original').buffer as ArrayBuffer - const tampered = new TextEncoder().encode('tampered').buffer as ArrayBuffer - const tag = await computeArtifactTag(KEY, HASH, TEAM, body) - const ok = await verifyArtifactTag(KEY, HASH, TEAM, tampered, tag) - expect(ok).toBe(false) - }) - - it('verify rejects a wrong key', async () => { - const body = new TextEncoder().encode('body').buffer as ArrayBuffer - const tag = await computeArtifactTag(KEY, HASH, TEAM, body) - const ok = await verifyArtifactTag('other-key', HASH, TEAM, body, tag) - expect(ok).toBe(false) - }) - - it('verify rejects when hash differs', async () => { - const body = new TextEncoder().encode('body').buffer as ArrayBuffer - const tag = await computeArtifactTag(KEY, HASH, TEAM, body) - const ok = await verifyArtifactTag(KEY, 'other-hash', TEAM, body, tag) - expect(ok).toBe(false) - }) - - it('verify rejects when teamId differs', async () => { - const body = new TextEncoder().encode('body').buffer as ArrayBuffer - const tag = await computeArtifactTag(KEY, HASH, TEAM, body) - const ok = await verifyArtifactTag(KEY, HASH, 'other-team', body, tag) - expect(ok).toBe(false) - }) - - it('verify rejects malformed base64 tag without throwing', async () => { - const body = new TextEncoder().encode('body').buffer as ArrayBuffer - const ok = await verifyArtifactTag(KEY, HASH, TEAM, body, '!!!not-base64!!!') - expect(ok).toBe(false) - }) -}) diff --git a/apps/cloud/tsconfig.json b/apps/cloud/tsconfig.json deleted file mode 100644 index 36ae6c9..0000000 --- a/apps/cloud/tsconfig.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "extends": "../../tsconfig.json", - "compilerOptions": { - "lib": ["ES2023", "WebWorker"], - "types": ["@cloudflare/workers-types"], - "jsx": "react-jsx", - "jsxImportSource": "hono/jsx" - }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] -} diff --git a/apps/cloud/wrangler.toml b/apps/cloud/wrangler.toml deleted file mode 100644 index a32a3d1..0000000 --- a/apps/cloud/wrangler.toml +++ /dev/null @@ -1,56 +0,0 @@ -name = "vx-cloud" -main = "src/index.ts" -compatibility_date = "2026-06-20" -compatibility_flags = ["nodejs_compat"] - -# Environment variables (non-secret). Secrets like signing keys go via -# `wrangler secret put VX_REMOTE_CACHE_SIGNATURE_KEY` -[vars] -VX_PROTOCOL_VERSION = "1.0" - -# D1 — relational store (orgs, members, runs, run_tasks, run_events). -# Create with `bun wrangler d1 create vx_cloud`, paste the returned id below. -[[d1_databases]] -binding = "DB" -database_name = "vx_cloud" -database_id = "TODO: replace with id from `wrangler d1 create vx_cloud`" -migrations_dir = "migrations" - -# R2 — content-addressed cache artifacts (per-org key prefix). -# Create with `bun wrangler r2 bucket create vx-cloud-artifacts`. -[[r2_buckets]] -binding = "ARTIFACTS" -bucket_name = "vx-cloud-artifacts" - -# KV — token-cache + per-org feature flags. Sub-ms global reads. -# Create with `bun wrangler kv namespace create TOKEN_CACHE`, then paste id. -[[kv_namespaces]] -binding = "TOKEN_CACHE" -id = "TODO: replace with id from `wrangler kv namespace create TOKEN_CACHE`" - -# Queues — event-ingest buffer; CI noisy runs (500+ events/sec) absorb here. -# Create with `bun wrangler queues create vx-event-ingest`. -[[queues.producers]] -binding = "EVENT_INGEST" -queue = "vx-event-ingest" - -[[queues.consumers]] -queue = "vx-event-ingest" -max_batch_size = 100 -max_batch_timeout = 5 -max_retries = 3 -dead_letter_queue = "vx-event-ingest-dlq" - -# Durable Objects — stateful per-run coordinator + per-hash dedup. -[[durable_objects.bindings]] -name = "RUN_COORDINATOR" -class_name = "RunCoordinatorDO" - -[[durable_objects.bindings]] -name = "INFLIGHT_DEDUP" -class_name = "InflightDedupDO" - -# Migrations register the DO classes for v1 wrangler deploys. -[[migrations]] -tag = "v1" -new_classes = ["RunCoordinatorDO", "InflightDedupDO"] diff --git a/apps/docs/astro.config.mjs b/apps/docs/astro.config.mjs index fa7245a..e881823 100644 --- a/apps/docs/astro.config.mjs +++ b/apps/docs/astro.config.mjs @@ -70,8 +70,8 @@ export default defineConfig({ { label: 'Distributed CI execution', link: '/guides/distributed-ci/' }, { label: 'Writing a vx plugin', link: '/guides/plugins/' }, { label: 'Predictive scheduling', link: '/guides/predictive-scheduling/' }, - { label: 'vx insights — local dashboard', link: '/guides/insights/' }, - { label: 'vx Cloud (Cloudflare deploy)', link: '/guides/vx-cloud/' }, + { label: 'vx insights — local & hosted dashboard', link: '/guides/insights/' }, + { label: 'Self-host vx serve', link: '/guides/self-hosting/' }, { label: 'OpenTelemetry CI/CD spans', link: '/guides/otel-bridge/' }, { label: 'vx serve wire protocol', link: '/guides/wire-protocol/' }, ], diff --git a/apps/docs/src/content/docs/guides/insights.md b/apps/docs/src/content/docs/guides/insights.md index 27724a2..e11163a 100644 --- a/apps/docs/src/content/docs/guides/insights.md +++ b/apps/docs/src/content/docs/guides/insights.md @@ -1,44 +1,41 @@ --- -title: vx insights — local run dashboard -description: Boot a Solid + DuckDB-WASM SPA against your workspace's cache.db. Historical run flamegraphs, per-task trends, no backend, no daemon. +title: vx insights — local & hosted dashboard +description: A Solid SPA that talks to vx serve over HTTP. Same UI locally or against a remote server. Run history, flamegraphs, cache stats, no daemon. --- -`vx insights serve` opens a localhost dashboard backed by your -workspace's `cache.db`. Pure read-only analytics — no backend, no -upload, no daemon. The page reads SQLite in the browser via DuckDB-WASM. +`vx insights` opens a dashboard backed by `vx serve` — the same +unified backend used everywhere. Run it locally for a private, +client-side view of your workspace; or point a hosted copy of the +SPA at any reachable `vx serve` for a team view. One platform. ## Quick start ```sh cd your-workspace -vx insights serve +vx insights ``` -That prints two URLs: +This boots two foreground processes: -- The SPA on `http://127.0.0.1:5290` (Vite dev server). -- A tiny static HTTP server (kernel-assigned port) exposing - `cache.db` at `/cache.db` with the SQLite MIME so the SPA can - fetch it. +- **`vx serve`** on a kernel-assigned port, exposing the JSON `/v1/*` + insights API + the WebSocket run-submission protocol. +- **The SPA** (Vite dev server) on `http://127.0.0.1:5290`. -`Ctrl-C` stops both. - -Override the SPA port with `--port`: - -```sh -vx insights serve --port 7000 -``` +`Ctrl-C` stops both. Override the SPA port with `--port`. ## What you see -Two pages: - -- **Overview** — recent runs list, sorted by start time descending. - Each row shows project, task name, status, duration, cache - source. Click a row → run detail. +- **Overview** — cache stats cards (entries, total bytes, 24h hit + rate, runs/24h) plus a list of recent invocations sorted by start + time. Click a row → run detail. - **Run detail** — per-task timeline (flamegraph), one lane per - project, bars colored by status / cache source. The same data - drives both — replayable in the browser. + project, bars colored by status / cache source, plus the task + table. + +A **connection picker** in the top-right shows the current server +origin and a status dot. Click to paste a different URL and the +SPA reconnects — `http://localhost:4321` for another local server, +or `https://vx.your-company.com` for a hosted one. ## How it works @@ -46,19 +43,20 @@ Two pages: Browser ┌─────────────────────────────┐ │ apps/insights SPA (Solid) │ - │ • UnoCSS dark theme │ - │ • Solid Router (hash) │ - │ • DuckDB-WASM (~30 MB lazy)│ + │ • connection picker │ + │ • fetch over HTTP │ └────────┬────────────────────┘ - │ fetch /cache.db + │ GET /v1/runs, /v1/invocations, … + │ WS / (delegated run submission) ▼ ┌─────────────────────────────┐ - │ Tiny static server (Bun) │ - │ • cache.db @ vnd.sqlite3 │ + │ vx serve (Bun.serve) │ + │ • /v1/* JSON over cache.db │ │ • CORS * │ - │ • /health │ + │ • SSE event stream │ + │ • WS run protocol │ └────────┬────────────────────┘ - │ reads + │ bun:sqlite ▼ ┌─────────────────────────────┐ │ /.vx/cache/ │ @@ -66,54 +64,57 @@ Two pages: └─────────────────────────────┘ ``` -DuckDB-WASM reads SQLite files directly via the `sqlite_scanner` -extension — no ETL, no conversion. The SPA `ATTACH`-es the fetched -bytes as a database, runs aggregations client-side, renders Solid -components. Queries stay in the browser. +The SPA is platform-agnostic — every read is an HTTP call to a +configurable origin. Same JSON shape locally or hosted. -## What's needed on disk +## HTTP surface -- `/.vx/cache/cache.db` — at least one `vx run` - in the workspace. -- `/apps/insights/` — the SPA source. Set - `VX_INSIGHTS_DIR` if the installed binary can't find a checkout - alongside its `import.meta.dir`. +`vx serve` exposes a small JSON API: -If `cache.db` is missing, `vx insights` prints a clean hint and -exits 1. If the SPA scaffold is missing, the binary points you at -`VX_INSIGHTS_DIR`. +| Path | Returns | +| --- | --- | +| `GET /health` | `ok` | +| `GET /version` | `{ vx, protocol, workspace, channels, rpc }` | +| `GET /v1/runs?project=&task=&runId=&limit=` | per-task run rows | +| `GET /v1/invocations?limit=` | grouped per `runId` | +| `GET /v1/runs/:runId` | full run detail + tasks | +| `GET /v1/cache/stats` | entry count, size, 24h hit rate | +| `GET /v1/history?project=&task=&limit=` | per-task rollups + p50/p99 | +| `GET /v1/explain/:taskId` | latest cache-key entry for a task | +| `GET /v1/why/:runId/:taskId` | compare hash with previous run | +| `GET /events`, `GET /v1/events` | SSE stream of run events | -## Why client-side analytics +All routes ship `Access-Control-Allow-Origin: *` — the hosted SPA +must be able to reach localhost from a foreign origin. -- **Zero backend.** Nothing to provision, nothing to operate. The - static server just serves bytes. -- **Privacy by default.** Data never leaves your laptop. -- **Read-only by construction.** The SPA fetches `cache.db` once - per page load. Mutating queries can't touch your real cache. -- **Open at the data layer.** Anyone can write a DuckDB query - against `cache.db` directly — the SPA is just a UI on top. +## Host the SPA once, point it anywhere -For team-wide analytics, see the -[vx Cloud guide](/vx/guides/vx-cloud/). +Build `apps/insights/` once, deploy the static `dist/` to any +host. Users open it, paste their `vx serve` origin into the +connection picker, and the SPA does its thing. Browsers allow +HTTPS pages to call `http://localhost:*` per the Secure Context +exception, so a hosted `https://insights.example.com` can read +from a local `vx serve` running on `http://localhost:4321`. -## Known limits +This is the "Cloud" model — but the cloud is just a deployment +of `vx serve` (in Docker, on a VM, anywhere). No separate stack, +no Cloudflare Workers, no D1. -- **DuckDB-WASM is heavy** (~30 MB). First query is slow because - the WASM bundle and SQLite extension download. Subsequent queries - are fast. -- **No real-time.** The page snapshots `cache.db` on load. Reload - to see new runs. -- **Charts are minimal today.** The Overview and Run detail pages - ship; cache hit-rate trends, per-author breakdowns, and the - "Bottleneck atlas" from the cloud spec are scaffold-pending. +## Privacy -## What's coming +When you run `vx insights` locally, nothing leaves your machine. +A hosted SPA pointed at `http://localhost:*` is also entirely +local — the picker is just configuration; the page reads from +your machine, not a third party. -- More pages (per-task trends, cache cliff detection, regression - surfacing). -- Auto-refresh when `cache.db` mtime changes. -- An option to embed the SPA inside `vx serve` for an in-browser - live view of running runs. +## Known limits + +- **No real-time view yet.** SSE event streaming exists on the + server (`/v1/events`) but the SPA doesn't subscribe yet. Reload + to see new runs. +- **No auth.** vx serve binds to localhost by default; trust is by + network reachability. Add a reverse proxy with auth for hosted + deployments. -See also: `docs/design/vx-cloud-2026-06.md` §2.1 (local face), -`apps/insights/README.md`. +See also: [`Self-hosting`](/vx/guides/self-hosting/), +[`Wire protocol`](/vx/guides/wire-protocol/). diff --git a/apps/docs/src/content/docs/guides/otel-bridge.md b/apps/docs/src/content/docs/guides/otel-bridge.md index c09c158..5fca932 100644 --- a/apps/docs/src/content/docs/guides/otel-bridge.md +++ b/apps/docs/src/content/docs/guides/otel-bridge.md @@ -1,12 +1,12 @@ --- -title: OpenTelemetry CI/CD spans -description: Pipe every vx run's events into any OTLP-compatible backend (Grafana / Tempo / Honeycomb / Datadog / Jaeger). Single env var, single npm install, zero config in code. +title: OpenTelemetry CI/CD spans (native) +description: Pipe every vx run's events into any OTLP-compatible backend. No bridge package — core speaks OTel natively when the env var is set and the peer deps are installed. --- -vx ships an opt-in OpenTelemetry exporter at -`@vzn/vx-otel-bridge`. Set one env var, install one package, and -every `vx run` emits OTel CI/CD-conventions log records to your -existing observability stack. +vx core speaks OpenTelemetry CI/CD-conventions natively when the +optional `@opentelemetry/*` peer deps are installed and +`OTEL_EXPORTER_OTLP_ENDPOINT` points somewhere. No bridge package, +no custom wire format — just the OTLP/HTTP exporter you already use. ## Why OTel @@ -14,10 +14,10 @@ The OpenTelemetry CI/CD semantic conventions () define canonical attribute names for every CI concept: `cicd.pipeline.run.id`, `cicd.pipeline.task.name`, -`cicd.pipeline.task.run.result`, `cicd.worker.id`. By emitting in -this shape, vx events arrive at Grafana / Tempo / Honeycomb / -Datadog / Jaeger / your-self-hosted-collector without any -integration code — they already understand the spec. +`cicd.pipeline.task.run.result`, `cicd.worker.id`. Emitting in +this shape means vx events arrive at Grafana / Tempo / Honeycomb / +Datadog / Jaeger / your-self-hosted-collector with zero +integration code. ## Quick start @@ -26,43 +26,40 @@ integration code — they already understand the spec. export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 export OTEL_SERVICE_NAME=vx -# 2. Install the bridge in your workspace -bun add @vzn/vx-otel-bridge +# 2. Install the three OTel optional peer deps in your workspace +bun add @opentelemetry/api-logs \ + @opentelemetry/sdk-logs \ + @opentelemetry/exporter-logs-otlp-http # 3. Run anything vx run lint ``` -vx detects the env var, dynamically imports the bridge, and -attaches it as an additional event-bus subscriber. Every task's -lifecycle becomes an OTel log record. - -If the env var is unset, or the bridge package isn't installed, -core gains nothing — the runtime stays at 19 packages. +vx checks the env var, dynamically imports the peers, attaches a +log-record processor to the in-process event bus, and pushes every +event through the OTLP/HTTP exporter. Missing env var = silent +skip. Missing peer deps = silent skip. Neither path blocks a run. ## What lands in your backend -For each task, vx emits a `task:complete` record shaped like: +Each task's lifecycle becomes an OTel log record: ```jsonc { - "timeUnixNano": "1719009123000000000", - "severityNumber": 9, // INFO; 17 for failed + "timestamp": 1719009123000, + "severityNumber": 9, // INFO; ERROR for failed "severityText": "info", - "body": "pkg-a#build → success (123ms)", - "traceId": "01931d80-2c0c-7000-8000-000000000000", // vx run id - "spanId": "pkg-a#build", // task id + "body": "task complete: pkg-a#build (success)", "attributes": { "vx.kind": "task:complete", - "cicd.pipeline.run.id": "01931d80-2c0c-7000-8000-000000000000", + "cicd.pipeline.run.id": "run-1-1719009123000", "cicd.pipeline.task.name": "pkg-a#build", "cicd.pipeline.task.run.result": "success", - "vx.outcome": { - "status": "success", - "exitCode": 0, - "durationMs": 123, - "cacheHit": false - } + "vx.task.id": "pkg-a#build", + "vx.outcome.status": "success", + "vx.outcome.exit_code": 0, + "vx.outcome.duration_ms": 123, + "vx.outcome.hash": "abc123…" } } ``` @@ -72,7 +69,7 @@ chunks become log bodies), `run:status`, and `run:end`. ## Backend pointers -The bridge speaks OTLP/HTTP — every major backend accepts it. +The exporter speaks OTLP/HTTP — every major backend accepts it. ```sh # Grafana Cloud / Tempo @@ -97,48 +94,37 @@ handles it). ## What this gives you -- **Per-run timelines.** Each `vx run` is a trace; each task is a - log record with the run's trace id. Tools that show distributed - traces show every task of a run grouped. -- **Per-task percentiles.** Honeycomb / Grafana can aggregate - `durationMs` by `cicd.pipeline.task.name` for p50/p99. -- **Regression alerts.** Set up an alert on "p99 of `lint` exceeds - baseline by 3×" and your CI dashboard pings before the team - notices. +- **Per-task percentiles.** Backends can aggregate + `vx.outcome.duration_ms` by `cicd.pipeline.task.name` for p50/p99. +- **Regression alerts.** Alert on "p99 of `lint` exceeds baseline + by 3×" and your CI dashboard pings before the team notices. - **Cross-build dashboards.** Filter by `cicd.pipeline.run.id` or by repo/branch/commit (when the cloud uploader carries them). ## How it works `vx run` checks `OTEL_EXPORTER_OTLP_ENDPOINT` at startup. If set -and `options.log` is undefined (i.e. the real CLI path, not an -embedder), it dynamically imports `@vzn/vx-otel-bridge` via a -string-variable specifier (so the optional peer doesn't bloat -core's dep tree). The bridge's `createOtelBridge({ endpoint, -serviceName }).attach(bus)` subscribes to the event bus and pushes -each event through an OTLP log-record exporter. +and `options.log` is undefined (the real CLI path, not an +embedder), `src/orchestrator/otel-emit.ts` dynamically imports the +three OTel peers via string-variable specifiers (so TS doesn't try +to resolve them at type-check time and core's dep tree stays at +the same baseline). It subscribes a log-record emitter to the +event bus that translates each WireEvent to an OTel `LogRecord` +with the right semantic-conventions attributes. -On `run:end`, the bridge flushes pending records and is detached. +On `run:end` the processor flushes pending records and is +detached. ## Limits today -- **Spans, not traces.** Each event is a log record correlated with - a synthetic span id — tools that prefer real spans (start/end - pairs) see flat log streams. Real spans are coming. -- **No metric export.** Only logs/events. Aggregations need to - happen on the backend side. -- **Local-only attribution.** `cicd.pipeline.run.id` is vx's run - UUIDv7; mapping to your CI job (e.g. GHA's `${{ github.run_id }}`) - takes a tiny shell wrapper or a future env-var fold. - -## Combining with vx Cloud - -`vx-cloud` (the Cloudflare deployment) also persists events to D1 -via the EVENT_INGEST queue. The two are independent — you can run -either, both, or neither. OTel is the "ship to my existing -observability stack"; vx Cloud is the "spin up vx-native dashboards -in my CF account." - -See also: `packages/otel-bridge/README.md`, -`docs/design/wire-protocol-2026-06.md` §4 (the OTel LogRecord -shape). +- **Spans, not traces.** Each event is a log record correlated + with `cicd.pipeline.run.id` — tools that prefer real spans + (start/end pairs) see flat log streams. Real spans are coming. +- **No metric export.** Only logs/events. Aggregations happen on + the backend. +- **Local-only attribution.** `cicd.pipeline.run.id` is generated + per `vx run`; mapping to your CI job (e.g. GHA's + `${{ github.run_id }}`) takes a tiny shell wrapper or a future + env-var fold. + +See also: [`Wire protocol`](/vx/guides/wire-protocol/). diff --git a/apps/docs/src/content/docs/guides/self-hosting.md b/apps/docs/src/content/docs/guides/self-hosting.md new file mode 100644 index 0000000..4514990 --- /dev/null +++ b/apps/docs/src/content/docs/guides/self-hosting.md @@ -0,0 +1,124 @@ +--- +title: Self-host vx serve +description: Deploy vx serve in Docker (or any container runtime). One process, one stack. Powers local CLI, hosted SPA, team analytics. +--- + +`vx serve` is the unified backend. The same binary that powers +`vx run` delegation locally also runs in Docker as a team-shared +endpoint. There is no separate cloud stack — `vx serve` is the +cloud. + +## What it gives you + +| Surface | Path | +| --- | --- | +| Health | `GET /health` | +| Capabilities | `GET /version` | +| Insights API (read-only) | `GET /v1/runs`, `/v1/invocations`, `/v1/runs/:id`, `/v1/cache/stats`, `/v1/history`, `/v1/explain/:taskId`, `/v1/why/:runId/:taskId` | +| Event stream | `GET /v1/events` (SSE), `GET /stream` (NDJSON) | +| Run submission | `WS /` (JSON envelopes) | + +All HTTP responses ship `Access-Control-Allow-Origin: *` so a +hosted SPA can read from your machine, and a self-hosted SPA can +read from the hosted endpoint. + +## Docker deploy + +A minimal Dockerfile: + +```dockerfile +FROM oven/bun:1 +WORKDIR /workspace +COPY . . +RUN bun install --frozen-lockfile +EXPOSE 4321 +CMD ["bun", "src/bin.ts", "serve", "--port", "4321"] +``` + +```sh +docker build -t vx-serve . +docker run --rm -p 4321:4321 vx-serve +``` + +Persisting the cache across container restarts means mounting +`/.vx/`: + +```sh +docker run --rm \ + -p 4321:4321 \ + -v $(pwd)/.vx:/workspace/.vx \ + vx-serve +``` + +## docker-compose + +```yaml +services: + vx-serve: + image: vx-serve:latest + ports: + - "4321:4321" + volumes: + - ./.vx:/workspace/.vx + - ./:/workspace:ro + environment: + VX_REMOTE_CACHE_URL: https://your-cache.example.com + VX_REMOTE_CACHE_TOKEN: ${VX_REMOTE_CACHE_TOKEN} +``` + +## Auth + TLS + +`vx serve` does not ship auth or TLS — by design, it's a Bun.serve +process that binds to a port. Front it with a reverse proxy +(nginx, Caddy, Traefik) for TLS termination and any auth scheme +your environment uses. + +A Caddy example: + +``` +vx.example.com { + reverse_proxy localhost:4321 + basicauth { + team {$BCRYPT_HASH} + } +} +``` + +## Point the SPA at it + +Build `apps/insights/` once and host the resulting `dist/`. Users +open it, paste the server origin into the connection picker, and +the SPA reads via `/v1/*`. No build step per user, no per-user +config — same SPA, any backend. + +```sh +cd apps/insights +bun install +bun run build +# Deploy dist/ to any static host (S3 + CloudFront, Vercel, GitHub +# Pages, your own nginx — anywhere). +``` + +## Browser → localhost gotcha + +The Secure Context exception in WHATWG lets HTTPS pages call +`http://localhost:*`. So a hosted `https://insights.example.com` +can read from `http://localhost:4321` without breaking the mixed- +content rule — that's intentional, and what the connection picker +exploits. + +## Why one stack + +Previous versions of vx shipped a separate Cloudflare Workers +project (`apps/cloud/`) for hosted use, with a different SQL +backend (D1 vs bun:sqlite), different runtime, different deploy +story. We unified on `vx serve`: + +- Bun is fine on any host (Docker is the lingua franca). +- One SQL backend (bun:sqlite) is one bug surface, one schema + migration story, one set of queries. +- The hosted SPA sees the same `/v1/*` shape locally or against a + multi-tenant deployment — no shimming. + +See also: [`insights`](/vx/guides/insights/), +[`wire protocol`](/vx/guides/wire-protocol/). diff --git a/apps/docs/src/content/docs/guides/vx-cloud.md b/apps/docs/src/content/docs/guides/vx-cloud.md deleted file mode 100644 index a74f9fc..0000000 --- a/apps/docs/src/content/docs/guides/vx-cloud.md +++ /dev/null @@ -1,177 +0,0 @@ ---- -title: vx Cloud — Cloudflare-template deployment -description: Spin up a private vx Cloud in your Cloudflare account in 5 minutes. Workers + R2 + D1 + Durable Objects + Queues + KV. HMAC artifact signing, queue→D1 event ingest, OAuth coming. ---- - -`apps/cloud/` in the vx repo is a Cloudflare Workers project that -ships **template-spawnable hosted observability + cache + execution**. -`bun wrangler deploy` from a fresh clone of the repo gives you a -private vx Cloud running in your own Cloudflare account in about five -minutes. No proprietary glue; the OSS binary IS the hosted runtime. - -This guide walks through the deploy. Full design: -`docs/design/vx-cloud-2026-06.md`. - -## What you get - -A Cloudflare Workers project with these bindings, all declared in -`apps/cloud/wrangler.toml`: - -| Binding | Purpose | -| --- | --- | -| **Workers** | Edge HTTP for cache + insights API + Turbo-wire endpoint | -| **R2** (`ARTIFACTS`) | Cache artifact storage (S3-API-compatible, **zero egress fees**) | -| **D1** (`DB`) | SQLite at the edge — orgs, members, tokens, runs, run_tasks, run_events | -| **Durable Objects** (`RUN_COORDINATOR`, `INFLIGHT_DEDUP`) | Stateful actors for per-run coordination + content-addressed dedup | -| **Queues** (`EVENT_INGEST`) | Buffered event ingest from CI runs into D1 | -| **KV** (`TOKEN_CACHE`) | Sub-ms hot lookups for bearer tokens | - -## Deploy - -Prerequisites: Cloudflare account, `bun` ≥ 1.3. - -```sh -git clone https://github.com/vznjs/vx -cd vx/apps/cloud -bun install -bun wrangler login # one-time auth -bun wrangler d1 create vx_cloud -bun wrangler r2 bucket create vx-cloud-artifacts -bun wrangler kv namespace create TOKEN_CACHE -bun wrangler queues create vx-event-ingest -bun wrangler d1 migrations apply vx_cloud -bun wrangler deploy -``` - -Each `create` command prints an ID — paste it into the matching -`TODO: replace with id from wrangler create` line in -`wrangler.toml`. Then `bun wrangler deploy` ships the worker; the -output URL is your vx Cloud origin. - -## Point your runner at it - -Once deployed, set two env vars in your CI: - -```sh -export VX_REMOTE_CACHE_URL=https://vx-cloud-.workers.dev/v8/artifacts -export VX_REMOTE_CACHE_TOKEN= -``` - -`vx run` now reads/writes the remote cache via the standard -Turbo-wire endpoint (which is what `apps/cloud/` exposes). - -## HMAC artifact signing - -Set `VX_REMOTE_CACHE_SIGNATURE_KEY` on **both** the client and the -Worker: - -```sh -# Client (CI machine running vx run) -export VX_REMOTE_CACHE_SIGNATURE_KEY= - -# Worker (set via wrangler) -bun wrangler secret put VX_REMOTE_CACHE_SIGNATURE_KEY -``` - -When set, every cache PUT carries a `x-artifact-tag` HMAC-SHA256 -header over `(hash || teamId || body)`. The Worker rejects unsigned -or tampered artifacts with 401/500; the client treats those as -cache miss and re-runs the task. Same scheme Turbo uses; the wire is -compatible. - -Tag scheme: `base64(HMAC-SHA256(secret, hash || teamId || body))`. - -## Event ingest pipeline - -``` - vx run (locally / CI) - │ POST /v1/events/ingest - ▼ - ┌──────────────┐ batch ┌────────────┐ consume ┌────────┐ - │ Worker route │ ─────────▶ │ Queue │ ─────────▶ │ Worker │ - │ │ │ EVENT_INGEST│ │ queue()│ - └──────────────┘ └────────────┘ └────┬───┘ - │ - groups by runId │ - ensures runs row│ - allocates seq │ - D1.batch INSERT │ - ▼ - ┌──────┐ - │ D1 │ - │ runs │ - │ run_ │ - │ events│ - └──────┘ -``` - -The consumer (`apps/cloud/src/index.ts` `queue()`) groups messages -by `runId`, ensures the parent `runs` row exists via `ON CONFLICT DO -NOTHING`, allocates `seq` once per run by SELECT MAX + offsets, and -batches inserts atomically via D1's `batch()` API. - -## RunCoordinatorDO - -One Durable Object per active run, addressed by `runId`. WebSocket -Hibernation pattern: the DO sleeps between events; cost is per -event, not per idle connection. Methods over the JSON-RPC envelope: - -- `submit.run` → persists `RunMeta` (runId, orgId, startedAt, - status='running') and accepts the run. -- `events.append` → broadcasts to subscribed WS clients + - durably persists via the queue. -- `state.snapshot` → returns the latest `RunMeta`. -- `run.end` → transitions `status='ended'`. - -## What's deferred - -The hard things; the doc tracks them: - -- **GitHub OAuth + multi-tenant org provisioning.** Today auth is - bearer-token only; tokens are inserted manually into the `api_tokens` - D1 table. -- **RBAC** beyond the column existing. -- **Per-task InflightDedupDO fan-out.** The DO class is shipped; - RunCoordinatorDO doesn't address by task hash yet. -- **Hosted SaaS at `cloud.vx.dev`.** When you can spin up your own, - the SaaS is just convenience. -- **Hyperdrive escape hatch.** Designed for when D1's 10GB cap is - tight; not wired into `wrangler.toml` by default. - -## Costs - -Cloudflare free tier covers small teams forever: - -- Workers: 100k requests/day -- R2: 10 GB storage, **zero egress** -- D1: 5 GB/database, 100k reads/day -- DOs: 1M requests/month -- KV: 100k reads/day, 1k writes/day -- Queues: 1M operations/month - -At workload sizes where these limits bite, Hyperdrive into your own -Postgres is the escape hatch. - -## OSS-first guarantees - -There is no proprietary component in this stack. Every Worker, -every DO, every migration, every test is in this repo under -`apps/cloud/`. If `cloud.vx.dev` shuts down tomorrow, every customer -spins their own up in an afternoon. - -The hosted SaaS we may eventually run will be one CF account -deployment of the same template you just deployed — no special -branch, no closed-source modules, no "community edition" with -crippled features. - -## Tests - -```sh -cd apps/cloud -bun test tests/ # HMAC compute/verify round-trips -``` - -Hardcoded into CI as the apps/cloud test task. - -See also: `docs/design/vx-cloud-2026-06.md`, -`apps/cloud/README.md` (the deploy guide that ships with the template). diff --git a/apps/docs/src/content/docs/guides/wire-protocol.md b/apps/docs/src/content/docs/guides/wire-protocol.md index 948923d..76739fb 100644 --- a/apps/docs/src/content/docs/guides/wire-protocol.md +++ b/apps/docs/src/content/docs/guides/wire-protocol.md @@ -174,7 +174,7 @@ Bearer ` header on every request and on the WS handshake. Bash one-liner using SSE: ```sh -curl -N https://vx-cloud-xxx.workers.dev/events \ +curl -N https://vx.your-company.com/events \ -H "Authorization: Bearer $TOKEN" \ | jq -r '.params | select(.kind == "run:end")' \ | while read; do curl -X POST https://api.pushover.net/1/messages.json \ diff --git a/apps/docs/src/content/docs/introduction.md b/apps/docs/src/content/docs/introduction.md index c367016..2c63c53 100644 --- a/apps/docs/src/content/docs/introduction.md +++ b/apps/docs/src/content/docs/introduction.md @@ -72,15 +72,16 @@ require additional services: - **[Predictive scheduling](../guides/predictive-scheduling/)** — opt in with `predictive: true`; the scheduler reads run history and dispatches by expected remaining critical path. -- **[vx Cloud (Cloudflare)](../guides/vx-cloud/)** — `bun wrangler - deploy` from a fresh clone of `apps/cloud/` gives you a private vx - Cloud in your CF account in 5 minutes. -- **[vx insights serve](../guides/insights/)** — localhost - Solid+DuckDB-WASM SPA over your `cache.db`. Historical run - flamegraphs, no backend. -- **[OpenTelemetry CI/CD spans](../guides/otel-bridge/)** — single env - var, single npm install; every event lands in Grafana / Honeycomb / - Datadog / Tempo. +- **[Self-host vx serve](../guides/self-hosting/)** — drop the binary + in Docker, get a hosted backend with the same JSON `/v1/*` shape + the local one has. One stack, no separate cloud project. +- **[vx insights](../guides/insights/)** — Solid SPA over `vx serve`. + Run history, flamegraphs, cache stats. Connection picker switches + between local and hosted servers. +- **[OpenTelemetry CI/CD spans](../guides/otel-bridge/)** — set + `OTEL_EXPORTER_OTLP_ENDPOINT`, install the three OTel peers; every + event lands in Grafana / Honeycomb / Datadog / Tempo natively, no + bridge package. - **[Wire protocol](../guides/wire-protocol/)** — `vx serve` speaks JSON-RPC 2.0 over WS, SSE, and NDJSON. `curl -N http://localhost:5176/events | jq` streams every envelope. diff --git a/apps/docs/src/pages/index.astro b/apps/docs/src/pages/index.astro index 89323ff..03ba8a6 100644 --- a/apps/docs/src/pages/index.astro +++ b/apps/docs/src/pages/index.astro @@ -102,8 +102,8 @@ const platform = [ { icon: 'cloud', tone: 'var(--plasma)', - title: 'Cloudflare-template hosted', - body: 'apps/cloud is a Wrangler project. bun wrangler deploy gives you a private vx Cloud in your CF account in 5 minutes — Workers + R2 + D1 + Durable Objects + Queues + KV. Template-spawnable, OSS-first.', + title: 'Self-hostable, Docker-ready', + body: 'vx serve runs locally or in Docker — same backend everywhere. The hosted insights SPA points at any reachable origin via a connection picker. No separate cloud stack, no CF lock-in.', }, { icon: 'brain', @@ -462,7 +462,7 @@ const footCols = { : f.icon === 'wire' ? href('guides/wire-protocol/') : f.icon === 'cloud' - ? href('guides/vx-cloud/') + ? href('guides/self-hosting/') : href('guides/predictive-scheduling/') } > diff --git a/apps/insights/package.json b/apps/insights/package.json index a8792f7..4332509 100644 --- a/apps/insights/package.json +++ b/apps/insights/package.json @@ -9,7 +9,6 @@ "preview": "vite preview" }, "devDependencies": { - "@duckdb/duckdb-wasm": "^1.29.0", "@solidjs/router": "^0.15.0", "@unocss/preset-icons": "^0.65.0", "@unocss/preset-uno": "^0.65.0", diff --git a/apps/insights/src/api.ts b/apps/insights/src/api.ts index 024b0f6..dca45cb 100644 --- a/apps/insights/src/api.ts +++ b/apps/insights/src/api.ts @@ -1,92 +1,176 @@ -// Typed read-only queries against the local `cache.db`. The SPA never -// writes — DuckDB is in-browser, the SQLite is fetched once and queried -// from there. +// HTTP client for the vx serve insights API. Same shape locally or +// against a remote/hosted vx serve — the SPA is platform-agnostic. +// +// The base URL is resolved from the connection store; that store +// persists the user's choice to localStorage and defaults to +// http://localhost:4321 (vx serve's chosen origin lives in +// `.vx/serve.json`, but the SPA can't read disk — the user pastes +// the printed origin in, or accepts the default). -import { query } from './duckdb.ts' +import { createSignal } from 'solid-js' -export interface RunRow { - run_id: string +const STORAGE_KEY = 'vx-insights:origin' + +function defaultOrigin(): string { + // `vx insights` injects this at dev time; the hosted build falls back + // to the user choosing via the connection picker. + const injected = import.meta.env.VITE_DEFAULT_ORIGIN + if (typeof injected === 'string' && injected.length > 0) return injected + return 'http://localhost:4321' +} + +function readStoredOrigin(): string { + if (typeof localStorage === 'undefined') return defaultOrigin() + const stored = localStorage.getItem(STORAGE_KEY) + return stored ?? defaultOrigin() +} + +const [origin, setOrigin] = createSignal(readStoredOrigin()) + +export function getOrigin(): string { + return origin() +} + +export function getOriginSignal(): () => string { + return origin +} + +export function setOriginAndPersist(next: string): void { + const trimmed = next.replace(/\/+$/, '').trim() + setOrigin(trimmed) + if (typeof localStorage !== 'undefined') localStorage.setItem(STORAGE_KEY, trimmed) +} + +async function getJson(pathname: string): Promise { + const res = await fetch(`${origin()}${pathname}`, { + headers: { Accept: 'application/json' }, + }) + if (!res.ok) { + throw new Error(`${pathname}: ${res.status} ${res.statusText}`) + } + return (await res.json()) as T +} + +// --------------------------------------------------------------------------- +// Types — mirror src/orchestrator/insights-queries.ts return shapes. +// --------------------------------------------------------------------------- + +export interface RunSummaryRow { + runId: string | null project: string task: string status: string - exit_code: number - duration_ms: number - started_at: number - ended_at: number - cache_hit: number | null - cpu_ms: number | null - peak_rss_bytes: number | null - wallclock_start_ns: number | null - wallclock_end_ns: number | null -} - -export interface RunSummary { - run_id: string - started_at: number - ended_at: number - total: number - succeeded: number - failed: number - cache_hits: number - duration_ms: number + exitCode: number + durationMs: number + startedAt: number + endedAt: number + cacheHit: boolean | null + hash: string + wallclockStartNs: string | null + wallclockEndNs: string | null } -export interface CacheStats { - entries: number - total_bytes: number - hit_rate_24h: number - runs_24h: number -} - -export async function listRuns(limit = 50): Promise { - return await query(` - SELECT - run_id, - MIN(started_at) AS started_at, - MAX(ended_at) AS ended_at, - COUNT(*) AS total, - SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END) AS succeeded, - SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) AS failed, - SUM(COALESCE(cache_hit, 0)) AS cache_hits, - MAX(ended_at) - MIN(started_at) AS duration_ms - FROM cachedb.runs - WHERE run_id IS NOT NULL - GROUP BY run_id - ORDER BY started_at DESC - LIMIT ${limit} - `) -} - -export async function getRun(runId: string): Promise<{ +export interface InvocationRow { runId: string - tasks: RunRow[] -}> { - const safe = runId.replaceAll("'", "''") - const tasks = await query(` - SELECT run_id, project, task, status, exit_code, duration_ms, - started_at, ended_at, cache_hit, - cpu_ms, peak_rss_bytes, wallclock_start_ns, wallclock_end_ns - FROM cachedb.runs - WHERE run_id = '${safe}' - ORDER BY started_at ASC - `) - return { runId, tasks } + startedAt: number + endedAt: number + taskCount: number + failedCount: number + hitCount: number + totalDurationMs: number +} + +export interface RunDetail { + runId: string + startedAt: number + endedAt: number + tasks: RunSummaryRow[] +} + +export interface CacheStats { + entryCount: number + totalBytes: number + runCountLast24h: number + hitCountLast24h: number + hitRate24h: number +} + +export interface TaskHistoryRow { + id: string + runs: number + successRate: number + hitRate: number + failureMode: 'stable' | 'flaky-recoverable' | 'flaky-fatal' + p50DurationMs: number | undefined + p99DurationMs: number | undefined +} + +export interface CacheKeyExplanation { + taskId: string + project: string + task: string + latestEntry: { + hash: string + command: string + exitCode: number + durationMs: number + sizeBytes: number + createdAt: number + } | null + note: string +} + +export interface ServerVersion { + protocol: string + vx: string + workspace: string + channels: readonly string[] + rpc: readonly string[] +} + +// --------------------------------------------------------------------------- +// Calls +// --------------------------------------------------------------------------- + +export async function getVersion(): Promise { + return await getJson('/version') +} + +export async function listInvocations(limit = 50): Promise { + const r = await getJson<{ invocations: InvocationRow[] }>(`/v1/invocations?limit=${limit}`) + return r.invocations +} + +export async function listRuns( + args: { runId?: string; limit?: number } = {}, +): Promise { + const params = new URLSearchParams() + if (args.runId !== undefined) params.set('runId', args.runId) + if (args.limit !== undefined) params.set('limit', String(args.limit)) + const r = await getJson<{ runs: RunSummaryRow[] }>(`/v1/runs?${params.toString()}`) + return r.runs +} + +export async function getRun(runId: string): Promise { + try { + return await getJson(`/v1/runs/${encodeURIComponent(runId)}`) + } catch (err) { + if (err instanceof Error && err.message.includes('404')) return null + throw err + } } export async function getCacheStats(): Promise { - const rows = await query(` - WITH e AS ( - SELECT COUNT(*) AS entries, COALESCE(SUM(size_bytes), 0) AS total_bytes - FROM cachedb.entries - ), - r AS ( - SELECT - COUNT(*) AS runs_24h, - AVG(COALESCE(cache_hit, 0)::DOUBLE) AS hit_rate_24h - FROM cachedb.runs - WHERE started_at >= (epoch_ms(current_timestamp) - 86400000) - ) - SELECT e.entries, e.total_bytes, r.hit_rate_24h, r.runs_24h FROM e, r - `) - return rows[0] ?? { entries: 0, total_bytes: 0, hit_rate_24h: 0, runs_24h: 0 } + return await getJson('/v1/cache/stats') +} + +export async function getHistory(args: { limit?: number } = {}): Promise { + const params = new URLSearchParams() + if (args.limit !== undefined) params.set('limit', String(args.limit)) + const r = await getJson<{ history: TaskHistoryRow[] }>(`/v1/history?${params.toString()}`) + return r.history +} + +export async function explainCacheKey(taskId: string): Promise { + return await getJson(`/v1/explain/${encodeURIComponent(taskId)}`) } diff --git a/apps/insights/src/components/Flamegraph.tsx b/apps/insights/src/components/Flamegraph.tsx index d6c9796..052fbe3 100644 --- a/apps/insights/src/components/Flamegraph.tsx +++ b/apps/insights/src/components/Flamegraph.tsx @@ -1,5 +1,5 @@ import { For } from 'solid-js' -import type { RunRow } from '../api.ts' +import type { RunSummaryRow } from '../api.ts' import { layout, type LayoutInput } from '../flamegraph-layout.ts' const LANE_HEIGHT = 22 @@ -12,17 +12,17 @@ function colorFor(status: string, cacheHit: boolean): string { return 'bg-success/70' } -export function Flamegraph(props: { tasks: readonly RunRow[] }) { +export function Flamegraph(props: { tasks: readonly RunSummaryRow[] }) { const inputs = (): LayoutInput[] => props.tasks - .filter((t) => t.wallclock_start_ns !== null && t.wallclock_end_ns !== null) + .filter((t) => t.wallclockStartNs !== null && t.wallclockEndNs !== null) .map((t) => ({ taskId: `${t.project}#${t.task}`, project: t.project, - startNs: Number(t.wallclock_start_ns), - endNs: Number(t.wallclock_end_ns), + startNs: Number(t.wallclockStartNs), + endNs: Number(t.wallclockEndNs), status: t.status, - cacheHit: (t.cache_hit ?? 0) === 1, + cacheHit: t.cacheHit === true, })) const l = () => layout(inputs()) diff --git a/apps/insights/src/components/Shell.tsx b/apps/insights/src/components/Shell.tsx index 83bef3d..96f4c2e 100644 --- a/apps/insights/src/components/Shell.tsx +++ b/apps/insights/src/components/Shell.tsx @@ -1,7 +1,25 @@ -import type { ParentComponent } from 'solid-js' +import { createResource, createSignal, Show, type ParentComponent } from 'solid-js' import { A } from '@solidjs/router' +import { getOrigin, getOriginSignal, getVersion, setOriginAndPersist } from '../api.ts' export const Shell: ParentComponent = (props) => { + const origin = getOriginSignal() + const [version] = createResource(origin, async () => { + try { + return await getVersion() + } catch { + return null + } + }) + + const [editing, setEditing] = createSignal(false) + const [draft, setDraft] = createSignal(getOrigin()) + + function commit() { + setOriginAndPersist(draft()) + setEditing(false) + } + return (

@@ -9,7 +27,7 @@ export const Shell: ParentComponent = (props) => { vx insights -
{props.children}
- Read-only client-side analytics over your local cache.db. + + Not connected. Run vx serve in your workspace and paste its origin above. + + } + > + {(v) => ( + <> + vx {v().vx} · workspace {v().workspace} + + )} +
) diff --git a/apps/insights/src/duckdb.ts b/apps/insights/src/duckdb.ts deleted file mode 100644 index 87cb73d..0000000 --- a/apps/insights/src/duckdb.ts +++ /dev/null @@ -1,75 +0,0 @@ -// Lazy DuckDB-WASM loader. DuckDB reads SQLite files via the -// `sqlite_scanner` extension — but only from its own virtual -// filesystem. We can't `ATTACH 'http://...'` directly because the -// SQLite reader doesn't speak HTTP. The flow is: -// 1. fetch the cache.db bytes from the static server -// 2. register them as a virtual file via `db.registerFileBuffer` -// 3. `ATTACH '' AS cachedb (TYPE SQLITE)` -// -// Queries then read the live SQLite via `cachedb.` aliases -// exposed in api.ts. The bundle is ~30MB; loadDuckDb() is deferred -// until the first call. - -import type { AsyncDuckDB, AsyncDuckDBConnection } from '@duckdb/duckdb-wasm' - -let dbPromise: Promise<{ db: AsyncDuckDB; conn: AsyncDuckDBConnection }> | undefined - -function resolveCacheDbUrl(): string { - const injected = import.meta.env.VITE_CACHE_DB_URL - if (typeof injected === 'string' && injected.length > 0) return injected - // Default for local dev: `vx insights serve` boots a static server that - // exposes cache.db at /cache.db on the same origin. - return '/cache.db' -} - -async function fetchCacheDbBytes(url: string): Promise { - const res = await fetch(url, { cache: 'no-store' }) - if (!res.ok) { - throw new Error( - `vx insights: failed to fetch ${url} (${res.status} ${res.statusText}). ` + - `Is \`vx insights serve\` running? Did a \`vx run\` populate the cache yet?`, - ) - } - const buf = await res.arrayBuffer() - return new Uint8Array(buf) -} - -async function bootstrap(): Promise<{ db: AsyncDuckDB; conn: AsyncDuckDBConnection }> { - const duckdb = await import('@duckdb/duckdb-wasm') - const bundles = duckdb.getJsDelivrBundles() - const bundle = await duckdb.selectBundle(bundles) - // The worker URL must be served same-origin or with permissive CORS; the - // JsDelivr bundle handles that for us via a Blob shim. - const workerUrl = URL.createObjectURL( - new Blob([`importScripts("${bundle.mainWorker!}");`], { type: 'text/javascript' }), - ) - const worker = new Worker(workerUrl) - const logger = new duckdb.ConsoleLogger(duckdb.LogLevel.WARNING) - const db = new duckdb.AsyncDuckDB(logger, worker) - await db.instantiate(bundle.mainModule, bundle.pthreadWorker) - URL.revokeObjectURL(workerUrl) - - const conn = await db.connect() - // sqlite_scanner reads a SQLite file from DuckDB's own virtual - // filesystem. We fetch the bytes once and register them as a - // virtual file named 'cache.db'; ATTACH then reads from there. - await conn.query(`INSTALL sqlite_scanner; LOAD sqlite_scanner;`) - const cacheDbUrl = resolveCacheDbUrl() - const bytes = await fetchCacheDbBytes(cacheDbUrl) - await db.registerFileBuffer('cache.db', bytes) - await conn.query(`ATTACH 'cache.db' AS cachedb (TYPE SQLITE);`) - return { db, conn } -} - -export function loadDuckDb(): Promise<{ db: AsyncDuckDB; conn: AsyncDuckDBConnection }> { - if (!dbPromise) dbPromise = bootstrap() - return dbPromise -} - -export async function query = Record>( - sql: string, -): Promise { - const { conn } = await loadDuckDb() - const result = await conn.query(sql) - return result.toArray().map((row) => row.toJSON() as T) -} diff --git a/apps/insights/src/pages/Overview.tsx b/apps/insights/src/pages/Overview.tsx index e1a756d..f07c176 100644 --- a/apps/insights/src/pages/Overview.tsx +++ b/apps/insights/src/pages/Overview.tsx @@ -1,21 +1,37 @@ import { For, Show, createResource } from 'solid-js' import { useNavigate } from '@solidjs/router' -import { listRuns } from '../api.ts' +import { getCacheStats, getOriginSignal, listInvocations } from '../api.ts' import { formatDuration, formatRelativeTime } from '../format.ts' export function Overview() { - const [runs] = createResource(() => listRuns(50)) + const origin = getOriginSignal() + const [runs] = createResource(origin, () => listInvocations(50)) + const [stats] = createResource(origin, () => getCacheStats()) const navigate = useNavigate() return ( -
-

Recent runs

+
+ +
+ + + + +
+
+

Recent invocations

- Failed to load runs: {String(runs.error)} + Failed to load: {String(runs.error)}. Is vx serve running at {origin()}?
-
Loading DuckDB (one-time, ~30MB)…
+
Loading…
@@ -35,21 +51,21 @@ export function Overview() { {(r) => (
navigate(`/runs/${r.run_id}`)} + onClick={() => navigate(`/runs/${r.runId}`)} > - + - - + + - + )} @@ -65,3 +81,12 @@ export function Overview() { ) } + +function Stat(props: { label: string; value: string }) { + return ( +
+
{props.label}
+
{props.value}
+
+ ) +} diff --git a/apps/insights/src/pages/RunDetail.tsx b/apps/insights/src/pages/RunDetail.tsx index a2d4cce..0c92391 100644 --- a/apps/insights/src/pages/RunDetail.tsx +++ b/apps/insights/src/pages/RunDetail.tsx @@ -22,12 +22,12 @@ export function RunDetail() {
Failed to load: {String(run.error)}
- +
{run()!.tasks.length} task(s) {' · '} {formatDuration( - run()!.tasks.reduce((acc, t) => acc + Number(t.duration_ms ?? 0), 0), + run()!.tasks.reduce((acc, t) => acc + Number(t.durationMs ?? 0), 0), )}{' '} total
@@ -50,9 +50,9 @@ export function RunDetail() { {t.project}#{t.task}
- + )} @@ -61,6 +61,9 @@ export function RunDetail() {
{r.run_id.slice(0, 8)}…{r.runId.slice(0, 8)}… - {formatRelativeTime(Number(r.started_at))} + {formatRelativeTime(r.startedAt)} {formatDuration(Number(r.duration_ms))}{Number(r.total)}{formatDuration(r.totalDurationMs)}{r.taskCount} 0 }} + classList={{ 'text-failure': r.failedCount > 0 }} > - {Number(r.failed)} + {r.failedCount} {Number(r.cache_hits)}{r.hitCount}
{t.status}{formatDuration(Number(t.duration_ms))}{formatDuration(t.durationMs)} - {(t.cache_hit ?? 0) === 1 ? 'hit' : 'miss'} + {t.cacheHit === true ? 'hit' : 'miss'}
+ +
Run not found.
+
) } diff --git a/apps/insights/vite.config.ts b/apps/insights/vite.config.ts index 4034897..f881aa7 100644 --- a/apps/insights/vite.config.ts +++ b/apps/insights/vite.config.ts @@ -11,9 +11,4 @@ export default defineConfig({ preview: { port: 5290, }, - optimizeDeps: { - // DuckDB-WASM ships its own ESM workers + WASM blobs; let Vite bundle them - // as-is rather than pre-bundle and break the worker URL resolution. - exclude: ['@duckdb/duckdb-wasm'], - }, }) diff --git a/bun.lock b/bun.lock index 7c4bcfb..d3bf3f7 100644 --- a/bun.lock +++ b/bun.lock @@ -16,15 +16,6 @@ "oxlint-tsgolint": "^0.22.1", }, }, - "apps/cloud": { - "name": "@vzn/vx-cloud", - "version": "0.0.0", - "devDependencies": { - "@cloudflare/workers-types": "^4.20260601.0", - "hono": "^4.7.0", - "wrangler": "^4.0.0", - }, - }, "apps/docs": { "name": "@vzn/vx-docs", "version": "0.0.0", @@ -40,7 +31,6 @@ "name": "@vzn/vx-insights", "version": "0.0.0", "devDependencies": { - "@duckdb/duckdb-wasm": "^1.29.0", "@solidjs/router": "^0.15.0", "@unocss/preset-icons": "^0.65.0", "@unocss/preset-uno": "^0.65.0", @@ -51,22 +41,6 @@ "vite-plugin-solid": "^2.11.0", }, }, - "packages/otel-bridge": { - "name": "@vzn/vx-otel-bridge", - "version": "0.0.0", - "dependencies": { - "@opentelemetry/api": "^1.9.0", - "@opentelemetry/api-logs": "^0.205.0", - "@opentelemetry/exporter-logs-otlp-http": "^0.205.0", - "@opentelemetry/sdk-logs": "^0.205.0", - }, - "peerDependencies": { - "@vzn/vx": "*", - }, - "optionalPeers": [ - "@vzn/vx", - ], - }, }, "packages": { "@ampproject/remapping": ["@ampproject/remapping@2.3.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="], @@ -139,85 +113,65 @@ "@clack/prompts": ["@clack/prompts@1.5.1", "", { "dependencies": { "@clack/core": "1.4.1", "fast-string-width": "^3.0.2", "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-zccHj2z2oCCO4yrDiRSlFOxWerGqRiysP7a5jPK6uoI9URKAquwY42Dd/iUP8JWHxEzdRe4TlbvZCo8z1/mhrw=="], - "@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.5.0", "", {}, "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg=="], - - "@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.16.1", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": ">1.20260305.0 <2.0.0-0" }, "optionalPeers": ["workerd"] }, "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw=="], - - "@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260617.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-jWwmgEVVWbsHNrLSNXzwjJaH90VzRxq1cWkQFUidxyeUPnMxemeNE8I9qFAfrpzGgE11e9sKDcE3ettJW08swQ=="], - - "@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260617.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LHH7b565g9znfCUOkwbec6FG2rmRbsgCy6aJiU9KN662mNheWl5sw/iKleiFSiljPKQQP3HkjnC/NSkdgi/aSA=="], - - "@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260617.1", "", { "os": "linux", "cpu": "x64" }, "sha512-FMnaAKXe4Cfd8TQurCVd9fs2XQVBFRCsP+Id/SRdUv89MlwYu9zXfoyx6BxM+brPTIUK38SHbo8iaxiwzLi9JQ=="], - - "@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260617.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-MRoifFYcqbxxIIQy7PqO5tFY/qPFSnjXzakWl0sO93l+HLyG35jRAgOi6jfqa4kBxc7gKKtH861DcewjxUfkjA=="], - - "@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260617.1", "", { "os": "win32", "cpu": "x64" }, "sha512-rgBV9wQrv0OSKgCTTbhFUFY3sLGNANZ88aqaLvtmEn2gmbFVb1J4PDGochVUdB7NSEp4D/ghHva6/8SZmbONpw=="], - - "@cloudflare/workers-types": ["@cloudflare/workers-types@4.20260621.1", "", {}, "sha512-c4xrf4shZdDOK1ihh1UKzlS/3MDYiGThT/Oqr4Y3qR9NLCSNzHB7rt+Vk/LOp0ZSNjA+7WNJEQsOhpiQtpT2GA=="], - - "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="], - "@ctrl/tinycolor": ["@ctrl/tinycolor@4.2.0", "", {}, "sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A=="], - "@duckdb/duckdb-wasm": ["@duckdb/duckdb-wasm@1.32.0", "", { "dependencies": { "apache-arrow": "^17.0.0" } }, "sha512-IewXTNYEjsZCPE9weUWgtjGxUlMRo7qhX0GF6tq/KjK8bnY+RAl4cyUdYUfcdzbyb4b9ZxPC+FOsCcxgaKFWMg=="], - "@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], "@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="], "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], - "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="], - "@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="], + "@esbuild/android-arm": ["@esbuild/android-arm@0.27.7", "", { "os": "android", "cpu": "arm" }, "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ=="], - "@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="], + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.7", "", { "os": "android", "cpu": "arm64" }, "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ=="], - "@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="], + "@esbuild/android-x64": ["@esbuild/android-x64@0.27.7", "", { "os": "android", "cpu": "x64" }, "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg=="], - "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="], + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw=="], - "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="], + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ=="], - "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="], + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.7", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w=="], - "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="], + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.7", "", { "os": "freebsd", "cpu": "x64" }, "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ=="], - "@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="], + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.7", "", { "os": "linux", "cpu": "arm" }, "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA=="], - "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="], + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A=="], - "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="], + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.7", "", { "os": "linux", "cpu": "ia32" }, "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg=="], - "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="], + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q=="], - "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="], + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw=="], - "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="], + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.7", "", { "os": "linux", "cpu": "ppc64" }, "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ=="], - "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="], + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ=="], - "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="], + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.7", "", { "os": "linux", "cpu": "s390x" }, "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw=="], - "@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="], + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.7", "", { "os": "linux", "cpu": "x64" }, "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA=="], - "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="], + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.7", "", { "os": "none", "cpu": "arm64" }, "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w=="], - "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="], + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.7", "", { "os": "none", "cpu": "x64" }, "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw=="], - "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="], + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.7", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A=="], - "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="], + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.7", "", { "os": "openbsd", "cpu": "x64" }, "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg=="], - "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="], + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.7", "", { "os": "none", "cpu": "arm64" }, "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw=="], - "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="], + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.7", "", { "os": "sunos", "cpu": "x64" }, "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA=="], - "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="], + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA=="], - "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="], + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.7", "", { "os": "win32", "cpu": "ia32" }, "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw=="], - "@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="], + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.7", "", { "os": "win32", "cpu": "x64" }, "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg=="], "@expressive-code/core": ["@expressive-code/core@0.43.1", "", { "dependencies": { "@ctrl/tinycolor": "^4.0.4", "hast-util-select": "^6.0.2", "hast-util-to-html": "^9.0.1", "hast-util-to-text": "^4.0.1", "hastscript": "^9.0.0", "postcss": "^8.4.38", "postcss-nested": "^6.0.1", "unist-util-visit": "^5.0.0", "unist-util-visit-parents": "^6.0.1" } }, "sha512-H4rUJXKyS6y2q9Ig9bIp3dFhWhkZQIeH/jRGl3DROlslrGvfD4OC9qzmvKEFExm+/DtdvvHMQ8/Olmrcfxp+wQ=="], @@ -291,7 +245,7 @@ "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], - "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="], + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], "@mdx-js/mdx": ["@mdx-js/mdx@3.1.1", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdx": "^2.0.0", "acorn": "^8.0.0", "collapse-white-space": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "estree-util-scope": "^1.0.0", "estree-walker": "^3.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "markdown-extensions": "^2.0.0", "recma-build-jsx": "^1.0.0", "recma-jsx": "^1.0.0", "recma-stringify": "^1.0.0", "rehype-recma": "^1.0.0", "remark-mdx": "^3.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "source-map": "^0.7.0", "unified": "^11.0.0", "unist-util-position-from-estree": "^2.0.0", "unist-util-stringify-position": "^4.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ=="], @@ -301,28 +255,6 @@ "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.5", "", { "dependencies": { "@tybys/wasm-util": "^0.10.2" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q=="], - "@opentelemetry/api": ["@opentelemetry/api@1.9.1", "", {}, "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="], - - "@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.205.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-wBlPk1nFB37Hsm+3Qy73yQSobVn28F4isnWIBvKpd5IUH/eat8bwcL02H9yzmHyyPmukeccSl2mbN5sDQZYnPg=="], - - "@opentelemetry/core": ["@opentelemetry/core@2.1.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-RMEtHsxJs/GiHHxYT58IY57UXAQTuUnZVco6ymDEqTNlJKTimM4qPUPVe8InNFyBjhHBEAx4k3Q8LtNayBsbUQ=="], - - "@opentelemetry/exporter-logs-otlp-http": ["@opentelemetry/exporter-logs-otlp-http@0.205.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.205.0", "@opentelemetry/core": "2.1.0", "@opentelemetry/otlp-exporter-base": "0.205.0", "@opentelemetry/otlp-transformer": "0.205.0", "@opentelemetry/sdk-logs": "0.205.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-5JteMyVWiro4ghF0tHQjfE6OJcF7UBUcoEqX3UIQ5jutKP1H+fxFdyhqjjpmeHMFxzOHaYuLlNR1Bn7FOjGyJg=="], - - "@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.205.0", "", { "dependencies": { "@opentelemetry/core": "2.1.0", "@opentelemetry/otlp-transformer": "0.205.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-2MN0C1IiKyo34M6NZzD6P9Nv9Dfuz3OJ3rkZwzFmF6xzjDfqqCTatc9v1EpNfaP55iDOCLHFyYNCgs61FFgtUQ=="], - - "@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.205.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.205.0", "@opentelemetry/core": "2.1.0", "@opentelemetry/resources": "2.1.0", "@opentelemetry/sdk-logs": "0.205.0", "@opentelemetry/sdk-metrics": "2.1.0", "@opentelemetry/sdk-trace-base": "2.1.0", "protobufjs": "^7.3.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-KmObgqPtk9k/XTlWPJHdMbGCylRAmMJNXIRh6VYJmvlRDMfe+DonH41G7eenG8t4FXn3fxOGh14o/WiMRR6vPg=="], - - "@opentelemetry/resources": ["@opentelemetry/resources@2.1.0", "", { "dependencies": { "@opentelemetry/core": "2.1.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-1CJjf3LCvoefUOgegxi8h6r4B/wLSzInyhGP2UmIBYNlo4Qk5CZ73e1eEyWmfXvFtm1ybkmfb2DqWvspsYLrWw=="], - - "@opentelemetry/sdk-logs": ["@opentelemetry/sdk-logs@0.205.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.205.0", "@opentelemetry/core": "2.1.0", "@opentelemetry/resources": "2.1.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, "sha512-nyqhNQ6eEzPWQU60Nc7+A5LIq8fz3UeIzdEVBQYefB4+msJZ2vuVtRuk9KxPMw1uHoHDtYEwkr2Ct0iG29jU8w=="], - - "@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.1.0", "", { "dependencies": { "@opentelemetry/core": "2.1.0", "@opentelemetry/resources": "2.1.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-J9QX459mzqHLL9Y6FZ4wQPRZG4TOpMCyPOh6mkr/humxE1W2S3Bvf4i75yiMW9uyed2Kf5rxmLhTm/UK8vNkAw=="], - - "@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.1.0", "", { "dependencies": { "@opentelemetry/core": "2.1.0", "@opentelemetry/resources": "2.1.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-uTX9FBlVQm4S2gVQO1sb5qyBLq/FPjbp+tmGoxu4tIgtYGmBYB44+KX/725RFDe30yBSaA9Ml9fqphe1hbUyLQ=="], - - "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="], - "@oslojs/encoding": ["@oslojs/encoding@1.1.0", "", {}, "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ=="], "@oxc-parser/binding-android-arm-eabi": ["@oxc-parser/binding-android-arm-eabi@0.132.0", "", { "os": "android", "cpu": "arm" }, "sha512-KrLaPWa5c9Y7LkW+rKkaUE3y7DBDrQtaf7rlsSDfv6KAHUjgzAIRA761Lrrp6//Yd/Rlie/yEOt9YENCoJnOcw=="], @@ -475,30 +407,6 @@ "@pondwader/socks5-server": ["@pondwader/socks5-server@1.0.10", "", {}, "sha512-bQY06wzzR8D2+vVCUoBsr5QS2U6UgPUQRmErNwtsuI6vLcyRKkafjkr3KxbtGFf9aBBIV2mcvlsKD1UYaIV+sg=="], - "@poppinss/colors": ["@poppinss/colors@4.1.6", "", { "dependencies": { "kleur": "^4.1.5" } }, "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg=="], - - "@poppinss/dumper": ["@poppinss/dumper@0.6.5", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@sindresorhus/is": "^7.0.2", "supports-color": "^10.0.0" } }, "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw=="], - - "@poppinss/exception": ["@poppinss/exception@1.2.3", "", {}, "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw=="], - - "@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="], - - "@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="], - - "@protobufjs/codegen": ["@protobufjs/codegen@2.0.5", "", {}, "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g=="], - - "@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.1", "", {}, "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg=="], - - "@protobufjs/fetch": ["@protobufjs/fetch@1.1.1", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1" } }, "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw=="], - - "@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="], - - "@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="], - - "@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="], - - "@protobufjs/utf8": ["@protobufjs/utf8@1.1.1", "", {}, "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg=="], - "@rollup/pluginutils": ["@rollup/pluginutils@5.4.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg=="], "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.62.0", "", { "os": "android", "cpu": "arm" }, "sha512-IPIQ55ythEHkfEd9jMEi32OQ7SxURsGA43JI22lj01OLZNt2NUbJX8YUHxkVWyQ6daHPNn0truF5nSj3DQp6YQ=="], @@ -567,14 +475,8 @@ "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="], - "@sindresorhus/is": ["@sindresorhus/is@7.2.0", "", {}, "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw=="], - "@solidjs/router": ["@solidjs/router@0.15.4", "", { "peerDependencies": { "solid-js": "^1.8.6" } }, "sha512-WOpgg9a9T638cR+5FGbFi/IV4l2FpmBs1GpIMSPa0Ce9vyJN7Wts+X2PqMf9IYn0zUj2MlSJtm1gp7/HI/n5TQ=="], - "@speed-highlight/core": ["@speed-highlight/core@1.2.17", "", {}, "sha512-Z92FwKpCtfaW1V0jTU/fh3QzYEZN8wDwrzRIBoADCJfn4mJCNcJN/XegifX7BDrQ8/h9Xh/JnbyMchL0FqXrkg=="], - - "@swc/helpers": ["@swc/helpers@0.5.23", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw=="], - "@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="], "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], @@ -587,10 +489,6 @@ "@types/bun": ["@types/bun@1.3.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="], - "@types/command-line-args": ["@types/command-line-args@5.2.3", "", {}, "sha512-uv0aG6R0Y8WHZLTamZwtfsDLVRnOa+n+n5rEvFWL5Na5gZ8V2Teab/duDPFzIIIhs9qizDpcavCusCLJZu62Kw=="], - - "@types/command-line-usage": ["@types/command-line-usage@5.0.4", "", {}, "sha512-BwR5KP3Es/CSht0xqBcUXS3qCAUVXwpRKsV2+arxeb65atasuXG9LykC9Ab10Cw3s2raH92ZqOeILaQbsB2ACg=="], - "@types/d3": ["@types/d3@7.4.3", "", { "dependencies": { "@types/d3-array": "*", "@types/d3-axis": "*", "@types/d3-brush": "*", "@types/d3-chord": "*", "@types/d3-color": "*", "@types/d3-contour": "*", "@types/d3-delaunay": "*", "@types/d3-dispatch": "*", "@types/d3-drag": "*", "@types/d3-dsv": "*", "@types/d3-ease": "*", "@types/d3-fetch": "*", "@types/d3-force": "*", "@types/d3-format": "*", "@types/d3-geo": "*", "@types/d3-hierarchy": "*", "@types/d3-interpolate": "*", "@types/d3-path": "*", "@types/d3-polygon": "*", "@types/d3-quadtree": "*", "@types/d3-random": "*", "@types/d3-scale": "*", "@types/d3-scale-chromatic": "*", "@types/d3-selection": "*", "@types/d3-shape": "*", "@types/d3-time": "*", "@types/d3-time-format": "*", "@types/d3-timer": "*", "@types/d3-transition": "*", "@types/d3-zoom": "*" } }, "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww=="], "@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="], @@ -749,14 +647,10 @@ "@vue/shared": ["@vue/shared@3.5.38", "", {}, "sha512-FTW0AFZNaK5/mOqvGBwVfUlNLU38TiQn4+DQgIFUnrBBJQ1crMJ82yeGQLV5jyKFsO8yRukpbuP7x+nRbH6aug=="], - "@vzn/vx-cloud": ["@vzn/vx-cloud@workspace:apps/cloud"], - "@vzn/vx-docs": ["@vzn/vx-docs@workspace:apps/docs"], "@vzn/vx-insights": ["@vzn/vx-insights@workspace:apps/insights"], - "@vzn/vx-otel-bridge": ["@vzn/vx-otel-bridge@workspace:packages/otel-bridge"], - "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], "acorn": ["acorn@8.17.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg=="], @@ -767,20 +661,14 @@ "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], - "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], - "anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="], - "apache-arrow": ["apache-arrow@17.0.0", "", { "dependencies": { "@swc/helpers": "^0.5.11", "@types/command-line-args": "^5.2.3", "@types/command-line-usage": "^5.0.4", "@types/node": "^20.13.0", "command-line-args": "^5.2.1", "command-line-usage": "^7.0.1", "flatbuffers": "^24.3.25", "json-bignum": "^0.0.3", "tslib": "^2.6.2" }, "bin": { "arrow2csv": "bin/arrow2csv.cjs" } }, "sha512-X0p7auzdnGuhYMVKYINdQssS4EcKec9TCXyez/qtJt32DrIMGbzqiaMiQ0X6fQlQpw8Fl0Qygcv4dfRAr5Gu9Q=="], - "arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="], "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], "aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="], - "array-back": ["array-back@3.1.0", "", {}, "sha512-TkuxA4UCOvxuDK6NZYXCalszEzj+TLszyASooky+i742l9TqsOdYCMJJupxRic61hwquNtppB3hgcuq9SVSH1Q=="], - "array-iterate": ["array-iterate@2.0.1", "", {}, "sha512-I1jXZMjAgCMmxT4qxXfPXa6SthSoE8h6gkSI9BGGNv8mP8G/v0blc+qFnZu6K42vTOiuME596QaLO0TP3Lk0xg=="], "astring": ["astring@1.9.0", "", { "bin": { "astring": "bin/astring" } }, "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg=="], @@ -807,8 +695,6 @@ "birpc": ["birpc@4.0.0", "", {}, "sha512-LShSxJP0KTmd101b6DRyGBj57LZxSDYWKitQNW/mi8GRMvZb078Uf9+pveax1DrVL89vm7mWe+TovdI/UDOuPw=="], - "blake3-wasm": ["blake3-wasm@2.1.5", "", {}, "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g=="], - "body-parser": ["body-parser@2.3.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^2.0.0", "debug": "^4.4.3", "http-errors": "^2.0.1", "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", "qs": "^6.15.2", "raw-body": "^3.0.2", "type-is": "^2.1.0" } }, "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw=="], "boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="], @@ -833,10 +719,6 @@ "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], - "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], - - "chalk-template": ["chalk-template@0.4.0", "", { "dependencies": { "chalk": "^4.1.2" } }, "sha512-/ghrgmhfY8RaSdeo43hNXxpoHAtxdbskUHjPpfqUWGttFgycUhYPGx3YZBCnUCvOa7Doivn1IZec3DEGFoMgLg=="], - "character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="], "character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="], @@ -853,18 +735,10 @@ "collapse-white-space": ["collapse-white-space@2.1.0", "", {}, "sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw=="], - "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], - - "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], - "colorette": ["colorette@2.0.20", "", {}, "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w=="], "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], - "command-line-args": ["command-line-args@5.2.1", "", { "dependencies": { "array-back": "^3.1.0", "find-replace": "^3.0.0", "lodash.camelcase": "^4.3.0", "typical": "^4.0.0" } }, "sha512-H4UfQhZyakIjC74I9d34fGYDwk3XpSr17QhEd0Q3I9Xq1CETHo4Hcuo87WyWHpAF1aSLjLRf5lD9ZGX2qStUvg=="], - - "command-line-usage": ["command-line-usage@7.0.4", "", { "dependencies": { "array-back": "^6.2.2", "chalk-template": "^0.4.0", "table-layout": "^4.1.1", "typical": "^7.3.0" } }, "sha512-85UdvzTNx/+s5CkSgBm/0hzP80RFHAa7PsfeADE5ezZF3uHz3/Tqj9gIKGT9PTtpycc3Ua64T0oVulGfKxzfqg=="], - "commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="], "common-ancestor-path": ["common-ancestor-path@2.0.0", "", {}, "sha512-dnN3ibLeoRf2HNC+OlCiNc5d2zxbLJXOtiZUudNFSXZrNSydxcCsSpRzXwfu7BBWCIfHPw+xTayeBvJCP/D8Ng=="], @@ -1031,8 +905,6 @@ "entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], - "error-stack-parser-es": ["error-stack-parser-es@1.0.5", "", {}, "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA=="], - "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], @@ -1047,7 +919,7 @@ "esast-util-from-js": ["esast-util-from-js@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "acorn": "^8.0.0", "esast-util-from-estree": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw=="], - "esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="], + "esbuild": ["esbuild@0.27.7", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.7", "@esbuild/android-arm": "0.27.7", "@esbuild/android-arm64": "0.27.7", "@esbuild/android-x64": "0.27.7", "@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-x64": "0.27.7", "@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-x64": "0.27.7", "@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-x64": "0.27.7", "@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-x64": "0.27.7", "@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-x64": "0.27.7", "@esbuild/openharmony-arm64": "0.27.7", "@esbuild/sunos-x64": "0.27.7", "@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-x64": "0.27.7" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w=="], "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], @@ -1103,10 +975,6 @@ "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], - "find-replace": ["find-replace@3.0.0", "", { "dependencies": { "array-back": "^3.0.1" } }, "sha512-6Tb2myMioCAgv5kfvP5/PkZZ/ntTpVK39fHY7WkWBgvbeE+VHd/tZuZ4mrC+bxh4cfOZeYKVPaJIZtZXV7GNCQ=="], - - "flatbuffers": ["flatbuffers@24.12.23", "", {}, "sha512-dLVCAISd5mhls514keQzmEG6QHmUUsNuWsb4tFafIUwvvgDjXhtfAYSKOzt5SWOy+qByV5pbsDZ+Vb7HUOBEdA=="], - "flattie": ["flattie@1.1.1", "", {}, "sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ=="], "fontace": ["fontace@0.4.1", "", { "dependencies": { "fontkitten": "^1.0.2" } }, "sha512-lDMvbAzSnHmbYMTEld5qdtvNH2/pWpICOqpean9IgC7vUbUJc3k+k5Dokp85CegamqQpFbXf0rAVkbzpyTA8aw=="], @@ -1143,8 +1011,6 @@ "hachure-fill": ["hachure-fill@0.5.2", "", {}, "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg=="], - "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], - "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], @@ -1263,8 +1129,6 @@ "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], - "json-bignum": ["json-bignum@0.0.3", "", {}, "sha512-2WHyXj3OfHSgNyuzDbSxI1w2jgw5gkWSWhS7Qg4bWXx1nLk3jnbwfUeS0PSba3IzpTUWdHxBieELUzXRjQB2zg=="], - "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], @@ -1277,8 +1141,6 @@ "khroma": ["khroma@2.1.0", "", {}, "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw=="], - "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], - "klona": ["klona@2.0.6", "", {}, "sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA=="], "kolorist": ["kolorist@1.8.0", "", {}, "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ=="], @@ -1291,10 +1153,6 @@ "lodash-es": ["lodash-es@4.18.1", "", {}, "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A=="], - "lodash.camelcase": ["lodash.camelcase@4.3.0", "", {}, "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA=="], - - "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], - "longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="], "lru-cache": ["lru-cache@11.5.1", "", {}, "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A=="], @@ -1433,8 +1291,6 @@ "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], - "miniflare": ["miniflare@4.20260617.1", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "0.34.5", "undici": "7.28.0", "workerd": "1.20260617.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-Go3/gzStm99QHptsSgU+q1S+xDfLoRgwjJNY80kaTVi0ENhTyqKq+sc4xZiWBSbM7uUcJwmzm8+QFKtcYLJ9nw=="], - "mlly": ["mlly@1.8.2", "", { "dependencies": { "acorn": "^8.16.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.3" } }, "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA=="], "mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="], @@ -1511,7 +1367,7 @@ "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], - "path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="], + "path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], @@ -1541,8 +1397,6 @@ "property-information": ["property-information@7.2.0", "", {}, "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg=="], - "protobufjs": ["protobufjs@7.6.4", "", { "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" } }, "sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw=="], - "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], "qs": ["qs@6.15.2", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw=="], @@ -1689,12 +1543,8 @@ "stylis": ["stylis@4.4.0", "", {}, "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA=="], - "supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="], - "svgo": ["svgo@4.0.1", "", { "dependencies": { "commander": "^11.1.0", "css-select": "^5.1.0", "css-tree": "^3.0.1", "css-what": "^6.1.0", "csso": "^5.0.5", "picocolors": "^1.1.1", "sax": "^1.5.0" }, "bin": "./bin/svgo.js" }, "sha512-XDpWUOPC6FEibaLzjfe0ucaV0YrOjYotGJO1WpF0Zd+n6ZGEQUsSugaoLq9QkEZtAfQIxT42UChcssDVPP3+/w=="], - "table-layout": ["table-layout@4.1.1", "", { "dependencies": { "array-back": "^6.2.2", "wordwrapjs": "^5.1.0" } }, "sha512-iK5/YhZxq5GO5z8wb0bY1317uDF3Zjpha0QFFLA8/trAoiLbQD0HUbMesEaxyzUgDxi2QlcbM8IvqOlEjgoXBA=="], - "tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="], "tinyclip": ["tinyclip@0.1.14", "", {}, "sha512-F1oWdz8tjT17qe1d5JgDK6z03WGOhYYAN0lK3/D/fzNiy93xswLLEw7pk+3g05onhAy6Bsc6PLNUGhdgVjemMQ=="], @@ -1723,8 +1573,6 @@ "type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="], - "typical": ["typical@4.0.0", "", {}, "sha512-VAH4IvQ7BDFYglMd7BPRDfLgxZZX4O4TFcRDA6EN5X7erNJJq+McIEp8np9aVtxrCJ6qx4GTYVfOWNjcqwZgRw=="], - "ufo": ["ufo@1.6.4", "", {}, "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA=="], "ultrahtml": ["ultrahtml@1.6.0", "", {}, "sha512-R9fBn90VTJrqqLDwyMph+HGne8eqY1iPfYhPzZrvKpIfwkWZbcYlfpsb8B9dTvBfpy1/hqAD7Wi8EKfP9e8zdw=="], @@ -1733,12 +1581,8 @@ "uncrypto": ["uncrypto@0.1.3", "", {}, "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q=="], - "undici": ["undici@7.28.0", "", {}, "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA=="], - "undici-types": ["undici-types@7.21.0", "", {}, "sha512-w9IMgQrz4O0YN1LtB7K5P63vhlIOvC7opSmouCJ+ZywlPAlO9gIkJ+otk6LvGpAs2wg4econaCz3TvQ9xPoyuQ=="], - "unenv": ["unenv@2.0.0-rc.24", "", { "dependencies": { "pathe": "^2.0.3" } }, "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw=="], - "unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="], "unifont": ["unifont@0.7.4", "", { "dependencies": { "css-tree": "^3.1.0", "ofetch": "^1.5.1", "ohash": "^2.0.11" } }, "sha512-oHeis4/xl42HUIeHuNZRGEvxj5AaIKR+bHPNegRq5LV1gdc3jundpONbjglKpihmJf+dswygdMJn3eftGIMemg=="], @@ -1805,12 +1649,6 @@ "which-pm-runs": ["which-pm-runs@1.1.0", "", {}, "sha512-n1brCuqClxfFfq/Rb0ICg9giSZqCS+pLtccdag6C2HyufBrh3fBOiy9nb6ggRMvWOVH5GrdJskj5iGTZNxd7SA=="], - "wordwrapjs": ["wordwrapjs@5.1.1", "", {}, "sha512-0yweIbkINJodk27gX9LBGMzyQdBDan3s/dEAiwBOj+Mf0PPyWL6/rikalkv8EeD0E8jm4o5RXEOrFTP3NXbhJg=="], - - "workerd": ["workerd@1.20260617.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260617.1", "@cloudflare/workerd-darwin-arm64": "1.20260617.1", "@cloudflare/workerd-linux-64": "1.20260617.1", "@cloudflare/workerd-linux-arm64": "1.20260617.1", "@cloudflare/workerd-windows-64": "1.20260617.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-Re5pl6pdowt3ZmWUzGlOuB7jbRIIPetgKalmo4cYmucQnVhpo7/3e4MfpekbhLi2EhZZz5EY9NWRu8zFzuEZew=="], - - "wrangler": ["wrangler@4.103.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", "miniflare": "4.20260617.1", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260617.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20260617.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "cf-wrangler": "bin/cf-wrangler.js", "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-3Lv1P5t2xcSEkSTKtG+Lz+3JFryuU7YPLkaCUj7gNe+CJsjZJLtUwqsh1x595QBxkIbCE0GAvDx2DCJUU4+oqw=="], - "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], "ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], @@ -1823,32 +1661,20 @@ "yocto-queue": ["yocto-queue@1.2.2", "", {}, "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ=="], - "youch": ["youch@4.1.0-beta.10", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@poppinss/dumper": "^0.6.4", "@speed-highlight/core": "^1.2.7", "cookie": "^1.0.2", "youch-core": "^0.3.3" } }, "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ=="], - - "youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="], - "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], - "@ampproject/remapping/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], - "@anthropic-ai/sandbox-runtime/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "@babel/generator/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], - "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "@jridgewell/gen-mapping/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], - - "@jridgewell/remapping/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], - "@mdx-js/mdx/estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], "@oxc-parser/binding-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], @@ -1867,22 +1693,12 @@ "anymatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], - "apache-arrow/@types/node": ["@types/node@20.19.43", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA=="], - - "astro/esbuild": ["esbuild@0.27.7", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.7", "@esbuild/android-arm": "0.27.7", "@esbuild/android-arm64": "0.27.7", "@esbuild/android-x64": "0.27.7", "@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-x64": "0.27.7", "@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-x64": "0.27.7", "@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-x64": "0.27.7", "@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-x64": "0.27.7", "@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-x64": "0.27.7", "@esbuild/openharmony-arm64": "0.27.7", "@esbuild/sunos-x64": "0.27.7", "@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-x64": "0.27.7" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w=="], - "astro/vite": ["vite@7.3.5", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww=="], "babel-plugin-jsx-dom-expressions/@babel/helper-module-imports": ["@babel/helper-module-imports@7.18.6", "", { "dependencies": { "@babel/types": "^7.18.6" } }, "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA=="], "body-parser/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], - "chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - - "command-line-usage/array-back": ["array-back@6.2.3", "", {}, "sha512-SGDvmg6QTYiTxCBkYVmThcoa67uLl35pyzRHdpCGBOcqFy6BtwnphoFPk7LhJshD+Yk1Kt35WGWeZPTgwR4Fhw=="], - - "command-line-usage/typical": ["typical@7.3.0", "", {}, "sha512-ya4mg/30vm+DOWfBg4YK3j2WD6TWtRkCbasOJr40CseYENzCUby/7rIvXA99JGsQHeNxLbnXdyLLxKSv3tauFw=="], - "csso/css-tree": ["css-tree@2.2.1", "", { "dependencies": { "mdn-data": "2.0.28", "source-map-js": "^1.0.1" } }, "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA=="], "cytoscape-fcose/cose-base": ["cose-base@2.2.0", "", { "dependencies": { "layout-base": "^2.0.0" } }, "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g=="], @@ -1911,13 +1727,11 @@ "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], - "router/path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], - "sitemap/@types/node": ["@types/node@24.13.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA=="], "svgo/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="], - "table-layout/array-back": ["array-back@6.2.3", "", {}, "sha512-SGDvmg6QTYiTxCBkYVmThcoa67uLl35pyzRHdpCGBOcqFy6BtwnphoFPk7LhJshD+Yk1Kt35WGWeZPTgwR4Fhw=="], + "tsx/esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="], "type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], @@ -1929,60 +1743,6 @@ "@unocss/vite/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], - "apache-arrow/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], - - "astro/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="], - - "astro/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.7", "", { "os": "android", "cpu": "arm" }, "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ=="], - - "astro/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.7", "", { "os": "android", "cpu": "arm64" }, "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ=="], - - "astro/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.27.7", "", { "os": "android", "cpu": "x64" }, "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg=="], - - "astro/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw=="], - - "astro/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ=="], - - "astro/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.7", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w=="], - - "astro/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.7", "", { "os": "freebsd", "cpu": "x64" }, "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ=="], - - "astro/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.7", "", { "os": "linux", "cpu": "arm" }, "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA=="], - - "astro/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A=="], - - "astro/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.7", "", { "os": "linux", "cpu": "ia32" }, "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg=="], - - "astro/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q=="], - - "astro/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw=="], - - "astro/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.7", "", { "os": "linux", "cpu": "ppc64" }, "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ=="], - - "astro/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ=="], - - "astro/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.7", "", { "os": "linux", "cpu": "s390x" }, "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw=="], - - "astro/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.7", "", { "os": "linux", "cpu": "x64" }, "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA=="], - - "astro/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.7", "", { "os": "none", "cpu": "arm64" }, "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w=="], - - "astro/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.7", "", { "os": "none", "cpu": "x64" }, "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw=="], - - "astro/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.7", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A=="], - - "astro/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.7", "", { "os": "openbsd", "cpu": "x64" }, "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg=="], - - "astro/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.7", "", { "os": "none", "cpu": "arm64" }, "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw=="], - - "astro/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.7", "", { "os": "sunos", "cpu": "x64" }, "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA=="], - - "astro/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA=="], - - "astro/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.7", "", { "os": "win32", "cpu": "ia32" }, "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw=="], - - "astro/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.7", "", { "os": "win32", "cpu": "x64" }, "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg=="], - "csso/css-tree/mdn-data": ["mdn-data@2.0.28", "", {}, "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g=="], "cytoscape-fcose/cose-base/layout-base": ["layout-base@2.0.1", "", {}, "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg=="], @@ -2047,6 +1807,58 @@ "sitemap/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + "tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="], + + "tsx/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="], + + "tsx/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="], + + "tsx/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="], + + "tsx/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="], + + "tsx/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="], + + "tsx/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="], + + "tsx/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="], + + "tsx/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="], + + "tsx/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="], + + "tsx/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="], + + "tsx/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="], + + "tsx/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="], + + "tsx/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="], + + "tsx/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="], + + "tsx/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="], + + "tsx/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="], + + "tsx/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="], + + "tsx/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="], + + "tsx/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="], + + "tsx/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="], + + "tsx/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="], + + "tsx/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="], + + "tsx/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="], + + "tsx/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="], + + "tsx/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="], + "vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], "vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], diff --git a/docs/design/vx-cloud-2026-06.md b/docs/design/vx-cloud-2026-06.md index 2c6acd5..2335350 100644 --- a/docs/design/vx-cloud-2026-06.md +++ b/docs/design/vx-cloud-2026-06.md @@ -1,13 +1,18 @@ # vx Cloud — hosted observability, cache, and execution -Status: **Phases A-C SHIPPED 2026-06-21** as the `apps/cloud/` -Cloudflare-Workers scaffold + the HMAC validation + queue→D1 consumer - -- RunCoordinatorDO submit.run. Phases D-E (OAuth, hosted SaaS) deferred. - Owner ask: "hosted service where people could see local and company-wide - things." Pairs with `distributed-ci-2026-06.md` (the execution protocol) - and `remote-cache.md` (the existing cache transport). This is the - _observability + multi-tenancy_ layer on top. +> **SUPERSEDED 2026-06-21 (same day).** This design called for a +> separate Cloudflare-Workers stack (`apps/cloud/`) with its own SQL +> backend (D1), runtime, and deploy story. After shipping Phases A-C +> the owner cut it: "vx cloud is exactly the same as vx serve. We +> don't need a separate stack for it. It can run in Docker just +> fine." The unified path: `vx serve` is the cloud — same Bun +> binary, same `bun:sqlite` cache, same `/v1/*` HTTP shape — and it +> runs in Docker for hosted use. See +> [`apps/docs/src/content/docs/guides/self-hosting.md`](../../apps/docs/src/content/docs/guides/self-hosting.md) +> for the deploy story. `apps/cloud/` was deleted along with this +> directive. + +Status: **SUPERSEDED.** Original framing preserved below for context. ## Implementation snapshot (2026-06-21) diff --git a/docs/progress/implementation-log-2026-06.md b/docs/progress/implementation-log-2026-06.md index a0b7465..b92c41b 100644 --- a/docs/progress/implementation-log-2026-06.md +++ b/docs/progress/implementation-log-2026-06.md @@ -735,3 +735,68 @@ plus most of Wave 2-3 (HistoryTable, MCP, plugin API, predictive) and the Wave 4 scaffolds (apps/cloud, apps/insights). The PR materializes the carved-in-stone rules from `architecture-north-star-2026-06.md §3.x` as actual code. + +--- + +## Unification step — vx serve is the cloud (2026-06-21) + +Owner directive, same day: "vx cloud is exactly the same as vx serve. +We don't need a separate stack for it. It can run in Docker just fine. +Cf is no longer a requirement. Simplify and unify architecture where +possible only what we really need. Also instead of Otel bridge we +could natively talk in that. No custom format and standard." + +**Shipped.** + +- Delete `apps/cloud/` (entire CF Workers project — wrangler.toml, + R2/D1/DO/Queue/KV bindings, HMAC wrapper, queue→D1 consumer). +- Delete `packages/otel-bridge/` and `packages/*` from root workspaces. +- Inline OTel emit into core as `src/orchestrator/otel-emit.ts` using + dynamic specifier imports of three optional `@opentelemetry/*` peer + deps; missing peers / missing env var = silent skip. +- New `src/orchestrator/insights-queries.ts` — pure SQL over a + `bun:sqlite` Database (listRuns / listInvocations / getRun / + getCacheStatsSql / getHistory / explainCacheKey / whyDidThisRerun). + 10 unit tests in `tests/insights-queries.test.ts`. +- Extend `vx serve` with `GET /v1/runs`, `/v1/invocations`, + `/v1/runs/:id`, `/v1/cache/stats`, `/v1/history`, `/v1/explain/:id`, + `/v1/why/:runId/:taskId`, `/v1/events`, plus `Access-Control-Allow- +Origin: *` on every response and an `OPTIONS` 204 handler. `/version` + now also carries the workspace root. +- Rewrite `apps/insights/` SPA to drop DuckDB-WASM entirely and talk + to `vx serve` over HTTP. New connection picker in `Shell` (URL chip + - status dot, localStorage-persisted, defaults from + `VITE_DEFAULT_ORIGIN` at dev time). 13 files changed, + +422/-603 LOC. +- Simplify `vx insights` to boot `vx serve` + the SPA dev server and + pass `VITE_DEFAULT_ORIGIN` through. Drop the static cache.db + server. +- Docs: replace `vx-cloud.md` with `self-hosting.md` (Docker compose + example + Caddy auth/TLS recipe + the browser-localhost gotcha + documented); rewrite `otel-bridge.md` to "OpenTelemetry — native"; + rewrite `insights.md` with the connection-picker UX; update README + - introduction + landing page; mark `vx-cloud-2026-06.md` design + as superseded. + +**Decisions.** + +- **One SQL backend, one schema.** bun:sqlite for everything. The + CF/D1 path had its own (compatible) schema, queries, migrations; + the unification cut that surface entirely. +- **Wide-open CORS on `/v1/*`.** The hosted SPA must reach localhost + from a foreign origin. The surface is read-only + the WS run- + submission is gated by network reachability (vx serve binds to + localhost by default; production deploys front it with auth at a + reverse proxy). +- **OTel inline, not a package.** Dynamic-specifier import of three + `@opentelemetry/*` peers means a user opts in by setting the env + var _and_ installing the peers. Core's 19-dep tree stays untouched + for everyone else. + +**Tests.** 923 pass / 17 skip / 0 fail (was 919/17). Added 4 serve +HTTP-API tests + 10 insights-queries tests; removed 3 obsolete +static-server tests. + +**Net.** −1646/+896 LOC across 25 files in the first commit; another +−603/+422 across 13 files in the SPA refactor. One stack everywhere; +the SPA is a thin client; `vx serve` is the only backend. diff --git a/package.json b/package.json index 49c5945..60e93ac 100644 --- a/package.json +++ b/package.json @@ -24,8 +24,7 @@ }, "workspaces": [ ".", - "apps/*", - "packages/*" + "apps/*" ], "dependencies": { "@anthropic-ai/sandbox-runtime": "^0.0.51" diff --git a/packages/otel-bridge/README.md b/packages/otel-bridge/README.md deleted file mode 100644 index da2d58c..0000000 --- a/packages/otel-bridge/README.md +++ /dev/null @@ -1,100 +0,0 @@ -# @vzn/vx-otel-bridge - -Thin one-direction adapter that subscribes to a vx event bus and exports -[OpenTelemetry LogRecords](https://opentelemetry.io/docs/specs/otel/logs/data-model/) -over OTLP/HTTP. The vx core stays free of OTel runtime deps; this package -is the bridge. - -## Why - -Every vx wire event is already shaped to map cleanly to an OTel LogRecord -(see `docs/design/wire-protocol-2026-06.md` §4). The bridge applies the -[CI/CD semantic conventions](https://opentelemetry.io/docs/specs/semconv/cicd/cicd-spans/) -so any OTel-aware backend (Grafana, Honeycomb, Tempo, Datadog, Jaeger, -Splunk) reads vx runs without writing an integration. - -## Install - -```sh -bun add @vzn/vx-otel-bridge -``` - -## Usage - -```ts -import { createOtelBridge } from '@vzn/vx-otel-bridge' -import { createEventBus, run } from '@vzn/vx' - -const bus = createEventBus() -const bridge = createOtelBridge({ - endpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT, - serviceName: 'vx', -}) -const detach = bridge.attach(bus) - -await run({ tasks: ['build'], cwd: process.cwd(), bus }) - -detach() -await bridge.cleanup() -``` - -Once the vx Plugin API ships you'll wire this in `vx.workspace.ts`: - -```ts -import { defineWorkspace } from '@vzn/vx' -import { createOtelBridge } from '@vzn/vx-otel-bridge' - -export default defineWorkspace({ - plugins: [createOtelBridge()], -}) -``` - -## Environment variables - -The bridge follows the standard OTel discovery rules: - -| Variable | Effect | -| --------------------------------- | ---------------------------------------------------------- | -| `OTEL_EXPORTER_OTLP_ENDPOINT` | OTLP collector base URL (default `http://localhost:4318`). | -| `OTEL_SERVICE_NAME` | Resource attribute `service.name` (default `vx`). | -| `OTEL_EXPORTER_OTLP_HEADERS` | Comma-separated `key=value` auth headers. | -| `OTEL_RESOURCE_ATTRIBUTES` | Extra resource attributes. | - -Explicit options on `createOtelBridge({ endpoint, serviceName, headers })` -override env vars. - -## Pointing at a backend - -### Grafana / Tempo - -```sh -export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 -``` - -### Honeycomb - -```sh -export OTEL_EXPORTER_OTLP_ENDPOINT=https://api.honeycomb.io -export OTEL_EXPORTER_OTLP_HEADERS="x-honeycomb-team=YOUR_KEY" -``` - -Or wire via options: - -```ts -createOtelBridge({ - endpoint: 'https://api.honeycomb.io', - headers: { 'x-honeycomb-team': process.env.HONEYCOMB_KEY! }, -}) -``` - -## CI/CD semconv mapping - -| WireEvent field | LogRecord attribute | -| ------------------------------------- | ------------------------------- | -| `traceId` (vx run id) | `cicd.pipeline.run.id` | -| `attributes['vx.task.id']` | `cicd.pipeline.task.name` | -| `attributes['vx.outcome.status']` | `cicd.pipeline.task.run.result` | -| `attributes['vx.worker.id']` | `cicd.worker.id` | - -Everything else stays under the `vx.*` namespace (`vx.task.project`, -`vx.outcome.duration_ms`, `vx.outcome.cpu_ms`, …). diff --git a/packages/otel-bridge/package.json b/packages/otel-bridge/package.json deleted file mode 100644 index 232e2ee..0000000 --- a/packages/otel-bridge/package.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "name": "@vzn/vx-otel-bridge", - "version": "0.0.0", - "description": "Thin one-direction adapter that subscribes to the vx event bus and exports OTel LogRecords over OTLP.", - "type": "module", - "main": "./src/index.ts", - "types": "./src/index.ts", - "exports": { - ".": { - "types": "./src/index.ts", - "import": "./src/index.ts" - } - }, - "files": [ - "src", - "README.md" - ], - "dependencies": { - "@opentelemetry/api": "^1.9.0", - "@opentelemetry/api-logs": "^0.205.0", - "@opentelemetry/exporter-logs-otlp-http": "^0.205.0", - "@opentelemetry/sdk-logs": "^0.205.0" - }, - "peerDependencies": { - "@vzn/vx": "*" - }, - "peerDependenciesMeta": { - "@vzn/vx": { - "optional": true - } - }, - "publishConfig": { - "access": "public" - } -} diff --git a/packages/otel-bridge/src/index.ts b/packages/otel-bridge/src/index.ts deleted file mode 100644 index d8fe5b1..0000000 --- a/packages/otel-bridge/src/index.ts +++ /dev/null @@ -1,245 +0,0 @@ -// @vzn/vx-otel-bridge — a thin, one-direction adapter. Subscribes to a vx -// event bus, translates each WireEvent to an OTel LogRecord per the CI/CD -// semantic conventions, and pushes through an OTLP/HTTP exporter. The vx -// core stays free of OTel runtime deps; users wire this bridge themselves. - -import { SeverityNumber } from '@opentelemetry/api-logs' -import type { LogRecord } from '@opentelemetry/api-logs' -import { OTLPLogExporter } from '@opentelemetry/exporter-logs-otlp-http' -import { BatchLogRecordProcessor, LoggerProvider } from '@opentelemetry/sdk-logs' - -import type { EventBus, OtelBridge, OtelBridgeOptions, WireEvent } from './types.js' - -export type { EventBus, OtelBridge, OtelBridgeOptions, WireEvent } - -const SEMCONV = { - pipelineRunId: 'cicd.pipeline.run.id', - taskName: 'cicd.pipeline.task.name', - taskRunResult: 'cicd.pipeline.task.run.result', - workerId: 'cicd.worker.id', -} as const - -// The bus does not carry a runId; we derive one at attach time. A run begins -// at the first event after attach, ends at run:end. Multiple runs through -// the same bus would each get their own id via a stable counter — but vx -// today emits one run per bus, so this is a simple monotonically-incrementing -// id baked into a closure. -let runCounter = 0 - -export function mapToLogRecord(event: WireEvent, ctx?: { runId?: string }): LogRecord { - const timeUnixNano = String(Date.now()) + '000000' - const traceId = ctx?.runId ?? 'unknown' - const base: LogRecord = { - timestamp: Date.now(), - severityNumber: SeverityNumber.INFO, - severityText: 'info', - body: event.kind, - attributes: { - 'vx.kind': event.kind, - 'vx.time_unix_nano': timeUnixNano, - [SEMCONV.pipelineRunId]: traceId, - }, - } - const attrs = base.attributes as Record - - switch (event.kind) { - case 'run:start': - attrs['vx.run.total'] = event.info.total - if (event.info.concurrency !== undefined) attrs[SEMCONV.workerId] = event.info.concurrency - if (event.info.requestedCount !== undefined) - attrs['vx.run.requested_count'] = event.info.requestedCount - return base - - case 'task:start': - attrs['vx.task.id'] = event.task.id - attrs[SEMCONV.taskName] = event.task.task - attrs['vx.task.project'] = event.task.project - attrs['vx.task.requested'] = event.task.requested - attrs['vx.task.is_group'] = event.task.isGroup - attrs['vx.task.persistent'] = event.task.persistent - if (event.task.command !== undefined) attrs['vx.task.command'] = event.task.command - base.body = `task start: ${event.task.id}` - return base - - case 'task:stdout': - case 'task:stderr': - attrs['vx.task.id'] = event.taskId - base.body = event.chunk - if (event.kind === 'task:stderr') { - base.severityNumber = SeverityNumber.WARN - base.severityText = 'warn' - } - return base - - case 'task:complete': { - const { outcome } = event - attrs['vx.task.id'] = outcome.taskId - attrs[SEMCONV.taskRunResult] = outcome.status - attrs['vx.outcome.status'] = outcome.status - attrs['vx.outcome.exit_code'] = outcome.exitCode - attrs['vx.outcome.duration_ms'] = outcome.durationMs - if (outcome.hash !== undefined) attrs['vx.outcome.hash'] = outcome.hash - if (outcome.cpuMs !== undefined) attrs['vx.outcome.cpu_ms'] = outcome.cpuMs - if (outcome.peakRssBytes !== undefined) - attrs['vx.outcome.peak_rss_bytes'] = outcome.peakRssBytes - if (outcome.restored !== undefined) attrs['vx.outcome.restored'] = outcome.restored - if (outcome.wallclockStartNs !== undefined) - attrs['vx.outcome.wallclock_start_ns'] = outcome.wallclockStartNs - if (outcome.wallclockEndNs !== undefined) - attrs['vx.outcome.wallclock_end_ns'] = outcome.wallclockEndNs - if (outcome.status === 'failed') { - base.severityNumber = SeverityNumber.ERROR - base.severityText = 'error' - } - base.body = `task complete: ${outcome.taskId} (${outcome.status})` - return base - } - - case 'run:status': - base.body = event.line - return base - - case 'run:end': - base.body = 'run end' - return base - } -} - -// Minimal duck-typed projection of the in-process RunEvent to a WireEvent. -// Mirrors the shape of `wireForwarder` + `toWireEvent` in vx — the bridge -// reads only the fields that exist post-projection and never touches the -// live config / dep graph the RunEvent's node ref otherwise drags along. -type LiveRunEvent = - | { kind: 'run:start'; info: WireEvent extends { kind: 'run:start'; info: infer I } ? I : never } - | { kind: 'task:start'; node: LiveNode } - | { kind: 'task:stdout'; node: LiveNode; chunk: string } - | { kind: 'task:stderr'; node: LiveNode; chunk: string } - | { kind: 'task:complete'; node: LiveNode; outcome: LiveOutcome } - | { kind: 'run:status'; line: string } - | { kind: 'run:end' } - -type LiveNode = { - id: string - projectName: string - taskName: string - requested: boolean - surfaced?: boolean - config: { exec?: { command?: string; persistent?: unknown } } -} - -type LiveOutcome = { - node: LiveNode - status: string - exitCode: number - durationMs: number - hash?: string - cpuMs?: number - peakRssBytes?: number - restored?: boolean - sandboxViolations?: number - sandboxViolationLines?: string[] - wallclockStartNs?: bigint - wallclockEndNs?: bigint -} - -const isGroup = (node: LiveNode) => node.config.exec === undefined - -function toWire(event: unknown): WireEvent | null { - const e = event as LiveRunEvent - switch (e.kind) { - case 'run:start': - return { kind: 'run:start', info: e.info } - case 'task:start': - if (isGroup(e.node)) return null - return { - kind: 'task:start', - task: { - id: e.node.id, - project: e.node.projectName, - task: e.node.taskName, - isGroup: false, - requested: e.node.requested, - surfaced: e.node.surfaced === true, - persistent: e.node.config.exec?.persistent !== undefined, - ...(e.node.config.exec?.command !== undefined && { - command: e.node.config.exec.command, - }), - }, - } - case 'task:stdout': - return { kind: 'task:stdout', taskId: e.node.id, chunk: e.chunk } - case 'task:stderr': - return { kind: 'task:stderr', taskId: e.node.id, chunk: e.chunk } - case 'task:complete': { - if (isGroup(e.node)) return null - const o = e.outcome - return { - kind: 'task:complete', - outcome: { - taskId: o.node.id, - status: o.status as never, - exitCode: o.exitCode, - durationMs: o.durationMs, - ...(o.hash !== undefined && { hash: o.hash }), - ...(o.cpuMs !== undefined && { cpuMs: o.cpuMs }), - ...(o.peakRssBytes !== undefined && { peakRssBytes: o.peakRssBytes }), - ...(o.restored !== undefined && { restored: o.restored }), - ...(o.sandboxViolations !== undefined && { sandboxViolations: o.sandboxViolations }), - ...(o.sandboxViolationLines !== undefined && { - sandboxViolationLines: o.sandboxViolationLines, - }), - ...(o.wallclockStartNs !== undefined && { - wallclockStartNs: o.wallclockStartNs.toString(), - }), - ...(o.wallclockEndNs !== undefined && { wallclockEndNs: o.wallclockEndNs.toString() }), - }, - } - } - case 'run:status': - return { kind: 'run:status', line: e.line } - case 'run:end': - return { kind: 'run:end' } - } -} - -export function createOtelBridge(options: OtelBridgeOptions = {}): OtelBridge { - const endpoint = - options.endpoint ?? process.env['OTEL_EXPORTER_OTLP_ENDPOINT'] ?? 'http://localhost:4318' - const serviceName = options.serviceName ?? process.env['OTEL_SERVICE_NAME'] ?? 'vx' - - // Resource attributes live on the LogRecord via the LoggerProvider's - // resource. We use the SDK default resource and rely on OTEL_RESOURCE_ATTRIBUTES - // / OTEL_SERVICE_NAME env discovery, then layer our header config on top. - const exporter = new OTLPLogExporter({ - url: endpoint.replace(/\/$/, '') + '/v1/logs', - headers: options.headers, - }) - const processor = new BatchLogRecordProcessor(exporter) - const provider = new LoggerProvider({ processors: [processor] }) - const logger = provider.getLogger(serviceName) - - return { - attach(bus: EventBus) { - const runId = `run-${++runCounter}-${Date.now()}` - const ctx = { runId } - // Inline projection of the in-process RunEvent → WireEvent (the same - // contract as @vzn/vx's `wireForwarder`, replicated here so the bridge - // has zero runtime imports from vx). Drop group-task lifecycle events - // (pure scheduling noise) and dedupe the double run:end run() emits. - let endForwarded = false - return bus.subscribe((event) => { - const wire = toWire(event) - if (wire === null) return - if (wire.kind === 'run:end') { - if (endForwarded) return - endForwarded = true - } - logger.emit(mapToLogRecord(wire, ctx)) - }) - }, - async cleanup() { - await provider.forceFlush() - await provider.shutdown() - }, - } -} diff --git a/packages/otel-bridge/src/types.ts b/packages/otel-bridge/src/types.ts deleted file mode 100644 index cc4512d..0000000 --- a/packages/otel-bridge/src/types.ts +++ /dev/null @@ -1,24 +0,0 @@ -// Type-only imports from @vzn/vx. `import type` is compile-time only — -// zero runtime coupling, no Bun workspace self-resolve required at -// type-check time. The bridge's runtime imports use the same package name; -// consumers install @vzn/vx as a peer dep. - -import type { EventBus, RunEventSubscriber, WireEvent, OutcomeView } from '@vzn/vx' - -export type { EventBus, RunEventSubscriber, WireEvent, OutcomeView } - -export interface OtelBridgeOptions { - /** OTLP/HTTP collector endpoint. Defaults to OTEL_EXPORTER_OTLP_ENDPOINT. */ - endpoint?: string - /** Resource attribute `service.name`. Defaults to OTEL_SERVICE_NAME or 'vx'. */ - serviceName?: string - /** Optional Authorization / x-honeycomb-team / etc. headers. */ - headers?: Record -} - -export interface OtelBridge { - /** Subscribe to a vx bus; events fan into the OTLP exporter. */ - attach(bus: EventBus): () => void - /** Flush pending records and close the exporter. */ - cleanup(): Promise -} diff --git a/packages/otel-bridge/tests/map-to-log-record.test.ts b/packages/otel-bridge/tests/map-to-log-record.test.ts deleted file mode 100644 index 1cd9087..0000000 --- a/packages/otel-bridge/tests/map-to-log-record.test.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { describe, it, expect } from 'bun:test' -import { SeverityNumber } from '@opentelemetry/api-logs' - -import { mapToLogRecord } from '../src/index.ts' -import type { WireEvent } from '../src/types.ts' - -const ctx = { runId: 'run-test-1' } - -const attrs = (record: ReturnType) => - (record.attributes ?? {}) as Record - -describe('mapToLogRecord', () => { - it('emits run:start with run id under the CI/CD semconv key', () => { - const event: WireEvent = { - kind: 'run:start', - info: { total: 3, concurrency: 4, requestedCount: 1 }, - } - const record = mapToLogRecord(event, ctx) - const a = attrs(record) - expect(record.severityNumber).toBe(SeverityNumber.INFO) - expect(a['vx.kind']).toBe('run:start') - expect(a['cicd.pipeline.run.id']).toBe('run-test-1') - expect(a['cicd.worker.id']).toBe(4) - expect(a['vx.run.total']).toBe(3) - expect(a['vx.run.requested_count']).toBe(1) - }) - - it('emits task:start with semconv pipeline.task.name', () => { - const event: WireEvent = { - kind: 'task:start', - task: { - id: 'pkg-a#build', - project: 'pkg-a', - task: 'build', - isGroup: false, - requested: true, - surfaced: false, - persistent: false, - command: 'tsc -b', - }, - } - const record = mapToLogRecord(event, ctx) - const a = attrs(record) - expect(a['cicd.pipeline.task.name']).toBe('build') - expect(a['vx.task.id']).toBe('pkg-a#build') - expect(a['vx.task.project']).toBe('pkg-a') - expect(a['vx.task.command']).toBe('tsc -b') - expect(a['vx.task.requested']).toBe(true) - expect(record.body).toBe('task start: pkg-a#build') - }) - - it('emits task:complete success → INFO + run.result attribute', () => { - const event: WireEvent = { - kind: 'task:complete', - outcome: { - taskId: 'pkg-a#build', - status: 'success', - exitCode: 0, - durationMs: 1234, - hash: 'deadbeef', - cpuMs: 800, - }, - } - const record = mapToLogRecord(event, ctx) - const a = attrs(record) - expect(record.severityNumber).toBe(SeverityNumber.INFO) - expect(a['cicd.pipeline.task.run.result']).toBe('success') - expect(a['vx.outcome.status']).toBe('success') - expect(a['vx.outcome.exit_code']).toBe(0) - expect(a['vx.outcome.duration_ms']).toBe(1234) - expect(a['vx.outcome.hash']).toBe('deadbeef') - expect(a['vx.outcome.cpu_ms']).toBe(800) - expect(a['vx.task.id']).toBe('pkg-a#build') - }) - - it('emits task:complete failed → ERROR severity', () => { - const event: WireEvent = { - kind: 'task:complete', - outcome: { - taskId: 'pkg-a#build', - status: 'failed', - exitCode: 1, - durationMs: 50, - }, - } - const record = mapToLogRecord(event, ctx) - const a = attrs(record) - expect(record.severityNumber).toBe(SeverityNumber.ERROR) - expect(record.severityText).toBe('error') - expect(a['cicd.pipeline.task.run.result']).toBe('failed') - }) - - it('emits task:stdout with chunk as body', () => { - const event: WireEvent = { - kind: 'task:stdout', - taskId: 'pkg-a#build', - chunk: 'hello world', - } - const record = mapToLogRecord(event, ctx) - const a = attrs(record) - expect(record.body).toBe('hello world') - expect(record.severityNumber).toBe(SeverityNumber.INFO) - expect(a['vx.task.id']).toBe('pkg-a#build') - expect(a['vx.kind']).toBe('task:stdout') - }) - - it('emits task:stderr with WARN severity', () => { - const event: WireEvent = { - kind: 'task:stderr', - taskId: 'pkg-a#build', - chunk: 'something noisy', - } - const record = mapToLogRecord(event, ctx) - expect(record.severityNumber).toBe(SeverityNumber.WARN) - expect(record.body).toBe('something noisy') - }) - - it('emits run:end as a body marker', () => { - const event: WireEvent = { kind: 'run:end' } - const record = mapToLogRecord(event, ctx) - expect(record.body).toBe('run end') - expect((attrs(record))['vx.kind']).toBe('run:end') - expect((attrs(record))['cicd.pipeline.run.id']).toBe('run-test-1') - }) - - it('attaches a fallback run id when no ctx passed', () => { - const event: WireEvent = { kind: 'run:end' } - const record = mapToLogRecord(event) - expect((attrs(record))['cicd.pipeline.run.id']).toBe('unknown') - }) -}) diff --git a/packages/otel-bridge/tsconfig.json b/packages/otel-bridge/tsconfig.json deleted file mode 100644 index e0c192b..0000000 --- a/packages/otel-bridge/tsconfig.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "extends": "../../tsconfig.json", - "include": ["src/**/*", "tests/**/*"] -} diff --git a/src/cli/index.ts b/src/cli/index.ts index 4eadb5f..4c74217 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -75,7 +75,7 @@ export { parsePruneArgs, parseDuration, parseSize } from './cache.js' export { parseLockArgs, type LockArgs } from './lock.js' export { parseMigrateArgs, type MigrateArgs } from './migrate.js' export { parseShowArgs, type ShowArgs } from './show.js' -export { parseInsightsArgs, startStaticServer } from './insights.js' +export { parseInsightsArgs } from './insights.js' export { parseMcpArgs, type McpArgs } from './mcp.js' export { handleMcpRequest, diff --git a/src/cli/insights.ts b/src/cli/insights.ts index e7f54af..df24237 100644 --- a/src/cli/insights.ts +++ b/src/cli/insights.ts @@ -1,13 +1,16 @@ -// `vx insights serve` — boot the local SPA + static cache.db server. -// The SPA (apps/insights) is a Solid + DuckDB-WASM client that reads -// the workspace's cache.db directly. This command runs two things in -// foreground: (1) Vite dev for the SPA, (2) a tiny static HTTP server -// that exposes cache.db so the browser can fetch it. Ctrl-C stops both. +// `vx insights` — boot the local insights SPA pointed at a local `vx serve`. +// +// The SPA (apps/insights) is a Solid client that reads cache.db via the +// HTTP /v1/* surface vx serve exposes. This command runs two things in +// foreground: (1) vx serve, the same backend used everywhere, (2) the +// Vite dev server for the SPA with VITE_DEFAULT_ORIGIN pointed at the +// server's origin. Ctrl-C stops both. import path from 'node:path' import { existsSync } from 'node:fs' -import { findWorkspaceRoot, loadWorkspaceConfig, resolveCacheDir } from '../workspace/index.js' +import { findWorkspaceRoot } from '../workspace/index.js' import { UserError } from '../util/index.js' +import { startServe } from './serve.js' interface InsightsArgs { port: number @@ -48,9 +51,6 @@ export function parseInsightsArgs(args: readonly string[]): InsightsArgs { function resolveInsightsDir(): string { const env = process.env.VX_INSIGHTS_DIR if (env !== undefined && env.length > 0) return env - // import.meta.dir is src/cli when running from source, irrelevant when - // compiled. We resolve via the repo root: vx is shipped from this repo, - // so apps/insights/ sits alongside the running source tree. return path.resolve(import.meta.dir, '..', '..', 'apps', 'insights') } @@ -63,53 +63,17 @@ function ensureScaffoldPresent(insightsDir: string): void { } } -interface RunningServers { - staticPort: number - cacheDbPath: string - stop: () => Promise -} - -/** - * Tiny static server: exposes cache.db read-only at /cache.db. The SPA - * fetches it once and hands the bytes to DuckDB-WASM for in-browser - * querying. We deliberately do NOT proxy queries — analytics stays - * client-side per the design. - * - * Exported for tests; the regular CLI path uses it internally. - */ -export function startStaticServer(cacheDbPath: string): { port: number; stop: () => void } { - const server = Bun.serve({ - port: 0, - fetch(req) { - const url = new URL(req.url) - if (url.pathname === '/cache.db') { - const file = Bun.file(cacheDbPath) - return new Response(file, { - headers: { - 'content-type': 'application/vnd.sqlite3', - 'access-control-allow-origin': '*', - 'cache-control': 'no-store', - }, - }) - } - if (url.pathname === '/health') return new Response('ok') - return new Response('not found', { status: 404 }) - }, - }) - return { port: server.port ?? 0, stop: () => server.stop() } -} - async function startSpa( insightsDir: string, port: number, - cacheDbUrl: string, + defaultOrigin: string, ): Promise<{ stop: () => Promise }> { const child = Bun.spawn({ cmd: ['bun', 'run', 'dev', '--', '--port', String(port)], cwd: insightsDir, stdout: 'inherit', stderr: 'inherit', - env: { ...process.env, VITE_CACHE_DB_URL: cacheDbUrl }, + env: { ...process.env, VITE_DEFAULT_ORIGIN: defaultOrigin }, }) return { stop: async () => { @@ -123,35 +87,6 @@ async function startSpa( } } -async function startServers(workspaceRoot: string, port: number): Promise { - const config = await loadWorkspaceConfig(workspaceRoot) - const cacheDir = resolveCacheDir(workspaceRoot, config) - const cacheDbPath = path.join(cacheDir, 'cache.db') - - if (!existsSync(cacheDbPath)) { - throw new UserError( - `vx insights: no cache.db found at ${cacheDbPath}. ` + - 'Run `vx run ` at least once to populate it.', - ) - } - - const insightsDir = resolveInsightsDir() - ensureScaffoldPresent(insightsDir) - - const stat = startStaticServer(cacheDbPath) - const cacheDbUrl = `http://127.0.0.1:${stat.port}/cache.db` - const spa = await startSpa(insightsDir, port, cacheDbUrl) - - return { - staticPort: stat.port, - cacheDbPath, - stop: async () => { - stat.stop() - await spa.stop() - }, - } -} - export async function insightsCmd(args: readonly string[]): Promise { const [sub, ...rest] = args if (sub === undefined || sub === 'serve') { @@ -162,24 +97,31 @@ export async function insightsCmd(args: readonly string[]): Promise { return 1 } const root = await findWorkspaceRoot(process.cwd()) - let servers: RunningServers + const insightsDir = resolveInsightsDir() try { - servers = await startServers(root, parsed.port) + ensureScaffoldPresent(insightsDir) } catch (err) { const msg = err instanceof UserError || err instanceof Error ? err.message : String(err) process.stderr.write(`vx insights: ${msg}\n`) return 1 } + + // Boot vx serve — the same backend that powers everything else. The + // SPA talks to it via /v1/* HTTP routes. + const server = await startServe({ root }) + const spa = await startSpa(insightsDir, parsed.port, server.origin) + process.stdout.write( `vx insights: SPA on http://127.0.0.1:${parsed.port}\n` + - `vx insights: serving cache.db from ${servers.cacheDbPath} (port ${servers.staticPort})\n` + + `vx insights: API on ${server.origin}\n` + '(press Ctrl-C to stop)\n\n', ) await new Promise((resolve) => { process.once('SIGINT', () => resolve()) process.once('SIGTERM', () => resolve()) }) - await servers.stop() + await spa.stop() + await server.stop() process.stdout.write('\nvx insights: stopped\n') return 0 } diff --git a/src/cli/serve.ts b/src/cli/serve.ts index 22191b7..365a687 100644 --- a/src/cli/serve.ts +++ b/src/cli/serve.ts @@ -7,18 +7,25 @@ import path from 'node:path' import { mkdir, unlink, writeFile } from 'node:fs/promises' +import { Cache } from '../cache/index.js' import { run as runOrchestrator, createEventBus, wireForwarder, requestToOptions, projectOutcome, - decodeEnvelope, encodeForNDJSON, encodeForSSE, envelopeToClientMessage, + explainCacheKeyQuery, + getCacheStatsSql, + getHistory, + getRun, isEnvelope, + listInvocations, + listRuns, serverMessageToEnvelope, + whyDidThisRerunQuery, WIRE_CHANNELS, WIRE_PROTOCOL_VERSION, type ClientMessage, @@ -28,7 +35,7 @@ import { type ServerMessage, } from '../orchestrator/index.js' import { VERSION } from '../version.js' -import { findWorkspaceRoot } from '../workspace/index.js' +import { findWorkspaceRoot, loadWorkspaceConfig, resolveCacheDir } from '../workspace/index.js' /** Where `vx serve` advertises itself and `vx run` looks for it. */ export function serveInfoPath(workspaceRoot: string): string { @@ -80,6 +87,26 @@ export interface ServeServer { stop: () => Promise } +// CORS is wide-open: the hosted SPA needs to reach localhost from a foreign +// origin, and the surface is read-only insights + an authenticated WS run +// submission. Any tighter policy would break the "host the SPA once, point +// it at any vx serve" UX. +const CORS_HEADERS = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type', + 'Access-Control-Max-Age': '86400', +} + +function withCors(res: Response): Response { + for (const [k, v] of Object.entries(CORS_HEADERS)) res.headers.set(k, v) + return res +} + +function jsonResponse(body: unknown, init?: ResponseInit): Response { + return withCors(Response.json(body, init)) +} + export async function startServe(opts: { root: string port?: number @@ -89,6 +116,13 @@ export async function startServe(opts: { // it to dedup in-flight task execution. const inflight = new Map>() + // Read-only handle to the workspace's cache.db, opened once and reused + // for every /v1/* query. The query module is pure — opens nothing — + // so the lifetime lives here. + const workspaceConfig = await loadWorkspaceConfig(opts.root) + const cacheDir = resolveCacheDir(opts.root, workspaceConfig) + const cache = new Cache(cacheDir) + // Read-only event subscribers (SSE / NDJSON). Each callback gets every // event from every concurrent run as a notification envelope so a `curl` // user sees activity across the service. @@ -110,20 +144,87 @@ export async function startServe(opts: { port: opts.port ?? 0, fetch(req, srv) { const url = new URL(req.url) + // Browser preflight — answer everything with CORS-permissive headers. + if (req.method === 'OPTIONS') { + return withCors(new Response(null, { status: 204 })) + } // Liveness probe — `vx run` health-checks this before delegating. - if (url.pathname === '/health') return new Response('ok') + if (url.pathname === '/health') return withCors(new Response('ok')) // Capability handshake — what protocol version + channels + RPCs. if (url.pathname === '/version') { - return Response.json({ + return jsonResponse({ protocol: WIRE_PROTOCOL_VERSION, vx: VERSION, channels: WIRE_CHANNELS, rpc: ['getCacheStats', 'getRunHistory', 'explainCacheKey', 'whyDidThisRerun'], + workspace: opts.root, }) } + // ----------------------------------------------------------------- + // Insights HTTP surface — JSON read APIs over cache.db. The hosted + // SPA in apps/insights/ calls these directly; same shape will be + // mirrored by a future hosted multi-tenant deployment. + // ----------------------------------------------------------------- + if (url.pathname === '/v1/runs') { + const params = url.searchParams + const args: Parameters[1] = {} + const limitRaw = params.get('limit') + if (limitRaw !== null) args.limit = Number(limitRaw) + const project = params.get('project') + if (project !== null) args.project = project + const task = params.get('task') + if (task !== null) args.task = task + const runId = params.get('runId') + if (runId !== null) args.runId = runId + return jsonResponse({ runs: listRuns(cache.dbHandle(), args) }) + } + if (url.pathname === '/v1/invocations') { + const limit = Number(url.searchParams.get('limit') ?? '50') + return jsonResponse({ invocations: listInvocations(cache.dbHandle(), limit) }) + } + { + const m = /^\/v1\/runs\/([^/]+)$/.exec(url.pathname) + if (m) { + const detail = getRun(cache.dbHandle(), decodeURIComponent(m[1]!)) + if (!detail) return jsonResponse({ error: 'not found' }, { status: 404 }) + return jsonResponse(detail) + } + } + if (url.pathname === '/v1/cache/stats') { + return jsonResponse(getCacheStatsSql(cache.dbHandle())) + } + if (url.pathname === '/v1/history') { + const params = url.searchParams + const args: Parameters[1] = {} + const limitRaw = params.get('limit') + if (limitRaw !== null) args.limit = Number(limitRaw) + const project = params.get('project') + if (project !== null) args.project = project + const task = params.get('task') + if (task !== null) args.task = task + return jsonResponse({ history: getHistory(cache.dbHandle(), args) }) + } + { + const m = /^\/v1\/explain\/(.+)$/.exec(url.pathname) + if (m) { + return jsonResponse(explainCacheKeyQuery(cache.dbHandle(), decodeURIComponent(m[1]!))) + } + } + { + const m = /^\/v1\/why\/([^/]+)\/(.+)$/.exec(url.pathname) + if (m) { + return jsonResponse( + whyDidThisRerunQuery( + cache.dbHandle(), + decodeURIComponent(m[1]!), + decodeURIComponent(m[2]!), + ), + ) + } + } // Server-Sent Events — broadcasts the same event envelopes the WS // sees, but on a one-way stream. `curl -N http://.../events` works. - if (url.pathname === '/events') { + if (url.pathname === '/events' || url.pathname === '/v1/events') { const stream = new ReadableStream({ start(controller) { const enc = new TextEncoder() @@ -139,13 +240,15 @@ export async function startServe(opts: { }) }, }) - return new Response(stream, { - headers: { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-store', - Connection: 'keep-alive', - }, - }) + return withCors( + new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-store', + Connection: 'keep-alive', + }, + }), + ) } // NDJSON — one envelope per line, no SSE framing. `jq`-friendly. if (url.pathname === '/stream') { @@ -165,15 +268,17 @@ export async function startServe(opts: { }) }, }) - return new Response(stream, { - headers: { - 'Content-Type': 'application/x-ndjson', - 'Cache-Control': 'no-store', - }, - }) + return withCors( + new Response(stream, { + headers: { + 'Content-Type': 'application/x-ndjson', + 'Cache-Control': 'no-store', + }, + }), + ) } if (srv.upgrade(req)) return undefined - return new Response('vx serve') + return withCors(new Response('vx serve')) }, websocket: { async message(ws, raw) { @@ -215,6 +320,11 @@ export async function startServe(opts: { origin, stop: async () => { await server.stop(true) + try { + cache.close() + } catch { + // already closed + } try { await unlink(infoPath) } catch { diff --git a/src/orchestrator/index.ts b/src/orchestrator/index.ts index b2d2b95..480f105 100644 --- a/src/orchestrator/index.ts +++ b/src/orchestrator/index.ts @@ -73,3 +73,23 @@ export { export { prepareForCoordinator, computeTaskHashForCoord } from './coordinator-prepare.js' export { workerExecute } from './worker-exec.js' export type { WorkerExecArgs, WorkerExecResult } from './worker-exec.js' +export { + explainCacheKey as explainCacheKeyQuery, + getCacheStatsSql, + getHistory, + getRun, + listInvocations, + listRuns, + whyDidThisRerun as whyDidThisRerunQuery, +} from './insights-queries.js' +export type { + CacheKeyExplanation, + CacheStatsResult, + GetHistoryArgs, + InvocationRow, + ListRunsArgs, + RunDetail, + RunSummaryRow, + TaskHistoryRow, + WhyDidThisRerun, +} from './insights-queries.js' diff --git a/src/orchestrator/insights-queries.ts b/src/orchestrator/insights-queries.ts new file mode 100644 index 0000000..a4c6912 --- /dev/null +++ b/src/orchestrator/insights-queries.ts @@ -0,0 +1,351 @@ +// Insights query module — pure functions over a `bun:sqlite` Database. +// +// One module, two callers: `vx serve` (over Bun.serve HTTP routes) and +// the same shape on `apps/cloud` (over Cloudflare D1 — same SQL, +// different driver). The SPA in `apps/insights` calls /v1/* routes +// backed by these functions. +// +// Pure SQL + JSON-safe return shapes. No Cache lifecycle here; the +// caller opens and closes. bigints are serialized as decimal strings +// for JSON compatibility (matches the WireEvent timeUnixNano rule). + +import type { Database } from 'bun:sqlite' + +// --------------------------------------------------------------------------- +// Run listing + detail +// --------------------------------------------------------------------------- + +export interface RunSummaryRow { + runId: string | null + project: string + task: string + status: string + exitCode: number + durationMs: number + startedAt: number + endedAt: number + cacheHit: boolean | null + hash: string + // High-precision spans relative to run start. Decimal strings on the wire + // (bigints aren't JSON-safe); use BigInt or Number on the client. + wallclockStartNs: string | null + wallclockEndNs: string | null +} + +export interface ListRunsArgs { + limit?: number + project?: string + task?: string + runId?: string +} + +export function listRuns(db: Database, args: ListRunsArgs = {}): RunSummaryRow[] { + const limit = clampInt(args.limit ?? 100, 1, 500) + const where: string[] = [] + const params: (string | number)[] = [] + if (args.project) { + where.push('project = ?') + params.push(args.project) + } + if (args.task) { + where.push('task = ?') + params.push(args.task) + } + if (args.runId) { + where.push('run_id = ?') + params.push(args.runId) + } + const clause = where.length > 0 ? `WHERE ${where.join(' AND ')}` : '' + type RawRow = Omit & { + cacheHit: number | null + wallclockStartNs: bigint | null + wallclockEndNs: bigint | null + } + const rows = db + .query( + `SELECT run_id AS runId, project, task, status, exit_code AS exitCode, + duration_ms AS durationMs, started_at AS startedAt, ended_at AS endedAt, + cache_hit AS cacheHit, hash, + wallclock_start_ns AS wallclockStartNs, wallclock_end_ns AS wallclockEndNs + FROM runs ${clause} ORDER BY started_at DESC LIMIT ?`, + ) + .all(...params, limit) as RawRow[] + return rows.map((r) => ({ + ...r, + cacheHit: r.cacheHit === null ? null : Boolean(r.cacheHit), + wallclockStartNs: r.wallclockStartNs === null ? null : r.wallclockStartNs.toString(), + wallclockEndNs: r.wallclockEndNs === null ? null : r.wallclockEndNs.toString(), + })) +} + +/** + * Group recent runs by `runId` — what the SPA's overview page wants + * (one row per `vx run` invocation, not per task). + */ +export interface InvocationRow { + runId: string + startedAt: number + endedAt: number + taskCount: number + failedCount: number + hitCount: number + totalDurationMs: number +} + +export function listInvocations(db: Database, limit = 50): InvocationRow[] { + return db + .query( + `SELECT + run_id AS runId, + MIN(started_at) AS startedAt, + MAX(ended_at) AS endedAt, + COUNT(*) AS taskCount, + SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) AS failedCount, + SUM(CASE WHEN cache_hit = 1 OR status LIKE 'cache-hit%' THEN 1 ELSE 0 END) AS hitCount, + SUM(duration_ms) AS totalDurationMs + FROM runs + WHERE run_id IS NOT NULL + GROUP BY run_id + ORDER BY MAX(started_at) DESC + LIMIT ?`, + ) + .all(clampInt(limit, 1, 500)) as InvocationRow[] +} + +export interface RunDetail { + runId: string + startedAt: number + endedAt: number + tasks: RunSummaryRow[] +} + +export function getRun(db: Database, runId: string): RunDetail | null { + const tasks = listRuns(db, { runId, limit: 500 }) + if (tasks.length === 0) return null + const startedAt = Math.min(...tasks.map((t) => t.startedAt)) + const endedAt = Math.max(...tasks.map((t) => t.endedAt)) + return { runId, startedAt, endedAt, tasks } +} + +// --------------------------------------------------------------------------- +// Cache stats +// --------------------------------------------------------------------------- + +export interface CacheStatsResult { + entryCount: number + totalBytes: number + runCountLast24h: number + hitCountLast24h: number + hitRate24h: number +} + +export function getCacheStatsSql(db: Database): CacheStatsResult { + const aggregate = db + .query('SELECT COUNT(*) AS n, COALESCE(SUM(size_bytes), 0) AS bytes FROM entries') + .get() as { n: number; bytes: number } + const since = Date.now() - 24 * 60 * 60 * 1000 + const runs = db + .query( + "SELECT COUNT(*) AS total, COALESCE(SUM(CASE WHEN status = 'cache-hit' OR status = 'cache-hit-remote' THEN 1 ELSE 0 END), 0) AS hits FROM runs WHERE started_at >= ?", + ) + .get(since) as { total: number; hits: number } + return { + entryCount: aggregate.n, + totalBytes: aggregate.bytes, + runCountLast24h: runs.total, + hitCountLast24h: runs.hits, + hitRate24h: runs.total > 0 ? runs.hits / runs.total : 0, + } +} + +// --------------------------------------------------------------------------- +// Task history (the same SQL CTE LocalHistoryProvider uses) +// --------------------------------------------------------------------------- + +export interface TaskHistoryRow { + id: string + runs: number + successRate: number + hitRate: number + failureMode: 'stable' | 'flaky-recoverable' | 'flaky-fatal' + p50DurationMs: number | undefined + p99DurationMs: number | undefined +} + +export interface GetHistoryArgs { + project?: string + task?: string + limit?: number +} + +export function getHistory(db: Database, args: GetHistoryArgs = {}): TaskHistoryRow[] { + const limit = clampInt(args.limit ?? 50, 1, 500) + const where: string[] = [] + const params: (string | number)[] = [] + if (args.project) { + where.push('project = ?') + params.push(args.project) + } + if (args.task) { + where.push('task = ?') + params.push(args.task) + } + const clause = where.length > 0 ? `WHERE ${where.join(' AND ')}` : '' + const pairs = db.query(`SELECT DISTINCT project, task FROM runs ${clause}`).all(...params) as { + project: string + task: string + }[] + + return pairs.slice(0, limit).map((p) => { + const aggregate = db + .query( + `SELECT + COUNT(*) AS total, + SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END) AS successes, + SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) AS failures, + SUM(CASE WHEN cache_hit = 1 OR status LIKE 'cache-hit%' THEN 1 ELSE 0 END) AS hits + FROM runs WHERE project = ? AND task = ? + ORDER BY started_at DESC LIMIT 50`, + ) + .get(p.project, p.task) as { + total: number + successes: number + failures: number + hits: number + } + const total = aggregate.total || 0 + const failures = aggregate.failures || 0 + const failureMode: TaskHistoryRow['failureMode'] = + failures === 0 ? 'stable' : failures < total / 5 ? 'flaky-recoverable' : 'flaky-fatal' + const durations = db + .query( + `SELECT duration_ms FROM runs + WHERE project = ? AND task = ? + AND (cache_hit IS NULL OR cache_hit = 0) + AND status = 'success' + ORDER BY started_at DESC LIMIT 50`, + ) + .all(p.project, p.task) as { duration_ms: number }[] + const sorted = durations.map((r) => r.duration_ms).sort((a, b) => a - b) + return { + id: `${p.project}#${p.task}`, + runs: total, + successRate: total > 0 ? (aggregate.successes || 0) / total : 0, + hitRate: total > 0 ? (aggregate.hits || 0) / total : 0, + failureMode, + p50DurationMs: pickPercentile(sorted, 0.5), + p99DurationMs: pickPercentile(sorted, 0.99), + } + }) +} + +// --------------------------------------------------------------------------- +// Cache key explain — latest entries row +// --------------------------------------------------------------------------- + +export interface CacheKeyExplanation { + taskId: string + project: string + task: string + latestEntry: { + hash: string + command: string + exitCode: number + durationMs: number + sizeBytes: number + createdAt: number + } | null + note: string +} + +export function explainCacheKey(db: Database, taskId: string): CacheKeyExplanation { + const [project, task] = taskId.split('#', 2) as [string, string] + const entry = db + .query( + `SELECT hash, command, exit_code AS exitCode, duration_ms AS durationMs, + size_bytes AS sizeBytes, created_at AS createdAt + FROM entries WHERE project = ? AND task = ? ORDER BY created_at DESC LIMIT 1`, + ) + .get(project, task) as CacheKeyExplanation['latestEntry'] + return { + taskId, + project, + task, + latestEntry: entry ?? null, + note: 'cache key components (files / env / runtime / upstream) require live config evaluation; this surface returns persisted entry metadata', + } +} + +// --------------------------------------------------------------------------- +// Why did this rerun — compare two runs +// --------------------------------------------------------------------------- + +export interface WhyDidThisRerun { + runId: string + taskId: string + found: boolean + thisRun?: { hash: string; status: string; cacheHit: boolean | null; startedAt: number } + previousRun?: { hash: string; status: string; cacheHit: boolean | null; startedAt: number } | null + hashChanged?: boolean | null + note: string +} + +export function whyDidThisRerun(db: Database, runId: string, taskId: string): WhyDidThisRerun { + const [project, task] = taskId.split('#', 2) as [string, string] + const this_ = db + .query( + `SELECT hash, status, cache_hit AS cacheHit, started_at AS startedAt + FROM runs WHERE run_id = ? AND project = ? AND task = ?`, + ) + .get(runId, project, task) as + | { hash: string; status: string; cacheHit: number | null; startedAt: number } + | undefined + if (!this_) { + return { + runId, + taskId, + found: false, + note: 'no row matching that runId + taskId', + } + } + const prev = db + .query( + `SELECT hash, status, cache_hit AS cacheHit, started_at AS startedAt + FROM runs WHERE project = ? AND task = ? AND started_at < ? + ORDER BY started_at DESC LIMIT 1`, + ) + .get(project, task, this_.startedAt) as + | { hash: string; status: string; cacheHit: number | null; startedAt: number } + | undefined + return { + runId, + taskId, + found: true, + thisRun: { ...this_, cacheHit: this_.cacheHit === null ? null : Boolean(this_.cacheHit) }, + previousRun: prev + ? { ...prev, cacheHit: prev.cacheHit === null ? null : Boolean(prev.cacheHit) } + : null, + hashChanged: prev ? prev.hash !== this_.hash : null, + note: + prev && prev.hash !== this_.hash + ? 'cache key changed between the previous run and this one (inputs differ)' + : prev + ? 'cache key unchanged — re-run with the same key (likely --no-cache or unrelated)' + : 'no prior run for this (project, task)', + } +} + +// --------------------------------------------------------------------------- +// helpers +// --------------------------------------------------------------------------- + +function clampInt(n: number, min: number, max: number): number { + if (!Number.isFinite(n)) return min + return Math.min(max, Math.max(min, Math.floor(n))) +} + +function pickPercentile(sorted: number[], q: number): number | undefined { + if (sorted.length === 0) return undefined + const idx = Math.min(sorted.length - 1, Math.floor(q * sorted.length)) + return sorted[idx] +} diff --git a/src/orchestrator/otel-emit.ts b/src/orchestrator/otel-emit.ts new file mode 100644 index 0000000..0fd1a08 --- /dev/null +++ b/src/orchestrator/otel-emit.ts @@ -0,0 +1,282 @@ +// Native OTel CI/CD-conventions emit. Lives in core (vs. the deleted +// @vzn/vx-otel-bridge package): when OTEL_EXPORTER_OTLP_ENDPOINT is set +// and the @opentelemetry/* optional peer deps are installed, every +// run subscribes a log-record emitter to the bus and pushes through +// the OTLP/HTTP exporter. +// +// Optional deps via dynamic specifier import — the OTel SDK pulls +// ~30 packages of closure; core stays at ~20 unless users opt in. + +import type { EventBus, WireEvent } from './events.js' + +const SEMCONV = { + pipelineRunId: 'cicd.pipeline.run.id', + taskName: 'cicd.pipeline.task.name', + taskRunResult: 'cicd.pipeline.task.run.result', + workerId: 'cicd.worker.id', +} as const + +interface SeverityNumberLike { + INFO: number + WARN: number + ERROR: number +} + +interface LoggerLike { + emit(record: { + timestamp: number + severityNumber: number + severityText?: string + body: string + attributes: Record + }): void +} + +let runCounter = 0 + +/** + * Attach a native OTel exporter to a vx bus. Returns a detach function. + * Returns null silently if either the env var is missing or the optional + * peer deps aren't installed — never blocks a run. + */ +export async function attachOtelEmit(bus: EventBus): Promise<(() => void) | null> { + const endpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT + if (!endpoint) return null + const serviceName = process.env.OTEL_SERVICE_NAME ?? 'vx' + + let SeverityNumber: SeverityNumberLike + let exporter: { shutdown(): Promise } + let processor: { shutdown(): Promise } + let logger: LoggerLike + try { + // Dynamic specifiers so TS doesn't try to resolve the optional peers + // at type-check time. Users `bun add @opentelemetry/api-logs + // @opentelemetry/sdk-logs @opentelemetry/exporter-logs-otlp-http` + // to opt in. + const apiLogs = (await import('@opentelemetry/api-logs' as string)) as { + SeverityNumber: SeverityNumberLike + } + const otlpHttp = (await import('@opentelemetry/exporter-logs-otlp-http' as string)) as { + OTLPLogExporter: new (opts: { url: string }) => { shutdown(): Promise } + } + const sdkLogs = (await import('@opentelemetry/sdk-logs' as string)) as { + LoggerProvider: new () => { + addLogRecordProcessor(p: unknown): void + getLogger(name: string): LoggerLike + } + BatchLogRecordProcessor: new (e: unknown) => { shutdown(): Promise } + } + SeverityNumber = apiLogs.SeverityNumber + exporter = new otlpHttp.OTLPLogExporter({ url: endpoint }) + processor = new sdkLogs.BatchLogRecordProcessor(exporter) + const provider = new sdkLogs.LoggerProvider() + provider.addLogRecordProcessor(processor) + logger = provider.getLogger(serviceName) + } catch { + // Optional peers not installed — silently skip. The env var is the + // user opt-in; the deps are the technical opt-in. + return null + } + + const runId = `run-${++runCounter}-${Date.now()}` + let endForwarded = false + + const dispose = bus.subscribe((event) => { + // Project the in-process RunEvent to a WireEvent shape. The bus + // delivers live nodes; we read only the fields the OTel mapping + // needs (id, taskName, exec etc.), no graph traversal. + const wire = projectToWireLite(event) + if (wire === null) return + if (wire.kind === 'run:end') { + if (endForwarded) return + endForwarded = true + } + try { + logger.emit(mapToLogRecord(wire, { runId, severityNumber: SeverityNumber })) + } catch { + // exporter failure must never break the run; degrade silently + } + }) + + return async () => { + dispose() + try { + await processor.shutdown() + await exporter.shutdown() + } catch { + // best effort + } + } +} + +interface LiveRunEvent { + kind: WireEvent['kind'] + info?: { total: number; concurrency?: number; requestedCount?: number } + node?: { + id: string + projectName: string + taskName: string + requested: boolean + surfaced?: boolean + config: { exec?: { command?: string; persistent?: unknown } } + } + chunk?: string + outcome?: { + node?: { id: string; projectName: string; taskName: string } + status: string + exitCode: number + durationMs: number + hash?: string + cpuMs?: number + peakRssBytes?: number + restored?: boolean + wallclockStartNs?: bigint + wallclockEndNs?: bigint + } + line?: string +} + +interface LiteWire { + kind: WireEvent['kind'] + info?: { total: number; concurrency?: number; requestedCount?: number } + taskId?: string + command?: string + chunk?: string + line?: string + outcome?: { + taskId: string + status: string + exitCode: number + durationMs: number + hash?: string + cpuMs?: number + peakRssBytes?: number + restored?: boolean + wallclockStartNs?: string + wallclockEndNs?: string + } +} + +function projectToWireLite(event: unknown): LiteWire | null { + const e = event as LiveRunEvent + switch (e.kind) { + case 'run:start': + return { kind: 'run:start', info: e.info ?? { total: 0 } } + case 'task:start': { + if (!e.node || e.node.config.exec === undefined) return null + const lite: LiteWire = { kind: 'task:start', taskId: e.node.id } + if (e.node.config.exec.command !== undefined) lite.command = e.node.config.exec.command + return lite + } + case 'task:stdout': + if (!e.node) return null + return { kind: 'task:stdout', taskId: e.node.id, chunk: e.chunk ?? '' } + case 'task:stderr': + if (!e.node) return null + return { kind: 'task:stderr', taskId: e.node.id, chunk: e.chunk ?? '' } + case 'task:complete': { + if (!e.node || !e.outcome || e.node.config.exec === undefined) return null + const o = e.outcome + const outcome: NonNullable = { + taskId: e.node.id, + status: o.status, + exitCode: o.exitCode, + durationMs: o.durationMs, + } + if (o.hash !== undefined) outcome.hash = o.hash + if (o.cpuMs !== undefined) outcome.cpuMs = o.cpuMs + if (o.peakRssBytes !== undefined) outcome.peakRssBytes = o.peakRssBytes + if (o.restored !== undefined) outcome.restored = o.restored + if (o.wallclockStartNs !== undefined) outcome.wallclockStartNs = o.wallclockStartNs.toString() + if (o.wallclockEndNs !== undefined) outcome.wallclockEndNs = o.wallclockEndNs.toString() + return { kind: 'task:complete', outcome } + } + case 'run:status': + return { kind: 'run:status', line: e.line ?? '' } + case 'run:end': + return { kind: 'run:end' } + } + return null +} + +/** Translate a projected wire event to an OTel LogRecord. */ +export function mapToLogRecord( + event: LiteWire, + ctx: { runId: string; severityNumber: SeverityNumberLike }, +): { + timestamp: number + severityNumber: number + severityText: string + body: string + attributes: Record +} { + const base = { + timestamp: Date.now(), + severityNumber: ctx.severityNumber.INFO, + severityText: 'info', + body: event.kind as string, + attributes: { + 'vx.kind': event.kind, + [SEMCONV.pipelineRunId]: ctx.runId, + } as Record, + } + + switch (event.kind) { + case 'run:start': { + const info = event.info! + base.attributes['vx.run.total'] = info.total + if (info.concurrency !== undefined) base.attributes[SEMCONV.workerId] = info.concurrency + if (info.requestedCount !== undefined) + base.attributes['vx.run.requested_count'] = info.requestedCount + base.body = `vx run start (${info.total} tasks)` + return base + } + case 'task:start': + if (event.taskId !== undefined) { + base.attributes[SEMCONV.taskName] = event.taskId + base.attributes['vx.task.id'] = event.taskId + } + if (event.command !== undefined) base.attributes['vx.task.command'] = event.command + base.body = `task start: ${event.taskId ?? ''}` + return base + case 'task:stdout': + case 'task:stderr': + base.attributes['vx.task.id'] = event.taskId + base.body = event.chunk ?? '' + if (event.kind === 'task:stderr') { + base.severityNumber = ctx.severityNumber.WARN + base.severityText = 'warn' + } + return base + case 'task:complete': { + const o = event.outcome! + base.attributes[SEMCONV.taskName] = o.taskId + base.attributes['vx.task.id'] = o.taskId + base.attributes[SEMCONV.taskRunResult] = o.status + base.attributes['vx.outcome.status'] = o.status + base.attributes['vx.outcome.exit_code'] = o.exitCode + base.attributes['vx.outcome.duration_ms'] = o.durationMs + if (o.hash !== undefined) base.attributes['vx.outcome.hash'] = o.hash + if (o.cpuMs !== undefined) base.attributes['vx.outcome.cpu_ms'] = o.cpuMs + if (o.peakRssBytes !== undefined) + base.attributes['vx.outcome.peak_rss_bytes'] = o.peakRssBytes + if (o.restored !== undefined) base.attributes['vx.outcome.restored'] = o.restored + if (o.wallclockStartNs !== undefined) + base.attributes['vx.outcome.wallclock_start_ns'] = o.wallclockStartNs + if (o.wallclockEndNs !== undefined) + base.attributes['vx.outcome.wallclock_end_ns'] = o.wallclockEndNs + if (o.status === 'failed') { + base.severityNumber = ctx.severityNumber.ERROR + base.severityText = 'error' + } + base.body = `task complete: ${o.taskId} (${o.status})` + return base + } + case 'run:status': + base.body = event.line ?? '' + return base + case 'run:end': + base.body = 'run end' + return base + } +} diff --git a/src/orchestrator/run.ts b/src/orchestrator/run.ts index 65ef8e3..c64d6e3 100644 --- a/src/orchestrator/run.ts +++ b/src/orchestrator/run.ts @@ -15,7 +15,8 @@ import { import { ulid, UserError } from '../util/index.js' import { executeTask } from './execute-task.js' import { computeTaskHash } from './task-hash.js' -import { busLogger, createEventBus, terminalSubscriber, type EventBus } from './events.js' +import { busLogger, createEventBus, terminalSubscriber } from './events.js' +import { attachOtelEmit } from './otel-emit.js' import { installPlugins } from './plugin.js' import { defaultLogger, resolveOutputView } from './logger.js' import { detectColors } from './colors.js' @@ -49,30 +50,14 @@ export async function run(options: RunOptions): Promise { bus.subscribe(terminalSubscriber(sink)) const log = busLogger(bus) - // OTel bridge — when OTEL_EXPORTER_OTLP_ENDPOINT is set AND - // @vzn/vx-otel-bridge is installed, attach it as an additional - // subscriber. Pure dynamic import so core stays free of OTel deps. - // Failure to import logs a hint and continues; never blocks a run. + // Native OTel CI/CD-conventions emit (core, no bridge package). When + // OTEL_EXPORTER_OTLP_ENDPOINT is set AND the @opentelemetry/* + // optional peer deps are installed, every event flows to OTLP. + // Missing deps = silent skip; never blocks a run. let detachOtel: (() => void) | undefined - if (process.env.OTEL_EXPORTER_OTLP_ENDPOINT && options.log === undefined) { - try { - // Dynamic specifier so TS doesn't try to resolve the optional - // peer at type-check time. @vzn/vx-otel-bridge isn't in core's - // dep tree; users add it to opt in. - const specifier = '@vzn/vx-otel-bridge' - const mod = (await import(specifier)) as { - createOtelBridge: (opts?: { endpoint?: string; serviceName?: string }) => { - attach: (bus: EventBus) => () => void - } - } - const bridge = mod.createOtelBridge({ - endpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT, - serviceName: process.env.OTEL_SERVICE_NAME ?? 'vx', - }) - detachOtel = bridge.attach(bus) - } catch { - // not installed — silently skip; the env var is the opt-in. - } + if (options.log === undefined) { + const stop = await attachOtelEmit(bus) + if (stop) detachOtel = stop } const prepared = await prepareRun(options, log) diff --git a/tests/insights-queries.test.ts b/tests/insights-queries.test.ts new file mode 100644 index 0000000..433f5dd --- /dev/null +++ b/tests/insights-queries.test.ts @@ -0,0 +1,245 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { describe, expect, it } from 'bun:test' +import { Cache, type RunRecord } from '../src/cache/index.js' +import { + explainCacheKeyQuery, + getCacheStatsSql, + getHistory, + getRun, + listInvocations, + listRuns, + whyDidThisRerunQuery, +} from '../src/orchestrator/index.js' + +function mkRun( + args: Partial & { hash: string; project: string; task: string }, +): RunRecord { + return { + hash: args.hash, + project: args.project, + task: args.task, + status: args.status ?? 'success', + exitCode: args.exitCode ?? 0, + durationMs: args.durationMs ?? 100, + forwardArgs: [], + startedAt: args.startedAt ?? Date.now() - 1000, + endedAt: args.endedAt ?? Date.now() - 900, + runId: args.runId ?? 'r-1', + cpuMs: 50, + peakRssBytes: 0, + wallclockStartNs: 0n, + wallclockEndNs: 0n, + cacheHit: args.cacheHit ?? false, + } +} + +function withCache(fn: (cache: Cache) => void) { + const dir = mkdtempSync(path.join(tmpdir(), 'vx-insights-q-')) + const cache = new Cache(dir) + try { + fn(cache) + } finally { + cache.close() + rmSync(dir, { recursive: true, force: true }) + } +} + +describe('listRuns', () => { + it('orders by started_at DESC and applies limit', () => { + withCache((cache) => { + cache.recordRuns([ + mkRun({ hash: 'h1', project: 'pkg', task: 'build', startedAt: 1000 }), + mkRun({ hash: 'h2', project: 'pkg', task: 'build', startedAt: 2000 }), + mkRun({ hash: 'h3', project: 'pkg', task: 'build', startedAt: 3000 }), + ]) + const rows = listRuns(cache.dbHandle(), { limit: 2 }) + expect(rows.length).toBe(2) + expect(rows[0]!.hash).toBe('h3') + expect(rows[1]!.hash).toBe('h2') + }) + }) + + it('filters by project + task + runId', () => { + withCache((cache) => { + cache.recordRuns([ + mkRun({ hash: 'h1', project: 'pkg', task: 'build', runId: 'r-1' }), + mkRun({ hash: 'h2', project: 'pkg', task: 'test', runId: 'r-1' }), + mkRun({ hash: 'h3', project: 'other', task: 'build', runId: 'r-2' }), + ]) + expect(listRuns(cache.dbHandle(), { project: 'pkg' }).length).toBe(2) + expect(listRuns(cache.dbHandle(), { task: 'build' }).length).toBe(2) + expect(listRuns(cache.dbHandle(), { runId: 'r-1' }).length).toBe(2) + expect(listRuns(cache.dbHandle(), { project: 'pkg', task: 'test' }).length).toBe(1) + }) + }) +}) + +describe('listInvocations', () => { + it('groups by run_id with per-invocation aggregates', () => { + withCache((cache) => { + cache.recordRuns([ + mkRun({ + hash: 'h1', + project: 'pkg', + task: 'build', + runId: 'r-1', + startedAt: 1000, + durationMs: 100, + }), + mkRun({ + hash: 'h2', + project: 'pkg', + task: 'test', + runId: 'r-1', + startedAt: 1100, + durationMs: 200, + status: 'failed', + }), + mkRun({ + hash: 'h3', + project: 'pkg', + task: 'build', + runId: 'r-2', + startedAt: 2000, + durationMs: 50, + cacheHit: true, + status: 'cache-hit', + }), + ]) + const rows = listInvocations(cache.dbHandle()) + expect(rows.length).toBe(2) + const r1 = rows.find((r) => r.runId === 'r-1')! + expect(r1.taskCount).toBe(2) + expect(r1.failedCount).toBe(1) + expect(r1.totalDurationMs).toBe(300) + const r2 = rows.find((r) => r.runId === 'r-2')! + expect(r2.hitCount).toBe(1) + }) + }) +}) + +describe('getRun', () => { + it('returns null for an unknown runId', () => { + withCache((cache) => { + expect(getRun(cache.dbHandle(), 'unknown')).toBeNull() + }) + }) + + it('returns run-level start/end derived from tasks', () => { + withCache((cache) => { + cache.recordRuns([ + mkRun({ + hash: 'h1', + project: 'pkg', + task: 'build', + runId: 'r-1', + startedAt: 1000, + endedAt: 1100, + }), + mkRun({ + hash: 'h2', + project: 'pkg', + task: 'test', + runId: 'r-1', + startedAt: 1200, + endedAt: 1300, + }), + ]) + const detail = getRun(cache.dbHandle(), 'r-1')! + expect(detail.startedAt).toBe(1000) + expect(detail.endedAt).toBe(1300) + expect(detail.tasks.length).toBe(2) + }) + }) +}) + +describe('getCacheStatsSql', () => { + it('counts entries + computes hit rate from runs in last 24h', () => { + withCache((cache) => { + cache.recordRuns([ + mkRun({ + hash: 'h1', + project: 'pkg', + task: 'build', + status: 'success', + startedAt: Date.now() - 1000, + }), + mkRun({ + hash: 'h2', + project: 'pkg', + task: 'build', + status: 'cache-hit', + startedAt: Date.now() - 500, + }), + ]) + const stats = getCacheStatsSql(cache.dbHandle()) + expect(stats.runCountLast24h).toBe(2) + expect(stats.hitCountLast24h).toBe(1) + expect(stats.hitRate24h).toBeCloseTo(0.5) + }) + }) +}) + +describe('getHistory', () => { + it('rolls (project, task) aggregates with failureMode classification', () => { + withCache((cache) => { + const now = Date.now() + cache.recordRuns( + Array.from({ length: 6 }, (_, i) => + mkRun({ + hash: `h${i}`, + project: 'pkg', + task: 'test', + status: i === 5 ? 'failed' : 'success', + startedAt: now - 1000 * (6 - i), + durationMs: 100 + i * 50, + }), + ), + ) + const rows = getHistory(cache.dbHandle(), { project: 'pkg', task: 'test' }) + expect(rows.length).toBe(1) + expect(rows[0]!.id).toBe('pkg#test') + expect(rows[0]!.runs).toBe(6) + expect(rows[0]!.successRate).toBeCloseTo(5 / 6, 5) + expect(rows[0]!.failureMode).toBe('flaky-recoverable') + expect(rows[0]!.p50DurationMs).toBeGreaterThan(0) + }) + }) +}) + +describe('explainCacheKeyQuery', () => { + it('returns the most recent entries row for a (project, task)', () => { + withCache((cache) => { + cache.recordRun(mkRun({ hash: 'h1', project: 'pkg', task: 'build', status: 'success' })) + const explained = explainCacheKeyQuery(cache.dbHandle(), 'pkg#build') + expect(explained.taskId).toBe('pkg#build') + expect(explained.project).toBe('pkg') + expect(explained.task).toBe('build') + }) + }) +}) + +describe('whyDidThisRerunQuery', () => { + it('compares hash between this and previous run', () => { + withCache((cache) => { + cache.recordRuns([ + mkRun({ hash: 'h1', project: 'pkg', task: 'test', runId: 'r-1', startedAt: 1000 }), + mkRun({ hash: 'h2', project: 'pkg', task: 'test', runId: 'r-2', startedAt: 2000 }), + ]) + const result = whyDidThisRerunQuery(cache.dbHandle(), 'r-2', 'pkg#test') + expect(result.found).toBe(true) + expect(result.thisRun!.hash).toBe('h2') + expect(result.previousRun!.hash).toBe('h1') + expect(result.hashChanged).toBe(true) + }) + }) + + it('returns found=false for an unknown runId', () => { + withCache((cache) => { + const result = whyDidThisRerunQuery(cache.dbHandle(), 'r-x', 'pkg#test') + expect(result.found).toBe(false) + }) + }) +}) diff --git a/tests/insights-static.test.ts b/tests/insights-static.test.ts deleted file mode 100644 index 1dcb55b..0000000 --- a/tests/insights-static.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -// Integration: vx insights serve's tiny static server for cache.db. -// Boots the static server against a temp file and verifies it streams -// the bytes with the right MIME + CORS headers. - -import { mkdtempSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import path from 'node:path' -import { describe, expect, it } from 'bun:test' -import { startStaticServer } from '../src/cli/index.js' - -describe('vx insights — static cache.db server', () => { - it('serves cache.db with correct content-type + CORS', async () => { - const dir = mkdtempSync(path.join(tmpdir(), 'vx-insights-static-')) - const dbPath = path.join(dir, 'cache.db') - const bytes = new Uint8Array(Array.from('SQLite format 3\0', (c) => c.charCodeAt(0))) - await Bun.write(dbPath, bytes) - - const server = startStaticServer(dbPath) - try { - const res = await fetch(`http://127.0.0.1:${server.port}/cache.db`) - expect(res.status).toBe(200) - expect(res.headers.get('content-type')).toBe('application/vnd.sqlite3') - expect(res.headers.get('access-control-allow-origin')).toBe('*') - const body = new Uint8Array(await res.arrayBuffer()) - expect(body.byteLength).toBe(bytes.byteLength) - // First bytes of a SQLite header - expect(new TextDecoder().decode(body).startsWith('SQLite format 3')).toBe(true) - } finally { - server.stop() - rmSync(dir, { recursive: true, force: true }) - } - }) - - it('returns /health → 200', async () => { - const dir = mkdtempSync(path.join(tmpdir(), 'vx-insights-static-h-')) - const dbPath = path.join(dir, 'cache.db') - await Bun.write(dbPath, new Uint8Array([0])) - const server = startStaticServer(dbPath) - try { - const res = await fetch(`http://127.0.0.1:${server.port}/health`) - expect(res.status).toBe(200) - expect(await res.text()).toBe('ok') - } finally { - server.stop() - rmSync(dir, { recursive: true, force: true }) - } - }) - - it('returns 404 for unknown paths', async () => { - const dir = mkdtempSync(path.join(tmpdir(), 'vx-insights-static-n-')) - const dbPath = path.join(dir, 'cache.db') - await Bun.write(dbPath, new Uint8Array([0])) - const server = startStaticServer(dbPath) - try { - const res = await fetch(`http://127.0.0.1:${server.port}/nope`) - expect(res.status).toBe(404) - } finally { - server.stop() - rmSync(dir, { recursive: true, force: true }) - } - }) -}) diff --git a/tests/serve.test.ts b/tests/serve.test.ts index 2752ed0..bd20cf0 100644 --- a/tests/serve.test.ts +++ b/tests/serve.test.ts @@ -85,6 +85,116 @@ describe('vx serve delegation', () => { }) }) +describe('vx serve /v1/* insights API', () => { + it('serves runs / invocations / cache stats / history after a delegated run', async () => { + const root = await makeWorkspace() + const server = await startServe({ root }) + try { + // Execute one delegated run to populate cache.db + const backend = serviceBackend(server.origin, captureLogger([])) + await backend.run({ tasks: ['hello'], cwd: root, flow: 'focused' }) + + // /v1/runs + const runs = (await (await fetch(`${server.origin}/v1/runs`)).json()) as { + runs: { project: string; task: string }[] + } + expect(runs.runs.length).toBeGreaterThanOrEqual(1) + expect(runs.runs[0]!.project).toBe('demo') + expect(runs.runs[0]!.task).toBe('hello') + + // /v1/invocations + const inv = (await (await fetch(`${server.origin}/v1/invocations`)).json()) as { + invocations: { runId: string; taskCount: number }[] + } + expect(inv.invocations.length).toBeGreaterThanOrEqual(1) + expect(inv.invocations[0]!.taskCount).toBeGreaterThanOrEqual(1) + + // /v1/cache/stats + const stats = (await (await fetch(`${server.origin}/v1/cache/stats`)).json()) as { + entryCount: number + runCountLast24h: number + } + expect(stats.runCountLast24h).toBeGreaterThanOrEqual(1) + + // /v1/history + const hist = (await (await fetch(`${server.origin}/v1/history`)).json()) as { + history: { id: string; runs: number }[] + } + expect(hist.history.length).toBeGreaterThanOrEqual(1) + expect(hist.history.find((h) => h.id === 'demo#hello')).toBeTruthy() + + // /v1/runs/:runId + const runId = inv.invocations[0]!.runId + const detail = (await (await fetch(`${server.origin}/v1/runs/${runId}`)).json()) as { + runId: string + tasks: { task: string }[] + } + expect(detail.runId).toBe(runId) + expect(detail.tasks.length).toBeGreaterThanOrEqual(1) + + // /v1/explain/:taskId + const explain = (await ( + await fetch(`${server.origin}/v1/explain/${encodeURIComponent('demo#hello')}`) + ).json()) as { taskId: string; project: string; task: string } + expect(explain.taskId).toBe('demo#hello') + expect(explain.project).toBe('demo') + expect(explain.task).toBe('hello') + } finally { + await server.stop() + await rm(root, { recursive: true, force: true }) + } + }) + + it('returns 404 for an unknown run id', async () => { + const root = await makeWorkspace() + const server = await startServe({ root }) + try { + const res = await fetch(`${server.origin}/v1/runs/does-not-exist`) + expect(res.status).toBe(404) + } finally { + await server.stop() + await rm(root, { recursive: true, force: true }) + } + }) + + it('answers CORS preflight + emits permissive headers on JSON responses', async () => { + const root = await makeWorkspace() + const server = await startServe({ root }) + try { + const pre = await fetch(`${server.origin}/v1/runs`, { + method: 'OPTIONS', + headers: { Origin: 'https://example.com' }, + }) + expect(pre.status).toBe(204) + expect(pre.headers.get('access-control-allow-origin')).toBe('*') + + const res = await fetch(`${server.origin}/v1/runs`) + expect(res.headers.get('access-control-allow-origin')).toBe('*') + } finally { + await server.stop() + await rm(root, { recursive: true, force: true }) + } + }) + + it('advertises workspace + RPC capabilities on /version', async () => { + const root = await makeWorkspace() + const server = await startServe({ root }) + try { + const v = (await (await fetch(`${server.origin}/version`)).json()) as { + vx: string + workspace: string + rpc: string[] + } + expect(typeof v.vx).toBe('string') + expect(v.workspace).toBe(root) + expect(v.rpc).toContain('getCacheStats') + } finally { + await server.stop() + await rm(root, { recursive: true, force: true }) + } + }) +}) + describe('resolveBackend', () => { it('falls back to local when no service is reachable', async () => { const root = await mkdtemp(path.join(tmpdir(), 'vx-nosvc-'))