diff --git a/meridian/.gitignore b/meridian/.gitignore new file mode 100644 index 0000000..4b6448d --- /dev/null +++ b/meridian/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +dist/ +data/ +data-eval-tmp/ +*.log +.DS_Store diff --git a/meridian/DESIGN.md b/meridian/DESIGN.md new file mode 100644 index 0000000..b57c68e --- /dev/null +++ b/meridian/DESIGN.md @@ -0,0 +1,229 @@ +# Meridian — Design + +Meridian is a **visual automation-map platform**. You model a business as a +graph of typed nodes on a canvas, and that same graph runs as a live workflow: +triggers fire, data flows edge-to-edge, and every run is recorded. + +The design goal is a backend that is **correct, observable, and hard to break** — +a real execution engine rather than a demo. It has **zero runtime dependencies** +(pure Node.js standard library), so it runs anywhere Node 22+ runs. + +--- + +## 1. Core concepts + +| Concept | Meaning | +| --- | --- | +| **Workflow** | A directed graph: `nodes` + `edges` + `variables`. The "map" of a business process. | +| **Node** | A single unit of work. Has a `type`, a `config`, and named input/output **ports** (handles). | +| **Edge** | A directed connection `from (node, port) → to (node, port)`. Carries data downstream. | +| **NodeType** | A registered handler that defines a node's ports and `execute()` logic. The extension point. | +| **Trigger** | What starts a run: `manual`, `webhook`, or `schedule`. | +| **Run** | One execution of a workflow. Records per-node status, timing, I/O, and logs. | + +A workflow is just data (JSON). The engine is what gives it behavior. New +capability = new `NodeType`, registered once, usable in every workflow. + +--- + +## 2. Data model + +``` +Workflow { + id, name, description, + nodes: Node[], + edges: Edge[], + variables: Record, // workflow-scoped constants + createdAt, updatedAt +} + +Node { + id, type, // type must exist in the registry + name, // human label + config: Record, // per-node settings, may contain {{expressions}} + position: { x, y } // canvas placement (UI only) +} + +Edge { + id, + from: { node, port }, // source node + output port + to: { node, port } // target node + input port +} + +Run { + id, workflowId, status, // queued | running | succeeded | failed | canceled + trigger, input, + startedAt, finishedAt, + nodeRuns: NodeRun[], // one per executed node + output, error +} + +NodeRun { + nodeId, status, startedAt, finishedAt, + attempts, input, output, logs[], error +} +``` + +--- + +## 3. Execution engine + +The engine (`src/engine/engine.ts`) executes a workflow as a dataflow graph. + +**Ordering.** Nodes run in **topological order** (`src/util/graph.ts`). The +graph is validated first: every edge references real nodes/ports, and the graph +must be **acyclic** (cycles are rejected with the offending nodes named). + +**Data flow.** Each node produces an object keyed by output port. An edge +`A.out → B.in` makes `A`'s `out` value available to `B` as input port `in`. A +node with multiple inputs receives a map `{ portName: value }`. + +**Input resolution & expressions.** A node's `config` may contain template +expressions resolved at run time against a scope of: +- `input` — the node's incoming port values +- `vars` — workflow variables +- `trigger` — the triggering payload +- `nodes` — outputs of already-completed nodes (`nodes..`) + +Expressions use `{{ ... }}` with a small, **safe evaluator** (no `eval`) that +supports dotted paths, string/number/boolean literals, and a set of pure helper +functions (`upper`, `lower`, `now`, `default`, `json`, `len`, …). A bare +`"{{ input.x }}"` returns the raw typed value; interpolation inside a larger +string coerces to text. + +**Branching.** A node may emit only some of its output ports (e.g. a +`condition` node emits `true` **or** `false`). Downstream nodes whose *only* +inbound edges come from ports that did not fire are **pruned** (skipped) for that +run. This is how conditional paths work without a separate control-flow concept. + +**Reliability per node.** +- `retries` with exponential backoff (configurable, capped). +- `timeoutMs` — a node that exceeds it fails with a timeout error. +- `onError: "stop" | "continue"` — stop fails the whole run; continue prunes the + node's descendants but lets independent branches finish. + +**Observability.** The engine is an `EventEmitter`. It emits +`run:start`, `node:start`, `node:log`, `node:finish`, `run:finish`. The API +turns these into a **Server-Sent Events** stream so the UI shows live progress. + +**Determinism & isolation.** Node handlers get a controlled `ExecutionContext` +(logger, resolved input, config accessor, abort signal). They cannot see the +store or other nodes' internals except through resolved inputs — keeping the +graph the single source of truth. + +--- + +## 4. Built-in node types + +| Type | Ports (in → out) | Purpose | +| --- | --- | --- | +| `trigger` | → `out` | Entry point; emits the trigger payload. | +| `manual.input` | → `out` | Constant/seed value from config. | +| `transform` | `in` → `out` | Reshape data with expressions. | +| `condition` | `in` → `true` / `false` | Branch on a boolean expression. | +| `http.request` | `in` → `out` / `error` | Call an external API (real `fetch`). | +| `delay` | `in` → `out` | Wait N ms (bounded). | +| `log` | `in` → `out` | Record a message to the run log. | +| `merge` | `a`,`b` → `out` | Combine two branches into one object. | +| `template` | `in` → `out` | Render a string template. | +| `set.variable` | `in` → `out` | Compute a named value for downstream use. | +| `webhook.send` | `in` → `out` / `error` | POST a JSON payload to any URL. | +| `slack.message` | `in` → `out` / `error` | Post to a Slack Incoming Webhook. | +| `email.send` | `in` → `out` / `error` | Send email via the Resend API. | +| `llm.complete` | `in` → `out` / `error` | Call an Anthropic model (AI in the loop). | + +Integration nodes act on the world via `fetch`, read secrets from config or the +environment, and split success/failure across `out` and `error` ports so flows +can branch on outcome. Every type is registered in `src/engine/nodes/` and +self-describes its ports, +config schema, and defaults — the API exposes this catalog so the UI palette is +generated, never hard-coded. + +--- + +## 5. Triggers + +- **manual** — `POST /api/workflows/:id/run` with an optional JSON body. +- **webhook** — `POST /api/hooks/:workflowId` runs the workflow with the request + body as the trigger payload. Any workflow with a `trigger` node is reachable. +- **schedule** — an in-process scheduler (`src/triggers/scheduler.ts`) fires + workflows on a fixed interval declared in a `trigger` node's config + (`everyMs`). Registered on startup and when workflows change. + +Triggers are deliberately thin: they all funnel into `engine.run(workflow, +{ trigger, input })`, so execution semantics are identical no matter the source. + +--- + +## 6. Persistence + +A `Store` interface (`src/store/store.ts`) abstracts persistence. The default +implementation is a **file-backed JSON store** (`data/`) with atomic writes — +no database process required, so the app is self-contained. Swapping in Postgres +or SQLite later means one new class, no engine changes. + +--- + +## 7. HTTP API + +Built directly on `node:http` with a tiny typed router (`src/api/router.ts`). + +``` +GET /api/health +GET /api/node-types catalog for the UI palette +GET /api/workflows list +POST /api/workflows create +GET /api/workflows/:id read +PUT /api/workflows/:id update (revalidates the graph) +DELETE /api/workflows/:id delete +POST /api/workflows/:id/validate static validation report +POST /api/workflows/:id/run manual run (returns the Run) +GET /api/workflows/:id/runs run history +GET /api/runs/:id single run +GET /api/runs/:id/stream SSE live progress +POST /api/hooks/:id webhook trigger +``` + +Static files (`public/`) are served for everything else, so the canvas UI and +API share one origin and one `npm start`. + +--- + +## 8. MCP server (agent interface) + +Alongside the HTTP API, Meridian exposes an **MCP server** (`src/mcp/`) over +stdio so any Model Context Protocol client can drive the engine. It reuses the +exact same `WorkflowService`, registry, and store in-process — no HTTP hop — so +an AI agent and a human editing the canvas operate on one shared set of +workflows. + +The tool surface mirrors the service: introspect node types, CRUD workflows, +validate, run, and read run history. Design choices follow MCP best practice: + +- **Comprehensive, composable tools** (not one mega-tool) so an agent can plan: + list types → create → validate → run → inspect. +- **Zod-validated inputs** with rich descriptions and constraints. +- **Structured output** (`structuredContent`) plus a text rendering, and a + `response_format` toggle (markdown/json) on the catalog/list tools. +- **Behavior annotations** (`readOnlyHint`, `destructiveHint`, `idempotentHint`, + `openWorldHint`) so clients can reason about safety. +- **Actionable errors**: a missing workflow or invalid graph returns the + specific issues and the next tool to call, not a bare stack trace. +- **Response budgeting**: list tools truncate to a character limit and say so. + +Because the MCP layer is thin over the service, the engine's guarantees +(validation-before-run, branching, retries, observability) apply identically +whether a workflow is triggered by a human, a webhook, a schedule, or an agent. + +## 9. Why this is a strong foundation + +- **Zero runtime deps** → nothing to break on install; trivially auditable. +- **Graph-validated before execution** → no run starts on a malformed map. +- **Expressions without `eval`** → data-driven configs stay safe. +- **Per-node reliability (retry/timeout/error-policy)** → real-world resilience. +- **Event-sourced runs** → full observability and a live UI for free. +- **Registry-driven node types** → the product grows by adding handlers, not by + editing the engine. + +The result: the "map" a user draws is not a diagram of the automation — it *is* +the automation. diff --git a/meridian/README.md b/meridian/README.md new file mode 100644 index 0000000..eca3765 --- /dev/null +++ b/meridian/README.md @@ -0,0 +1,175 @@ +# Meridian + +**A visual automation-map platform.** Model any business process as a map of +typed nodes on a canvas — triggers, conditions, transforms, API calls — and that +same map runs as a live workflow engine. The map you draw *is* the automation. + +> Meridian doesn't magically "automate any business" on its own — no software +> does. What it gives you is a strong, general substrate: a validated dataflow +> engine plus a canvas, so you can wire up the automation for *your* business and +> run it for real. + +## Highlights + +- **Zero runtime dependencies.** The backend is pure Node.js standard library — + nothing to break on install, trivially auditable. +- **Real execution engine.** Topological execution, conditional branching, + per-node retries / timeouts / error policies, and full run history. +- **Safe expressions.** Node configs use `{{ ... }}` expressions evaluated by a + hand-written parser — never `eval`. +- **Live runs.** Executions stream over Server-Sent Events; the canvas lights up + node-by-node as they run. +- **Registry-driven.** Every capability is a node type in a registry; the UI + palette and config forms are generated from it. Add a feature = add a handler. + +See [`DESIGN.md`](./DESIGN.md) for the full architecture. + +## Quick start + +```bash +cd meridian +npm install +npm start # http://localhost:8787 +``` + +Open the URL, and you'll find a seeded **"Order triage"** workflow. Hit **▶ Run** +to watch it execute live, or drag new nodes from the palette and wire them up. + +### Other commands + +```bash +npm test # unit + engine tests (node:test) +npm run typecheck # tsc --noEmit +npm run build # compile to dist/ +npm run serve # run the compiled build +``` + +Config via env vars: `PORT`, `HOST`, `DATA_DIR`, `PUBLIC_DIR`. + +## How it works + +A **workflow** is a directed graph of **nodes** connected by **edges**. Each node +has a registered **type** that defines its input/output ports and its +`execute()` logic. To run a workflow, the engine: + +1. **Validates** the graph (types exist, ports match, no cycles). +2. Walks nodes in **topological order**. +3. For each node, gathers inbound port values, resolves `{{ expressions }}` in + its config, runs the handler with retries/timeout, and records the result. +4. **Prunes** branches that a condition didn't take. + +## Node types + +**Core:** `trigger` · `manual.input` · `transform` · `condition` · `template` · +`log` · `delay` · `merge` · `set.variable` · `http.request` + +**Integrations (act on the world):** `webhook.send` · `slack.message` · +`email.send` (Resend) · `llm.complete` (Anthropic — puts AI in the loop) + +Each self-describes its ports and config fields, so the front-end palette is +generated, never hard-coded. Integration nodes read secrets from config or the +environment (`SLACK_WEBHOOK_URL`, `RESEND_API_KEY`, `ANTHROPIC_API_KEY`) and +route success to `out` / failure to `error`, so a flow can branch on outcome. + +## API + +``` +GET /api/health +GET /api/node-types +GET /api/workflows POST /api/workflows +GET /api/workflows/:id PUT /api/workflows/:id DELETE …/:id +POST /api/workflows/:id/validate +POST /api/workflows/:id/run +GET /api/workflows/:id/run-stream (SSE live run) +GET /api/workflows/:id/runs +GET /api/runs/:id +POST /api/hooks/:id (webhook trigger) +``` + +## MCP server (drive it from an AI agent) + +Meridian ships an **MCP (Model Context Protocol) server** so any MCP client — +Claude Desktop, IDEs, or your own agent — can author, validate, and run business +automations through tools. It runs the engine **in-process** against the same +JSON store as the web app, so an agent and a human can collaborate on the same +workflows. + +```bash +npm run mcp # stdio transport (dev, via tsx) — for local clients +npm run mcp:http # Streamable HTTP transport on :8788 — for remote clients +# or, after `npm run build`: +npm run mcp:serve # node dist/mcp/index.js (stdio) +npm run mcp:http:serve # node dist/mcp/http-index.js (HTTP) + +npm run eval # drive the server and check evaluations/mcp_eval.xml (10/10) +``` + +**Transports.** `stdio` is for locally-spawned clients (Claude Desktop). The +**Streamable HTTP** transport runs stateless (no server sessions) and exposes +`POST /mcp` plus a `GET /health`, so you can host it and point remote clients at +`http://host:8788/mcp`. + +### Tools + +| Tool | What it does | +| --- | --- | +| `meridian_list_node_types` | Catalog of building blocks (call this first when authoring). | +| `meridian_list_workflows` | List automations. | +| `meridian_get_workflow` | Read one workflow in full. | +| `meridian_create_workflow` | Create a workflow from nodes + edges (returns a validation report). | +| `meridian_update_workflow` | Update a workflow (full graph replacement). | +| `meridian_delete_workflow` | Delete a workflow (destructive). | +| `meridian_validate_workflow` | Static validation without running. | +| `meridian_run_workflow` | Execute a workflow and return per-node results + output. | +| `meridian_list_runs` | Run history for a workflow. | +| `meridian_get_run` | One run in full, with per-node input/output/logs. | + +Every tool carries a detailed description, a Zod-validated input schema, +structured output, and behavior annotations (`readOnlyHint`, `destructiveHint`, +etc.). Errors are actionable — a missing workflow or an invalid graph comes back +with the specific issues and the next tool to call. + +### Claude Desktop config + +After `npm run build`, add this to `claude_desktop_config.json`: + +```json +{ + "mcpServers": { + "meridian": { + "command": "node", + "args": ["/absolute/path/to/meridian/dist/mcp/index.js"], + "env": { "DATA_DIR": "/absolute/path/to/meridian/data" } + } + } +} +``` + +Then ask the agent: *"List Meridian's node types, then build an automation that +auto-approves refunds under $50 and escalates the rest, and run it on a $120 +refund."* It will compose the tools to author and execute the workflow. + +## Project layout + +``` +meridian/ +├── src/ +│ ├── domain/ types +│ ├── engine/ execution engine, expressions, node registry + builtins +│ ├── store/ Store interface + JSON file store +│ ├── api/ node:http router, server, static serving +│ ├── mcp/ MCP server: tools, schemas, formatting (stdio) +│ ├── triggers/ schedule scheduler +│ ├── service.ts application service +│ └── app.ts composition root +├── public/ canvas UI (vanilla JS, no build) +└── test/ node:test suites +``` + +## Roadmap + +- Auth + multi-tenant workspaces +- Durable run queue (replace the in-process scheduler) +- More integration nodes (email, Slack, DB, LLM) +- Sub-workflows and reusable node groups +- Pluggable Postgres store behind the existing `Store` interface diff --git a/meridian/evaluations/mcp_eval.xml b/meridian/evaluations/mcp_eval.xml new file mode 100644 index 0000000..86b81c7 --- /dev/null +++ b/meridian/evaluations/mcp_eval.xml @@ -0,0 +1,48 @@ + + + + Using the Meridian MCP server, how many distinct node types are available in the catalog? + 14 + + + In the Meridian node-type catalog, what category does the 'http.request' node type belong to? + Integrations + + + In the Meridian catalog, which node type belongs to the 'AI' category? + llm.complete + + + In Meridian, the 'condition' node has two output ports. Which port does it emit on when its boolean expression evaluates to truthy? + true + + + In Meridian, list the input port names of the 'merge' node type, in the order given, separated by a comma and a space. + a, b + + + In the Meridian catalog, which two node types have zero input ports (i.e. they are entry points)? List their type identifiers alphabetically, separated by a comma and a space. + manual.input, trigger + + + In Meridian, what is the default value of the 'ms' config field on the 'delay' node type? + 500 + + + Create a Meridian workflow with a 'manual.input' node emitting {"amount": 150} into a 'condition' node whose expression is "input.in.amount > 100", with a 'template' node on the true port and another 'template' node on the false port. Run it. What is the status (succeeded/failed/skipped) of the node connected to the condition's 'false' port? + skipped + + + In Meridian, create and run a workflow with a single 'transform' node (no inbound edges) whose config output is {"doubled": "{{ 21 * 2 }}"}. What integer value does the 'doubled' field hold in the node's output? + 42 + + + Call meridian_run_workflow with an id that does not exist. Does the tool return an error result (true) or a successful run (false)? Answer true or false. + true + + diff --git a/meridian/evaluations/run-eval.mjs b/meridian/evaluations/run-eval.mjs new file mode 100644 index 0000000..c8fd4df --- /dev/null +++ b/meridian/evaluations/run-eval.mjs @@ -0,0 +1,203 @@ +#!/usr/bin/env node +// Eval runner for meridian-mcp-server. +// +// Spawns the MCP server over stdio and, for each QA pair in mcp_eval.xml, +// DERIVES the answer purely by calling MCP tools (the way an agent would) and +// compares it to the expected answer by normalized string equality. This both +// exercises the server end-to-end and proves every expected answer is +// achievable through the tool surface. +// +// Usage: node evaluations/run-eval.mjs (run from the meridian/ directory) + +import { spawn } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; + +const dir = path.dirname(fileURLToPath(import.meta.url)); +const root = path.resolve(dir, ".."); +const dataDir = path.join(root, "data-eval-tmp"); + +// ---- parse expected answers from the XML (order matters) ------------------ +const xml = readFileSync(path.join(dir, "mcp_eval.xml"), "utf8"); +const expected = [...xml.matchAll(/([\s\S]*?)<\/answer>/g)].map((m) => + m[1].trim(), +); + +// ---- MCP stdio plumbing --------------------------------------------------- +const child = spawn("npx", ["tsx", "src/mcp/index.ts"], { + cwd: root, + env: { ...process.env, DATA_DIR: dataDir }, + stdio: ["pipe", "pipe", "inherit"], +}); +let buf = ""; +const waiters = new Map(); +child.stdout.on("data", (chunk) => { + buf += chunk.toString(); + let i; + while ((i = buf.indexOf("\n")) >= 0) { + const line = buf.slice(0, i).trim(); + buf = buf.slice(i + 1); + if (!line) continue; + let msg; + try { + msg = JSON.parse(line); + } catch { + continue; + } + if (msg.id && waiters.has(msg.id)) { + waiters.get(msg.id)(msg); + waiters.delete(msg.id); + } + } +}); +let nextId = 1; +const rpc = (method, params) => + new Promise((resolve) => { + const id = nextId++; + waiters.set(id, resolve); + child.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n"); + }); +const notify = (method, params) => + child.stdin.write(JSON.stringify({ jsonrpc: "2.0", method, params }) + "\n"); +const call = (name, args = {}) => + rpc("tools/call", { name, arguments: args }).then((r) => { + const sc = r.result?.structuredContent; + return sc ?? JSON.parse(r.result.content[0].text); + }); + +// ---- helpers for the solvers --------------------------------------------- +const catalog = async () => + (await call("meridian_list_node_types", { response_format: "json" })).nodeTypes; +const typeByName = (cat, t) => cat.find((x) => x.type === t); + +async function runFreshWorkflow(nodes, edges, input) { + const created = await call("meridian_create_workflow", { + name: "eval-" + nextId, + nodes, + edges, + }); + const run = await call("meridian_run_workflow", { id: created.workflow.id, input }); + return { created, run }; +} + +// ---- one solver per QA pair (in file order) ------------------------------- +const solvers = [ + // 1. how many node types + async () => String((await catalog()).length), + // 2. category of http.request + async () => typeByName(await catalog(), "http.request").category, + // 3. which type is in the 'AI' category + async () => (await catalog()).find((x) => x.category === "AI").type, + // 4. which port does condition emit on when truthy — derive by running + async () => { + const { run } = await runFreshWorkflow( + [ + { id: "in", type: "manual.input", config: { value: true } }, + { id: "c", type: "condition", config: { expression: "true" } }, + { id: "t", type: "template", config: { text: "T" } }, + { id: "f", type: "template", config: { text: "F" } }, + ], + [ + { id: "e1", from: { node: "in", port: "out" }, to: { node: "c", port: "in" } }, + { id: "e2", from: { node: "c", port: "true" }, to: { node: "t", port: "in" } }, + { id: "e3", from: { node: "c", port: "false" }, to: { node: "f", port: "in" } }, + ], + true, + ); + const st = Object.fromEntries(run.run.nodeResults.map((n) => [n.nodeId, n.status])); + // the node that succeeded is wired to the truthy port + return st.t === "succeeded" ? "true" : "false"; + }, + // 5. merge input ports + async () => typeByName(await catalog(), "merge").inputs.map((p) => p.name).join(", "), + // 6. zero-input entry types, alphabetical + async () => + (await catalog()) + .filter((x) => x.inputs.length === 0) + .map((x) => x.type) + .sort() + .join(", "), + // 7. delay ms default + async () => + String(typeByName(await catalog(), "delay").fields.find((f) => f.key === "ms").default), + // 8. false-branch node status when amount 150 > 100 + async () => { + const { run } = await runFreshWorkflow( + [ + { id: "in", type: "manual.input", config: { value: { amount: 150 } } }, + { id: "c", type: "condition", config: { expression: "input.in.amount > 100" } }, + { id: "t", type: "template", config: { text: "hi" } }, + { id: "f", type: "template", config: { text: "lo" } }, + ], + [ + { id: "e1", from: { node: "in", port: "out" }, to: { node: "c", port: "in" } }, + { id: "e2", from: { node: "c", port: "true" }, to: { node: "t", port: "in" } }, + { id: "e3", from: { node: "c", port: "false" }, to: { node: "f", port: "in" } }, + ], + { amount: 150 }, + ); + return run.run.nodeResults.find((n) => n.nodeId === "f").status; + }, + // 9. transform doubled value + async () => { + const { created } = await runFreshWorkflow( + [{ id: "t", type: "transform", config: { output: { doubled: "{{ 21 * 2 }}" } } }], + [], + null, + ); + const run = await call("meridian_run_workflow", { id: created.workflow.id }); + // fetch full run detail to read node output + const runs = await call("meridian_list_runs", { workflow_id: created.workflow.id }); + const full = await call("meridian_get_run", { id: runs.runs[0].id }); + return String(full.run.nodeRuns.find((n) => n.nodeId === "t").output.out.doubled); + }, + // 10. running a missing workflow yields an error result + async () => { + const res = await call("meridian_run_workflow", { id: "does-not-exist" }); + return String(Boolean(res.error)); + }, +]; + +const norm = (s) => String(s).trim(); + +const main = async () => { + await rpc("initialize", { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "eval", version: "0" }, + }); + notify("notifications/initialized", {}); + + let pass = 0; + console.log("Running meridian-mcp-server evaluations\n"); + for (let i = 0; i < solvers.length; i++) { + let got; + try { + got = await solvers[i](); + } catch (e) { + got = `ERROR: ${e.message}`; + } + const ok = norm(got) === norm(expected[i]); + if (ok) pass++; + console.log( + `${ok ? "✓" : "✗"} Q${i + 1} expected="${expected[i]}" got="${got}"`, + ); + } + console.log(`\n${pass}/${solvers.length} passed`); + child.kill(); + process.exit(pass === solvers.length ? 0 : 1); +}; + +const t = setTimeout(() => { + console.log("timed out"); + child.kill(); + process.exit(1); +}, 60000); +t.unref(); + +main().catch((e) => { + console.error(e); + child.kill(); + process.exit(1); +}); diff --git a/meridian/package-lock.json b/meridian/package-lock.json new file mode 100644 index 0000000..1066277 --- /dev/null +++ b/meridian/package-lock.json @@ -0,0 +1,1732 @@ +{ + "name": "meridian", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "meridian", + "version": "0.1.0", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.29.0", + "zod": "^4.4.3" + }, + "devDependencies": { + "@types/node": "^22.10.0", + "tsx": "^4.19.0", + "typescript": "^5.7.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "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" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "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" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", + "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.0.tgz", + "integrity": "sha512-XKJXDsASUOo0LLtFwW5hCcQGH0N4WQc/Rn8/Pvoia+TJFOkkFPvrtW9lZOeeNcxQJspvOIERMwiRLsVFlhHEkA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", + "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "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" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.12.31", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.31.tgz", + "integrity": "sha512-zJIHFrl6bq3RDd2YusFNCDlM8qUprxKswyi/OPzPyzKDdyBXDqWx8bZlZ7R+saTdSTatUmb3O7K4SspGPaEOQg==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.4", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.4.tgz", + "integrity": "sha512-N8acGzVsQy6M/fjFcxtysNc4Q379TcM5dM/qKkNtsHFji88yANnXTr7BLeP75iPnFwBfQzM/jg2BZ9+HZrHCZA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tsx": { + "version": "4.23.1", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz", + "integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + } + } +} diff --git a/meridian/package.json b/meridian/package.json new file mode 100644 index 0000000..ebe7e26 --- /dev/null +++ b/meridian/package.json @@ -0,0 +1,35 @@ +{ + "name": "meridian", + "version": "0.1.0", + "private": true, + "description": "A visual automation-map platform: model any business as a graph of nodes and run it as a live workflow engine.", + "type": "module", + "engines": { + "node": ">=22" + }, + "bin": { + "meridian-mcp-server": "dist/mcp/index.js" + }, + "scripts": { + "dev": "tsx watch src/index.ts", + "start": "tsx src/index.ts", + "mcp": "tsx src/mcp/index.ts", + "mcp:serve": "node dist/mcp/index.js", + "mcp:http": "tsx src/mcp/http-index.ts", + "mcp:http:serve": "node dist/mcp/http-index.js", + "build": "tsc -p tsconfig.build.json", + "serve": "node dist/index.js", + "test": "tsx --test test/*.test.ts", + "eval": "node evaluations/run-eval.mjs", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "devDependencies": { + "@types/node": "^22.10.0", + "tsx": "^4.19.0", + "typescript": "^5.7.0" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.29.0", + "zod": "^4.4.3" + } +} diff --git a/meridian/public/app.js b/meridian/public/app.js new file mode 100644 index 0000000..3d1a840 --- /dev/null +++ b/meridian/public/app.js @@ -0,0 +1,555 @@ +// Meridian canvas UI — vanilla JS, no build step. +// Talks to the JSON API; renders nodes as absolutely-positioned divs and edges +// as SVG bezier paths. Runs stream live over Server-Sent Events. + +const $ = (sel) => document.querySelector(sel); +const api = { + async get(path) { + const r = await fetch(path); + if (!r.ok) throw await err(r); + return r.json(); + }, + async send(method, path, body) { + const r = await fetch(path, { + method, + headers: { "content-type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + if (!r.ok) throw await err(r); + return r.json(); + }, +}; +async function err(r) { + let msg = `${r.status}`; + try { + const j = await r.json(); + msg = j.error || msg; + if (j.issues?.length) msg += ": " + j.issues.map((i) => i.message).join("; "); + } catch {} + return new Error(msg); +} + +// ---- State --------------------------------------------------------------- +const state = { + catalog: [], + catalogByType: {}, + workflow: null, // { id, name, nodes, edges, variables } + selectedNodeId: null, + dragEdge: null, // { from: {node, port}, x, y } +}; + +// ---- Boot ---------------------------------------------------------------- +init(); + +async function init() { + bindTopbar(); + state.catalog = await api.get("/api/node-types"); + state.catalogByType = Object.fromEntries(state.catalog.map((t) => [t.type, t])); + renderPalette(); + await refreshWorkflowList(); + const list = await api.get("/api/workflows"); + if (list.length) await openWorkflow(list[0].id); + else newWorkflow(); + bindCanvasDragging(); +} + +// ---- Palette ------------------------------------------------------------- +function renderPalette() { + const host = $("#palette-list"); + host.innerHTML = ""; + const byCat = {}; + for (const t of state.catalog) (byCat[t.category] ??= []).push(t); + for (const [cat, items] of Object.entries(byCat)) { + const h = document.createElement("div"); + h.className = "palette-cat"; + h.textContent = cat; + host.appendChild(h); + for (const t of items) { + const el = document.createElement("div"); + el.className = "palette-item"; + el.innerHTML = ` +
${t.label}
+
${t.description.slice(0, 46)}${t.description.length > 46 ? "…" : ""}
`; + el.onclick = () => addNode(t.type); + host.appendChild(el); + } + } +} + +// ---- Workflow lifecycle -------------------------------------------------- +function newWorkflow() { + state.workflow = { + id: null, + name: "Untitled workflow", + description: "", + nodes: [], + edges: [], + variables: {}, + }; + state.selectedNodeId = null; + $("#wf-name").value = state.workflow.name; + renderAll(); +} + +async function refreshWorkflowList() { + const list = await api.get("/api/workflows"); + const sel = $("#wf-select"); + sel.innerHTML = + `` + + list.map((w) => ``).join(""); + if (state.workflow?.id) sel.value = state.workflow.id; +} + +async function openWorkflow(id) { + const wf = await api.get(`/api/workflows/${id}`); + state.workflow = wf; + state.selectedNodeId = null; + $("#wf-name").value = wf.name; + $("#wf-select").value = id; + renderAll(); +} + +async function saveWorkflow() { + const wf = state.workflow; + wf.name = $("#wf-name").value.trim() || "Untitled workflow"; + const payload = { + name: wf.name, + description: wf.description, + nodes: wf.nodes, + edges: wf.edges, + variables: wf.variables, + }; + const saved = wf.id + ? await api.send("PUT", `/api/workflows/${wf.id}`, payload) + : await api.send("POST", "/api/workflows", payload); + state.workflow = saved; + await refreshWorkflowList(); + $("#wf-select").value = saved.id; + toast("Saved", "ok"); + return saved; +} + +// ---- Nodes --------------------------------------------------------------- +function addNode(type) { + const spec = state.catalogByType[type]; + const wrap = $("#canvas-wrap").getBoundingClientRect(); + const config = {}; + for (const f of spec.fields) if (f.default !== undefined) config[f.key] = f.default; + const node = { + id: "n_" + Math.random().toString(36).slice(2, 8), + type, + name: spec.label, + config, + position: { + x: 120 + Math.round(Math.random() * 60), + y: 80 + Math.round(Math.random() * 60), + }, + }; + state.workflow.nodes.push(node); + state.selectedNodeId = node.id; + renderAll(); +} + +function deleteNode(id) { + const wf = state.workflow; + wf.nodes = wf.nodes.filter((n) => n.id !== id); + wf.edges = wf.edges.filter((e) => e.from.node !== id && e.to.node !== id); + if (state.selectedNodeId === id) state.selectedNodeId = null; + renderAll(); +} + +// ---- Rendering ----------------------------------------------------------- +function renderAll() { + renderNodes(); + renderEdges(); + renderInspector(); + $("#canvas-empty").style.display = state.workflow.nodes.length ? "none" : "flex"; +} + +function renderNodes() { + const canvas = $("#canvas"); + canvas.innerHTML = ""; + for (const node of state.workflow.nodes) { + const spec = state.catalogByType[node.type] || { + color: "#888", + inputs: [], + outputs: [], + label: node.type, + }; + const el = document.createElement("div"); + el.className = "node" + (state.selectedNodeId === node.id ? " selected" : ""); + el.style.left = node.position.x + "px"; + el.style.top = node.position.y + "px"; + el.dataset.id = node.id; + + const inputs = spec.inputs + .map( + (p) => + `
${p.name}
`, + ) + .join(""); + const outputs = spec.outputs + .map( + (p) => + `
${p.name}
`, + ) + .join(""); + + el.innerHTML = ` +
+ + ${escapeHtml(node.name)} + ${node.type} +
+
+
${inputs}
+
${outputs}
+
`; + canvas.appendChild(el); + + el.querySelector(".node-head").addEventListener("mousedown", (e) => + startNodeDrag(e, node), + ); + el.addEventListener("mousedown", () => selectNode(node.id)); + for (const knob of el.querySelectorAll('.knob[data-dir="out"]')) + knob.addEventListener("mousedown", (e) => startEdgeDrag(e, node.id, knob.dataset.port)); + for (const knob of el.querySelectorAll('.knob[data-dir="in"]')) + knob.addEventListener("mouseup", (e) => finishEdgeDrag(e, node.id, knob.dataset.port)); + } +} + +function knobCenter(nodeId, dir, port) { + const sel = `.node[data-id="${nodeId}"] .knob[data-dir="${dir}"][data-port="${port}"]`; + const knob = document.querySelector(sel); + const wrap = $("#canvas-wrap").getBoundingClientRect(); + if (!knob) return null; + const r = knob.getBoundingClientRect(); + return { x: r.left + r.width / 2 - wrap.left, y: r.top + r.height / 2 - wrap.top }; +} + +function bezier(a, b) { + const dx = Math.max(40, Math.abs(b.x - a.x) / 2); + return `M ${a.x} ${a.y} C ${a.x + dx} ${a.y}, ${b.x - dx} ${b.y}, ${b.x} ${b.y}`; +} + +function renderEdges(liveEdgeIds = new Set()) { + const svg = $("#edges"); + svg.innerHTML = ""; + for (const e of state.workflow.edges) { + const a = knobCenter(e.from.node, "out", e.from.port); + const b = knobCenter(e.to.node, "in", e.to.port); + if (!a || !b) continue; + const hit = document.createElementNS("http://www.w3.org/2000/svg", "path"); + hit.setAttribute("d", bezier(a, b)); + hit.setAttribute("class", "edge-hit"); + hit.addEventListener("click", () => { + state.workflow.edges = state.workflow.edges.filter((x) => x.id !== e.id); + renderAll(); + }); + const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); + path.setAttribute("d", bezier(a, b)); + path.setAttribute( + "class", + "edge-path" + (liveEdgeIds.has(e.id) ? " live" : ""), + ); + svg.appendChild(path); + svg.appendChild(hit); + } + if (state.dragEdge) { + const a = knobCenter(state.dragEdge.from.node, "out", state.dragEdge.from.port); + if (a) { + const p = document.createElementNS("http://www.w3.org/2000/svg", "path"); + p.setAttribute("d", bezier(a, { x: state.dragEdge.x, y: state.dragEdge.y })); + p.setAttribute("class", "edge-temp"); + svg.appendChild(p); + } + } +} + +// ---- Inspector ----------------------------------------------------------- +function selectNode(id) { + state.selectedNodeId = id; + document + .querySelectorAll(".node") + .forEach((n) => n.classList.toggle("selected", n.dataset.id === id)); + renderInspector(); +} + +function renderInspector() { + const empty = $("#inspector-empty"); + const body = $("#inspector-body"); + const node = state.workflow.nodes.find((n) => n.id === state.selectedNodeId); + if (!node) { + empty.hidden = false; + body.hidden = true; + return; + } + empty.hidden = true; + body.hidden = false; + const spec = state.catalogByType[node.type]; + body.innerHTML = `

${escapeHtml(spec?.label || node.type)}

+

${escapeHtml(spec?.description || "")}

+
`; + + for (const f of spec?.fields || []) { + const val = node.config[f.key]; + body.appendChild(fieldEl(f, val)); + } + + const rel = document.createElement("div"); + rel.className = "field"; + rel.innerHTML = ` +
+ + +
`; + body.appendChild(rel); + + const del = document.createElement("button"); + del.className = "danger"; + del.textContent = "Delete node"; + del.onclick = () => deleteNode(node.id); + body.appendChild(del); + + // Wire inputs. + $("#f-name").oninput = (e) => { + node.name = e.target.value; + const title = document.querySelector(`.node[data-id="${node.id}"] .node-title`); + if (title) title.textContent = node.name; + }; + $("#f-retries").oninput = (e) => { + const v = e.target.value; + if (v === "") delete node.retries; + else node.retries = Number(v); + }; + $("#f-onerror").onchange = (e) => (node.onError = e.target.value); + for (const f of spec?.fields || []) { + const inp = document.getElementById("cfg-" + f.key); + if (!inp) continue; + inp.addEventListener("input", () => { + node.config[f.key] = readField(f, inp); + }); + } +} + +function fieldEl(f, val) { + const wrap = document.createElement("div"); + wrap.className = "field"; + const id = "cfg-" + f.key; + let control; + if (f.type === "boolean") { + control = ``; + } else if (f.type === "text" || f.type === "json") { + const text = + f.type === "json" && typeof val !== "string" + ? JSON.stringify(val ?? f.default ?? null, null, 2) + : val ?? ""; + control = ``; + } else if (f.type === "number") { + control = ``; + } else { + control = ``; + } + wrap.innerHTML = `${control}${f.help ? `
${escapeHtml(f.help)}
` : ""}`; + return wrap; +} + +function readField(f, inp) { + if (f.type === "boolean") return inp.value === "true"; + if (f.type === "number") return inp.value === "" ? null : Number(inp.value); + if (f.type === "json") { + try { + return JSON.parse(inp.value); + } catch { + return inp.value; // keep as string (may be an expression) + } + } + return inp.value; +} + +// ---- Dragging: nodes & edges -------------------------------------------- +let nodeDrag = null; +function startNodeDrag(e, node) { + e.preventDefault(); + const wrap = $("#canvas-wrap").getBoundingClientRect(); + nodeDrag = { + node, + offX: e.clientX - wrap.left - node.position.x, + offY: e.clientY - wrap.top - node.position.y, + }; +} + +function startEdgeDrag(e, nodeId, port) { + e.preventDefault(); + e.stopPropagation(); + state.dragEdge = { from: { node: nodeId, port }, x: e.clientX, y: e.clientY }; +} + +function finishEdgeDrag(e, nodeId, port) { + if (!state.dragEdge) return; + e.stopPropagation(); + const from = state.dragEdge.from; + if (from.node !== nodeId) { + // prevent duplicates + const exists = state.workflow.edges.some( + (x) => + x.from.node === from.node && + x.from.port === from.port && + x.to.node === nodeId && + x.to.port === port, + ); + if (!exists) { + state.workflow.edges.push({ + id: "e_" + Math.random().toString(36).slice(2, 8), + from, + to: { node: nodeId, port }, + }); + } + } + state.dragEdge = null; + renderAll(); +} + +function bindCanvasDragging() { + window.addEventListener("mousemove", (e) => { + const wrap = $("#canvas-wrap").getBoundingClientRect(); + if (nodeDrag) { + nodeDrag.node.position.x = Math.round(e.clientX - wrap.left - nodeDrag.offX); + nodeDrag.node.position.y = Math.round(e.clientY - wrap.top - nodeDrag.offY); + const el = document.querySelector(`.node[data-id="${nodeDrag.node.id}"]`); + if (el) { + el.style.left = nodeDrag.node.position.x + "px"; + el.style.top = nodeDrag.node.position.y + "px"; + } + renderEdges(); + } else if (state.dragEdge) { + state.dragEdge.x = e.clientX - wrap.left; + state.dragEdge.y = e.clientY - wrap.top; + renderEdges(); + } + }); + window.addEventListener("mouseup", () => { + nodeDrag = null; + if (state.dragEdge) { + state.dragEdge = null; + renderEdges(); + } + }); +} + +// ---- Run (SSE) ----------------------------------------------------------- +async function runWorkflow() { + const saved = await saveWorkflow(); + clearRunStatus(); + document + .querySelectorAll(".node") + .forEach((n) => + n.classList.remove("status-running", "status-succeeded", "status-failed", "status-skipped"), + ); + setRunStatus("running", "running"); + logLine("system", "", `Run started for “${saved.name}”`); + + const es = new EventSource(`/api/workflows/${saved.id}/run-stream`); + es.addEventListener("node:start", (ev) => { + const { nodeId } = JSON.parse(ev.data); + markNode(nodeId, "running"); + }); + es.addEventListener("node:log", (ev) => { + const { nodeId, entry } = JSON.parse(ev.data); + logLine(entry.level, nodeName(nodeId), entry.message); + }); + es.addEventListener("node:finish", (ev) => { + const { nodeRun } = JSON.parse(ev.data); + markNode(nodeRun.nodeId, nodeRun.status); + if (nodeRun.status === "failed") + logLine("error", nodeRun.name, nodeRun.error || "failed"); + }); + es.addEventListener("run:finish", (ev) => { + const { run } = JSON.parse(ev.data); + setRunStatus(run.status, run.status); + if (run.status === "succeeded") + logLine("system", "", "✓ Completed. Output: " + JSON.stringify(run.output)); + else logLine("error", "", run.error || "Run failed"); + }); + es.addEventListener("done", () => es.close()); + es.addEventListener("error", (ev) => { + try { + const d = JSON.parse(ev.data); + logLine("error", "", d.message || "stream error"); + setRunStatus("failed", "failed"); + } catch {} + es.close(); + }); +} + +function markNode(nodeId, status) { + const el = document.querySelector(`.node[data-id="${nodeId}"]`); + if (!el) return; + el.classList.remove("status-running", "status-succeeded", "status-failed", "status-skipped"); + el.classList.add("status-" + status); +} +function nodeName(id) { + return state.workflow.nodes.find((n) => n.id === id)?.name || id; +} + +function logLine(level, tag, msg) { + const el = document.createElement("div"); + el.className = "log-line " + level; + el.innerHTML = `${tag ? `${escapeHtml(tag)}` : ""}${escapeHtml(msg)}`; + const log = $("#run-log"); + log.appendChild(el); + log.scrollTop = log.scrollHeight; +} +function setRunStatus(text, cls) { + const el = $("#run-status"); + el.textContent = text; + el.className = "run-status " + cls; +} +function clearRunStatus() { + $("#run-status").textContent = ""; + $("#run-status").className = "run-status"; +} + +// ---- Topbar -------------------------------------------------------------- +function bindTopbar() { + $("#btn-new").onclick = () => newWorkflow(); + $("#btn-save").onclick = () => saveWorkflow().catch((e) => toast(e.message, "error")); + $("#btn-run").onclick = () => runWorkflow().catch((e) => toast(e.message, "error")); + $("#btn-validate").onclick = () => validateWorkflow().catch((e) => toast(e.message, "error")); + $("#btn-clear-log").onclick = () => ($("#run-log").innerHTML = ""); + $("#wf-select").onchange = (e) => { + if (e.target.value) openWorkflow(e.target.value).catch((x) => toast(x.message, "error")); + }; +} + +async function validateWorkflow() { + const saved = await saveWorkflow(); + const res = await api.send("POST", `/api/workflows/${saved.id}/validate`, {}); + if (res.valid && res.issues.length === 0) { + toast("Valid ✓", "ok"); + } else if (res.valid) { + toast(res.issues.map((i) => i.message).join("; "), "ok"); + } else { + toast(res.issues.filter((i) => i.level === "error").map((i) => i.message).join("; "), "error"); + } +} + +// ---- Utils --------------------------------------------------------------- +let toastTimer; +function toast(msg, kind = "") { + const el = $("#toast"); + el.textContent = msg; + el.className = "toast " + kind; + el.hidden = false; + clearTimeout(toastTimer); + toastTimer = setTimeout(() => (el.hidden = true), 3200); +} +function escapeHtml(s) { + return String(s ?? "").replace( + /[&<>"']/g, + (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c], + ); +} diff --git a/meridian/public/index.html b/meridian/public/index.html new file mode 100644 index 0000000..0197f4c --- /dev/null +++ b/meridian/public/index.html @@ -0,0 +1,64 @@ + + + + + + Meridian — Automation Map + + + + +
+
+ + Meridian + automation map +
+
+ + + + + + +
+
+ +
+ + +
+ +
+
+

Drop a Trigger and wire it to actions.

+

The map you draw is the automation.

+
+
+ + +
+ +
+
+ Run log + + +
+
+
+ + + + + + diff --git a/meridian/public/style.css b/meridian/public/style.css new file mode 100644 index 0000000..3c89ca7 --- /dev/null +++ b/meridian/public/style.css @@ -0,0 +1,490 @@ +:root { + --bg: #0b0f1a; + --panel: #121826; + --panel-2: #182036; + --line: #263049; + --text: #e6ebf5; + --muted: #8b97b0; + --accent: #6ea8fe; + --accent-2: #22c55e; + --danger: #ef4444; + --warn: #f59e0b; + --radius: 10px; + --shadow: 0 6px 24px rgba(0, 0, 0, 0.35); + font-family: + ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; +} + +* { + box-sizing: border-box; +} +html, +body { + margin: 0; + height: 100%; + background: var(--bg); + color: var(--text); + overflow: hidden; +} + +button { + font: inherit; + color: var(--text); + background: var(--panel-2); + border: 1px solid var(--line); + padding: 7px 13px; + border-radius: 8px; + cursor: pointer; + transition: 0.12s; +} +button:hover { + border-color: var(--accent); +} +button.primary { + background: var(--accent); + color: #06122b; + border-color: var(--accent); + font-weight: 600; +} +button.primary:hover { + filter: brightness(1.08); +} +button.ghost { + background: transparent; +} +button.small { + padding: 3px 9px; + font-size: 12px; +} + +input, +select, +textarea { + font: inherit; + color: var(--text); + background: var(--panel); + border: 1px solid var(--line); + border-radius: 8px; + padding: 7px 9px; +} +input:focus, +select:focus, +textarea:focus { + outline: none; + border-color: var(--accent); +} + +/* ---------- Top bar ---------- */ +.topbar { + height: 54px; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 16px; + background: var(--panel); + border-bottom: 1px solid var(--line); + z-index: 10; +} +.brand { + display: flex; + align-items: center; + gap: 9px; + font-weight: 700; + letter-spacing: 0.3px; +} +.brand .logo { + color: var(--accent); + font-size: 20px; +} +.brand .tag { + font-size: 11px; + font-weight: 500; + color: var(--muted); + border: 1px solid var(--line); + padding: 2px 7px; + border-radius: 20px; + text-transform: uppercase; + letter-spacing: 1px; +} +.wf-controls { + display: flex; + gap: 8px; + align-items: center; +} +.wf-controls #wf-name { + width: 180px; +} +.wf-controls #wf-select { + max-width: 200px; +} + +/* ---------- Layout ---------- */ +.layout { + display: grid; + grid-template-columns: 220px 1fr 300px; + height: calc(100% - 54px - 180px); +} +.palette, +.inspector { + background: var(--panel); + overflow-y: auto; + padding: 14px; +} +.palette { + border-right: 1px solid var(--line); +} +.inspector { + border-left: 1px solid var(--line); +} +.palette h2 { + font-size: 13px; + text-transform: uppercase; + letter-spacing: 1px; + color: var(--muted); + margin: 4px 0; +} +.hint { + font-size: 12px; + color: var(--muted); + margin: 0 0 10px; +} + +.palette-cat { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 1px; + color: var(--muted); + margin: 14px 0 6px; +} +.palette-item { + display: flex; + align-items: center; + gap: 9px; + padding: 8px 10px; + border: 1px solid var(--line); + border-radius: 8px; + margin-bottom: 6px; + cursor: pointer; + transition: 0.12s; + background: var(--panel-2); +} +.palette-item:hover { + border-color: var(--accent); + transform: translateX(2px); +} +.palette-item .dot { + width: 10px; + height: 10px; + border-radius: 50%; + flex: none; +} +.palette-item .pi-label { + font-size: 13px; + font-weight: 600; +} +.palette-item .pi-desc { + font-size: 11px; + color: var(--muted); +} + +/* ---------- Canvas ---------- */ +.canvas-wrap { + position: relative; + overflow: hidden; + background: + radial-gradient(circle at 1px 1px, #1b2338 1px, transparent 0) 0 0 / 22px + 22px; +} +.edges { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + pointer-events: none; + z-index: 1; +} +.canvas { + position: absolute; + inset: 0; + z-index: 2; +} +.canvas-empty { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + color: var(--muted); + pointer-events: none; + text-align: center; +} +.canvas-empty .sub { + font-size: 13px; + opacity: 0.7; +} + +.node { + position: absolute; + min-width: 168px; + background: var(--panel-2); + border: 1px solid var(--line); + border-radius: var(--radius); + box-shadow: var(--shadow); + user-select: none; + z-index: 2; +} +.node.selected { + border-color: var(--accent); + box-shadow: 0 0 0 2px rgba(110, 168, 254, 0.35); +} +.node .node-head { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 10px; + border-bottom: 1px solid var(--line); + border-top-left-radius: var(--radius); + border-top-right-radius: var(--radius); + cursor: grab; +} +.node .node-head .swatch { + width: 9px; + height: 9px; + border-radius: 50%; + flex: none; +} +.node .node-title { + font-size: 13px; + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.node .node-type { + font-size: 10px; + color: var(--muted); + margin-left: auto; +} +.node .ports { + display: flex; + justify-content: space-between; + padding: 8px 0; + gap: 12px; +} +.node .port-col { + display: flex; + flex-direction: column; + gap: 8px; +} +.node .port-col.out { + align-items: flex-end; +} +.port { + display: flex; + align-items: center; + gap: 6px; + font-size: 11px; + color: var(--muted); +} +.port.out { + flex-direction: row-reverse; +} +.port .knob { + width: 12px; + height: 12px; + border-radius: 50%; + background: var(--panel); + border: 2px solid var(--accent); + cursor: crosshair; +} +.port .knob:hover { + background: var(--accent); +} + +/* node run status */ +.node.status-running { + border-color: var(--accent); + animation: pulse 1s infinite; +} +.node.status-succeeded { + border-color: var(--accent-2); +} +.node.status-failed { + border-color: var(--danger); +} +.node.status-skipped { + opacity: 0.5; +} +@keyframes pulse { + 0%, + 100% { + box-shadow: 0 0 0 2px rgba(110, 168, 254, 0.15); + } + 50% { + box-shadow: 0 0 0 5px rgba(110, 168, 254, 0.4); + } +} + +.edge-path { + fill: none; + stroke: #3a4568; + stroke-width: 2; +} +.edge-path.live { + stroke: var(--accent-2); + stroke-width: 3; +} +.edge-hit { + fill: none; + stroke: transparent; + stroke-width: 14; + pointer-events: stroke; + cursor: pointer; +} + +/* ---------- Inspector ---------- */ +.inspector-empty { + color: var(--muted); + font-size: 13px; + text-align: center; + margin-top: 30px; +} +.field { + margin-bottom: 12px; +} +.field label { + display: block; + font-size: 12px; + color: var(--muted); + margin-bottom: 4px; +} +.field input, +.field select, +.field textarea { + width: 100%; +} +.field textarea { + min-height: 66px; + font-family: ui-monospace, "SF Mono", Menlo, monospace; + font-size: 12px; + resize: vertical; +} +.field .help { + font-size: 11px; + color: var(--muted); + margin-top: 3px; +} +.inspector h3 { + margin: 0 0 4px; + font-size: 15px; +} +.inspector .sub { + font-size: 12px; + color: var(--muted); + margin: 0 0 14px; +} +.danger { + color: var(--danger); + border-color: var(--danger); + width: 100%; + margin-top: 10px; +} +.danger:hover { + background: var(--danger); + color: #fff; +} + +/* ---------- Run bar ---------- */ +.runbar { + height: 180px; + background: var(--panel); + border-top: 1px solid var(--line); + display: flex; + flex-direction: column; +} +.runbar-head { + display: flex; + align-items: center; + gap: 12px; + padding: 8px 14px; + border-bottom: 1px solid var(--line); + font-size: 13px; +} +.run-status { + font-size: 12px; + padding: 2px 8px; + border-radius: 20px; + border: 1px solid var(--line); + color: var(--muted); +} +.run-status.running { + color: var(--accent); + border-color: var(--accent); +} +.run-status.succeeded { + color: var(--accent-2); + border-color: var(--accent-2); +} +.run-status.failed { + color: var(--danger); + border-color: var(--danger); +} +.runbar-head .ghost { + margin-left: auto; +} +.run-log { + flex: 1; + overflow-y: auto; + padding: 8px 14px; + font-family: ui-monospace, "SF Mono", Menlo, monospace; + font-size: 12px; + line-height: 1.6; +} +.log-line { + display: flex; + gap: 10px; + white-space: pre-wrap; +} +.log-line .node-tag { + color: var(--accent); + flex: none; +} +.log-line.error .msg { + color: var(--danger); +} +.log-line.warn .msg { + color: var(--warn); +} +.log-line.system .msg { + color: var(--muted); +} + +/* ---------- Toast ---------- */ +.toast { + position: fixed; + bottom: 196px; + left: 50%; + transform: translateX(-50%); + background: var(--panel-2); + border: 1px solid var(--line); + padding: 10px 16px; + border-radius: 8px; + box-shadow: var(--shadow); + z-index: 100; + font-size: 13px; +} +.toast.error { + border-color: var(--danger); +} +.toast.ok { + border-color: var(--accent-2); +} + +/* temporary drag edge */ +.edge-temp { + stroke: var(--accent); + stroke-dasharray: 5 4; + stroke-width: 2; + fill: none; +} diff --git a/meridian/src/api/router.ts b/meridian/src/api/router.ts new file mode 100644 index 0000000..e08b7f3 --- /dev/null +++ b/meridian/src/api/router.ts @@ -0,0 +1,186 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; + +/** + * A minimal, dependency-free router over node:http. Supports path params + * (`/api/workflows/:id`), JSON body parsing, and typed helpers. Kept tiny on + * purpose — the app has zero runtime dependencies. + */ + +export interface Ctx { + req: IncomingMessage; + res: ServerResponse; + params: Record; + query: URLSearchParams; + url: URL; + json(): Promise; + send(status: number, body: unknown): void; + text(status: number, body: string, contentType?: string): void; +} + +type Handler = (ctx: Ctx) => void | Promise; +type Method = "GET" | "POST" | "PUT" | "DELETE" | "PATCH"; + +interface Route { + method: Method; + segments: string[]; + handler: Handler; +} + +export class Router { + private routes: Route[] = []; + private fallback?: Handler; + + add(method: Method, pattern: string, handler: Handler): this { + this.routes.push({ method, segments: split(pattern), handler }); + return this; + } + get(p: string, h: Handler) { + return this.add("GET", p, h); + } + post(p: string, h: Handler) { + return this.add("POST", p, h); + } + put(p: string, h: Handler) { + return this.add("PUT", p, h); + } + delete(p: string, h: Handler) { + return this.add("DELETE", p, h); + } + /** Handler used when no route matches (e.g. static files). */ + notFound(h: Handler): this { + this.fallback = h; + return this; + } + + async handle(req: IncomingMessage, res: ServerResponse): Promise { + const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`); + const parts = split(url.pathname); + const ctx = makeCtx(req, res, url); + + for (const route of this.routes) { + if (route.method !== req.method) continue; + const params = match(route.segments, parts); + if (params) { + ctx.params = params; + try { + await route.handler(ctx); + } catch (err) { + handleError(ctx, err); + } + return; + } + } + + if (this.fallback) { + try { + await this.fallback(ctx); + } catch (err) { + handleError(ctx, err); + } + } else { + ctx.send(404, { error: "Not found" }); + } + } +} + +function split(p: string): string[] { + return p.split("/").filter(Boolean); +} + +function match( + pattern: string[], + actual: string[], +): Record | null { + if (pattern.length !== actual.length) return null; + const params: Record = {}; + for (let i = 0; i < pattern.length; i++) { + const p = pattern[i]!; + const a = actual[i]!; + if (p.startsWith(":")) params[p.slice(1)] = decodeURIComponent(a); + else if (p !== a) return null; + } + return params; +} + +function makeCtx(req: IncomingMessage, res: ServerResponse, url: URL): Ctx { + return { + req, + res, + params: {}, + query: url.searchParams, + url, + async json(): Promise { + const raw = await readBody(req); + if (!raw) return {} as T; + try { + return JSON.parse(raw) as T; + } catch { + throw new HttpError(400, "Invalid JSON body"); + } + }, + send(status, body) { + const payload = JSON.stringify(body); + res.writeHead(status, { + "content-type": "application/json; charset=utf-8", + "content-length": Buffer.byteLength(payload), + }); + res.end(payload); + }, + text(status, body, contentType = "text/plain; charset=utf-8") { + res.writeHead(status, { "content-type": contentType }); + res.end(body); + }, + }; +} + +function readBody(req: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + let size = 0; + const MAX = 5 * 1024 * 1024; // 5MB guard + req.on("data", (c: Buffer) => { + size += c.length; + if (size > MAX) { + reject(new HttpError(413, "Payload too large")); + req.destroy(); + return; + } + chunks.push(c); + }); + req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))); + req.on("error", reject); + }); +} + +export class HttpError extends Error { + constructor( + public status: number, + message: string, + ) { + super(message); + this.name = "HttpError"; + } +} + +function handleError(ctx: Ctx, err: unknown): void { + if (err instanceof HttpError) { + ctx.send(err.status, { error: err.message }); + return; + } + // Domain errors carry recognizable names. + const name = (err as Error)?.name; + if (name === "NotFoundError") { + ctx.send(404, { error: (err as Error).message }); + return; + } + if (name === "ValidationError") { + ctx.send(422, { + error: (err as Error).message, + issues: (err as { issues?: unknown }).issues ?? [], + }); + return; + } + // eslint-disable-next-line no-console + console.error("Unhandled error:", err); + ctx.send(500, { error: "Internal server error" }); +} diff --git a/meridian/src/api/server.ts b/meridian/src/api/server.ts new file mode 100644 index 0000000..f8be2bc --- /dev/null +++ b/meridian/src/api/server.ts @@ -0,0 +1,142 @@ +import http from "node:http"; +import type { WorkflowService } from "../service.js"; +import type { TriggerInfo } from "../domain/types.js"; +import { Router, HttpError, type Ctx } from "./router.js"; +import { staticHandler } from "./static.js"; +import { hasErrors } from "../engine/validate.js"; +import type { RunEvent } from "../engine/engine.js"; + +export interface ServerDeps { + service: WorkflowService; + publicDir: string; +} + +/** Build the HTTP server: JSON API + SSE + static SPA, all on one origin. */ +export function buildServer(deps: ServerDeps): http.Server { + const { service } = deps; + const router = new Router(); + + router.get("/api/health", (c) => + c.send(200, { status: "ok", time: new Date().toISOString() }), + ); + + router.get("/api/node-types", (c) => c.send(200, service.catalog())); + + // --- Workflows --------------------------------------------------------- + router.get("/api/workflows", async (c) => c.send(200, await service.list())); + + router.post("/api/workflows", async (c) => { + const body = await c.json>(); + c.send(201, await service.create(body)); + }); + + router.get("/api/workflows/:id", async (c) => + c.send(200, await service.get(c.params.id!)), + ); + + router.put("/api/workflows/:id", async (c) => { + const body = await c.json>(); + c.send(200, await service.update(c.params.id!, body)); + }); + + router.delete("/api/workflows/:id", async (c) => { + await service.remove(c.params.id!); + c.send(200, { ok: true }); + }); + + router.post("/api/workflows/:id/validate", async (c) => { + const wf = await service.get(c.params.id!); + const issues = service.validate(wf); + c.send(200, { valid: !hasErrors(issues), issues }); + }); + + // --- Runs -------------------------------------------------------------- + router.post("/api/workflows/:id/run", async (c) => { + const body = await c.json<{ input?: unknown }>(); + const trigger: TriggerInfo = { kind: "manual", payload: body.input ?? null }; + c.send(200, await service.runById(c.params.id!, trigger)); + }); + + // Live run with Server-Sent Events (GET so EventSource can consume it). + router.get("/api/workflows/:id/run-stream", async (c) => { + await streamRun(c, service, c.params.id!, { + kind: "manual", + payload: parseQueryInput(c), + }); + }); + + router.get("/api/workflows/:id/runs", async (c) => { + const limit = Number(c.query.get("limit") ?? "50"); + c.send(200, await service.listRuns(c.params.id!, limit)); + }); + + router.get("/api/runs/:id", async (c) => + c.send(200, await service.getRun(c.params.id!)), + ); + + // --- Webhook trigger --------------------------------------------------- + router.post("/api/hooks/:id", async (c) => { + const payload = await c.json(); + const trigger: TriggerInfo = { kind: "webhook", payload }; + const run = await service.runById(c.params.id!, trigger); + c.send(run.status === "succeeded" ? 200 : 202, { + runId: run.id, + status: run.status, + output: run.output, + }); + }); + + // Static SPA for everything else. + router.notFound(staticHandler(deps.publicDir)); + + return http.createServer((req, res) => { + void router.handle(req, res); + }); +} + +function parseQueryInput(c: Ctx): unknown { + const raw = c.query.get("input"); + if (!raw) return null; + try { + return JSON.parse(raw); + } catch { + return raw; + } +} + +/** Run a workflow and stream engine events to the client as SSE. */ +async function streamRun( + c: Ctx, + service: WorkflowService, + workflowId: string, + trigger: TriggerInfo, +): Promise { + c.res.writeHead(200, { + "content-type": "text/event-stream; charset=utf-8", + "cache-control": "no-cache, no-transform", + connection: "keep-alive", + "x-accel-buffering": "no", + }); + + const write = (event: string, data: unknown) => { + c.res.write(`event: ${event}\n`); + c.res.write(`data: ${JSON.stringify(data)}\n\n`); + }; + + const onEvent = (e: RunEvent) => write(e.type, e); + + try { + const run = await service.runById(workflowId, trigger, onEvent); + write("done", { runId: run.id, status: run.status }); + } catch (err) { + write("error", { + message: err instanceof Error ? err.message : String(err), + issues: (err as { issues?: unknown }).issues ?? [], + }); + } finally { + c.res.end(); + } +} + +// Re-export so callers don't need to reach into router internals. +export { HttpError }; diff --git a/meridian/src/api/static.ts b/meridian/src/api/static.ts new file mode 100644 index 0000000..0662b5c --- /dev/null +++ b/meridian/src/api/static.ts @@ -0,0 +1,59 @@ +import { promises as fs } from "node:fs"; +import path from "node:path"; +import type { Ctx } from "./router.js"; + +const TYPES: Record = { + ".html": "text/html; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".svg": "image/svg+xml", + ".ico": "image/x-icon", + ".map": "application/json; charset=utf-8", +}; + +/** + * Serve files from `root` for non-API requests. Path traversal is blocked by + * resolving and checking the result stays within root. Unknown paths fall back + * to index.html so the single-page app can handle client routing. + */ +export function staticHandler(root: string) { + return async (ctx: Ctx): Promise => { + const rel = decodeURIComponent(ctx.url.pathname); + // Unknown API paths are genuine 404s, not SPA routes. + if (rel.startsWith("/api/")) { + ctx.send(404, { error: "Not found" }); + return; + } + let filePath = path.join(root, rel === "/" ? "/index.html" : rel); + const resolved = path.resolve(filePath); + if (!resolved.startsWith(path.resolve(root))) { + ctx.send(403, { error: "Forbidden" }); + return; + } + try { + let data = await fs.readFile(resolved); + let ext = path.extname(resolved); + if (!ext) { + // No extension: serve the SPA shell. + data = await fs.readFile(path.join(root, "index.html")); + ext = ".html"; + } + ctx.res.writeHead(200, { + "content-type": TYPES[ext] ?? "application/octet-stream", + "content-length": data.length, + "cache-control": "no-cache", + }); + ctx.res.end(data); + } catch { + // Fall back to the SPA shell for unknown routes. + try { + const shell = await fs.readFile(path.join(root, "index.html")); + ctx.res.writeHead(200, { "content-type": TYPES[".html"]! }); + ctx.res.end(shell); + } catch { + ctx.send(404, { error: "Not found" }); + } + } + }; +} diff --git a/meridian/src/app.ts b/meridian/src/app.ts new file mode 100644 index 0000000..e5196d9 --- /dev/null +++ b/meridian/src/app.ts @@ -0,0 +1,56 @@ +import type { Server } from "node:http"; +import { loadConfig, type Config } from "./config.js"; +import { JsonStore } from "./store/jsonStore.js"; +import { defaultRegistry } from "./engine/nodes/index.js"; +import { WorkflowService } from "./service.js"; +import { buildServer } from "./api/server.js"; +import { Scheduler } from "./triggers/scheduler.js"; +import { exampleWorkflow } from "./seed.js"; + +export interface App { + config: Config; + service: WorkflowService; + scheduler: Scheduler; + server: Server; + start(): Promise<{ port: number; host: string }>; + stop(): Promise; +} + +/** Compose the application from its parts. Used by both the server and tests. */ +export function createApp(overrides: Partial = {}): App { + const config = { ...loadConfig(), ...overrides }; + const store = new JsonStore(config.dataDir); + const registry = defaultRegistry(); + const service = new WorkflowService(store, registry); + const scheduler = new Scheduler(service); + const server = buildServer({ service, publicDir: config.publicDir }); + + return { + config, + service, + scheduler, + server, + async start() { + // Seed an example workflow on first run so the canvas isn't empty. + const existing = await service.list(); + if (existing.length === 0) { + await store.saveWorkflow(exampleWorkflow()); + } + return new Promise<{ port: number; host: string }>((resolve) => { + server.listen(config.port, config.host, () => { + const addr = server.address(); + const port = + typeof addr === "object" && addr ? addr.port : config.port; + void scheduler.sync(); + resolve({ port, host: config.host }); + }); + }); + }, + stop() { + scheduler.stop(); + return new Promise((resolve, reject) => + server.close((err) => (err ? reject(err) : resolve())), + ); + }, + }; +} diff --git a/meridian/src/config.ts b/meridian/src/config.ts new file mode 100644 index 0000000..5b6c3f8 --- /dev/null +++ b/meridian/src/config.ts @@ -0,0 +1,21 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = path.dirname(fileURLToPath(import.meta.url)); + +export interface Config { + port: number; + host: string; + dataDir: string; + publicDir: string; +} + +export function loadConfig(): Config { + const root = path.resolve(here, ".."); + return { + port: Number(process.env.PORT ?? 8787), + host: process.env.HOST ?? "0.0.0.0", + dataDir: process.env.DATA_DIR ?? path.join(root, "data"), + publicDir: process.env.PUBLIC_DIR ?? path.join(root, "public"), + }; +} diff --git a/meridian/src/domain/types.ts b/meridian/src/domain/types.ts new file mode 100644 index 0000000..9862ffe --- /dev/null +++ b/meridian/src/domain/types.ts @@ -0,0 +1,138 @@ +/** + * Core domain types for Meridian. + * + * A Workflow is pure data: a directed graph of typed nodes and the edges that + * carry data between them. The engine gives the graph behavior. + */ + +export type Json = + | null + | boolean + | number + | string + | Json[] + | { [key: string]: Json }; + +/** A value flowing through the graph. Kept as `unknown` at boundaries. */ +export type Value = unknown; + +export interface Position { + x: number; + y: number; +} + +/** An endpoint of an edge: a specific port on a specific node. */ +export interface Port { + node: string; + port: string; +} + +export interface Edge { + id: string; + from: Port; + to: Port; +} + +export interface Node { + id: string; + type: string; + name: string; + config: Record; + position: Position; + /** Optional per-node reliability overrides. */ + retries?: number; + timeoutMs?: number; + onError?: "stop" | "continue"; +} + +export interface Workflow { + id: string; + name: string; + description: string; + nodes: Node[]; + edges: Edge[]; + variables: Record; + createdAt: string; + updatedAt: string; +} + +export type RunStatus = + | "queued" + | "running" + | "succeeded" + | "failed" + | "canceled"; + +export type NodeRunStatus = + | "pending" + | "running" + | "succeeded" + | "failed" + | "skipped"; + +export interface LogEntry { + ts: string; + level: "debug" | "info" | "warn" | "error"; + message: string; +} + +export interface NodeRun { + nodeId: string; + type: string; + name: string; + status: NodeRunStatus; + attempts: number; + startedAt?: string; + finishedAt?: string; + input?: Record; + /** Map of output port -> value emitted by the node. */ + output?: Record; + logs: LogEntry[]; + error?: string; +} + +export interface TriggerInfo { + kind: "manual" | "webhook" | "schedule"; + payload: Value; +} + +export interface Run { + id: string; + workflowId: string; + status: RunStatus; + trigger: TriggerInfo; + startedAt: string; + finishedAt?: string; + nodeRuns: NodeRun[]; + output?: Record; + error?: string; +} + +/** Describes a port for the UI palette and validation. */ +export interface PortSpec { + name: string; + description?: string; +} + +/** A config field descriptor, used to render forms in the UI. */ +export interface ConfigField { + key: string; + label: string; + type: "string" | "text" | "number" | "boolean" | "json" | "expression"; + required?: boolean; + default?: Value; + placeholder?: string; + help?: string; +} + +/** Static description of a node type, exposed via the API for the palette. */ +export interface NodeTypeSpec { + type: string; + label: string; + category: string; + description: string; + color: string; + inputs: PortSpec[]; + outputs: PortSpec[]; + fields: ConfigField[]; +} diff --git a/meridian/src/engine/context.ts b/meridian/src/engine/context.ts new file mode 100644 index 0000000..48bdf76 --- /dev/null +++ b/meridian/src/engine/context.ts @@ -0,0 +1,65 @@ +import type { LogEntry, NodeTypeSpec, Value } from "../domain/types.js"; +import { nowIso } from "../util/id.js"; +import { evaluate, type Scope } from "./expr.js"; + +/** What a node returns: a map of output-port name -> value. */ +export type NodeOutput = Record; + +/** + * The controlled surface a node handler sees. Handlers get their resolved + * inputs and config, a logger, and an abort signal — nothing else. They cannot + * reach the store, other nodes, or the graph, which keeps the graph the single + * source of truth. + */ +export interface ExecutionContext { + /** Incoming values keyed by input port name. */ + readonly input: Record; + /** Config with all `{{ expressions }}` already resolved. */ + readonly config: Record; + /** Workflow variables (read-only reference). */ + readonly vars: Record; + /** The triggering payload. */ + readonly trigger: Value; + /** Aborts when the run is canceled or the node times out. */ + readonly signal: AbortSignal; + /** Append a line to this node's run log. */ + log(level: LogEntry["level"], message: string): void; + /** Convenience: read a config field with a fallback. */ + cfg(key: string, fallback?: T): T; + /** Evaluate a raw expression against the standard scope (input/vars/trigger/nodes). */ + expr(source: string): Value; +} + +export function makeContext(args: { + input: Record; + config: Record; + vars: Record; + trigger: Value; + scope: Scope; + signal: AbortSignal; + sink: LogEntry[]; +}): ExecutionContext { + return { + input: args.input, + config: args.config, + vars: args.vars, + trigger: args.trigger, + signal: args.signal, + log(level, message) { + args.sink.push({ ts: nowIso(), level, message }); + }, + cfg(key: string, fallback?: T): T { + const v = args.config[key]; + return (v === undefined ? fallback : v) as T; + }, + expr(source: string): Value { + return evaluate(source, args.scope); + }, + }; +} + +/** A registered node type: its static description plus its behavior. */ +export interface NodeType { + spec: NodeTypeSpec; + execute(ctx: ExecutionContext): Promise | NodeOutput; +} diff --git a/meridian/src/engine/engine.ts b/meridian/src/engine/engine.ts new file mode 100644 index 0000000..ceacd64 --- /dev/null +++ b/meridian/src/engine/engine.ts @@ -0,0 +1,293 @@ +import { EventEmitter } from "node:events"; +import type { + LogEntry, + Node, + NodeRun, + Run, + TriggerInfo, + Value, + Workflow, +} from "../domain/types.js"; +import { ValidationError, TimeoutError } from "../util/errors.js"; +import { nowIso, shortId } from "../util/id.js"; +import { buildAdjacency, topoSort } from "../util/graph.js"; +import { resolveConfig, type Scope } from "./expr.js"; +import { makeContext, type NodeOutput } from "./context.js"; +import type { NodeRegistry } from "./registry.js"; +import { hasErrors, validateWorkflow } from "./validate.js"; + +export interface RunOptions { + trigger?: TriggerInfo; + /** Called for each engine event; also emitted on the EventEmitter. */ + onEvent?: (event: RunEvent) => void; +} + +export type RunEvent = + | { type: "run:start"; run: Run } + | { type: "node:start"; runId: string; nodeId: string } + | { type: "node:log"; runId: string; nodeId: string; entry: LogEntry } + | { type: "node:finish"; runId: string; nodeRun: NodeRun } + | { type: "run:finish"; run: Run }; + +const DEFAULT_TIMEOUT_MS = 30_000; +const MAX_RETRIES = 5; + +/** + * The execution engine. Runs a workflow as a dataflow graph: + * + * 1. Validate (reject malformed or cyclic graphs before doing anything). + * 2. Walk nodes in topological order. + * 3. For each node, gather inbound port values, resolve its config against a + * live scope, run its handler with retries/timeout, and record the result. + * 4. Prune descendants whose inbound ports never fired (this is branching). + * + * The engine is an EventEmitter so runs can be observed live (the API turns + * these events into an SSE stream). + */ +export class Engine extends EventEmitter { + constructor(private registry: NodeRegistry) { + super(); + } + + async run(wf: Workflow, opts: RunOptions = {}): Promise { + const issues = validateWorkflow(wf, this.registry); + if (hasErrors(issues)) throw new ValidationError(issues); + + const trigger: TriggerInfo = opts.trigger ?? { + kind: "manual", + payload: null, + }; + + const run: Run = { + id: shortId("run"), + workflowId: wf.id, + status: "running", + trigger, + startedAt: nowIso(), + nodeRuns: [], + }; + + const emit = (e: RunEvent) => { + opts.onEvent?.(e); + this.emit(e.type, e); + }; + emit({ type: "run:start", run }); + + const adj = buildAdjacency(wf); + const { order } = topoSort(wf); // validated acyclic above + const nodeById = new Map(wf.nodes.map((n) => [n.id, n])); + + // Per-node emitted outputs: nodeId -> { port -> value }. + const outputs = new Map(); + // Node ids that were pruned (an active branch never reached them). + const pruned = new Set(); + // `nodes..` scope, populated as nodes complete. + const nodesScope: Record = {}; + + try { + for (const nodeId of order) { + const node = nodeById.get(nodeId)!; + const inboundEdges = adj.in.get(nodeId) ?? []; + + // A node is pruned if it has inbound edges but none of them carried a + // value (either the source was pruned, or the source didn't emit that + // port — e.g. the untaken side of a condition). + if (inboundEdges.length > 0) { + const anyLive = inboundEdges.some((e) => { + if (pruned.has(e.from.node)) return false; + const srcOut = outputs.get(e.from.node); + return srcOut !== undefined && e.from.port in srcOut; + }); + if (!anyLive) { + pruned.add(nodeId); + run.nodeRuns.push(skippedRun(node)); + continue; + } + } + + // Gather inbound port values. + const input: Record = {}; + for (const e of inboundEdges) { + const srcOut = outputs.get(e.from.node); + if (srcOut && e.from.port in srcOut) { + input[e.to.port] = srcOut[e.from.port]; + } + } + + const scope: Scope = { + input, + vars: wf.variables, + trigger: trigger.payload, + nodes: nodesScope, + }; + + const nodeRun = await this.executeNode(node, input, scope, run.id, emit); + run.nodeRuns.push(nodeRun); + + if (nodeRun.status === "failed") { + if ((node.onError ?? "stop") === "stop") { + run.status = "failed"; + run.error = `Node '${node.name || node.id}' failed: ${nodeRun.error}`; + run.finishedAt = nowIso(); + emit({ type: "run:finish", run }); + return run; + } + // onError=continue: treat as pruned so descendants are skipped. + pruned.add(nodeId); + continue; + } + + outputs.set(nodeId, nodeRun.output ?? {}); + nodesScope[nodeId] = nodeRun.output ?? {}; + } + + run.status = "succeeded"; + run.output = collectTerminalOutputs(wf, outputs); + run.finishedAt = nowIso(); + emit({ type: "run:finish", run }); + return run; + } catch (err) { + run.status = "failed"; + run.error = err instanceof Error ? err.message : String(err); + run.finishedAt = nowIso(); + emit({ type: "run:finish", run }); + return run; + } + } + + private async executeNode( + node: Node, + input: Record, + scope: Scope, + runId: string, + emit: (e: RunEvent) => void, + ): Promise { + const type = this.registry.get(node.type)!; // existence checked in validation + const logs: LogEntry[] = []; + const nodeRun: NodeRun = { + nodeId: node.id, + type: node.type, + name: node.name || node.id, + status: "running", + attempts: 0, + startedAt: nowIso(), + input, + logs, + }; + emit({ type: "node:start", runId, nodeId: node.id }); + + const retries = clamp(node.retries ?? 0, 0, MAX_RETRIES); + const timeoutMs = node.timeoutMs ?? DEFAULT_TIMEOUT_MS; + + // Resolve config templates against the live scope once per attempt is not + // necessary — scope is stable for this node — so resolve up front. + let resolvedConfig: Record; + try { + resolvedConfig = resolveConfig(node.config, scope) as Record; + } catch (err) { + nodeRun.status = "failed"; + nodeRun.error = `config error: ${errMsg(err)}`; + nodeRun.finishedAt = nowIso(); + emit({ type: "node:finish", runId, nodeRun }); + return nodeRun; + } + + let lastErr: unknown; + for (let attempt = 0; attempt <= retries; attempt++) { + nodeRun.attempts = attempt + 1; + const beforeLen = logs.length; + const controller = new AbortController(); + const timer = setTimeout( + () => controller.abort(new TimeoutError(timeoutMs)), + timeoutMs, + ); + try { + const ctx = makeContext({ + input, + config: resolvedConfig, + vars: (scope["vars"] as Record) ?? {}, + trigger: scope["trigger"], + scope, + signal: controller.signal, + sink: logs, + }); + const output = await Promise.resolve(type.execute(ctx)); + clearTimeout(timer); + // Stream any logs produced this attempt. + for (let i = beforeLen; i < logs.length; i++) { + emit({ type: "node:log", runId, nodeId: node.id, entry: logs[i]! }); + } + nodeRun.status = "succeeded"; + nodeRun.output = output; + nodeRun.finishedAt = nowIso(); + emit({ type: "node:finish", runId, nodeRun }); + return nodeRun; + } catch (err) { + clearTimeout(timer); + lastErr = controller.signal.aborted ? controller.signal.reason : err; + for (let i = beforeLen; i < logs.length; i++) { + emit({ type: "node:log", runId, nodeId: node.id, entry: logs[i]! }); + } + logs.push({ + ts: nowIso(), + level: "warn", + message: `attempt ${attempt + 1} failed: ${errMsg(lastErr)}`, + }); + if (attempt < retries) { + await backoff(attempt, controller.signal); + } + } + } + + nodeRun.status = "failed"; + nodeRun.error = errMsg(lastErr); + nodeRun.finishedAt = nowIso(); + emit({ type: "node:finish", runId, nodeRun }); + return nodeRun; + } +} + +function skippedRun(node: Node): NodeRun { + return { + nodeId: node.id, + type: node.type, + name: node.name || node.id, + status: "skipped", + attempts: 0, + logs: [], + }; +} + +/** Outputs of nodes that have no outgoing edges — the workflow's results. */ +function collectTerminalOutputs( + wf: Workflow, + outputs: Map, +): Record { + const hasOutbound = new Set(wf.edges.map((e) => e.from.node)); + const result: Record = {}; + for (const n of wf.nodes) { + if (!hasOutbound.has(n.id) && outputs.has(n.id)) { + result[n.id] = outputs.get(n.id)!; + } + } + return result; +} + +function backoff(attempt: number, signal: AbortSignal): Promise { + const ms = Math.min(100 * 2 ** attempt, 5_000); + return new Promise((resolve) => { + const t = setTimeout(resolve, ms); + signal.addEventListener("abort", () => { + clearTimeout(t); + resolve(); + }); + }); +} + +function clamp(n: number, lo: number, hi: number): number { + return Math.max(lo, Math.min(hi, n)); +} + +function errMsg(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} diff --git a/meridian/src/engine/expr.ts b/meridian/src/engine/expr.ts new file mode 100644 index 0000000..791e0d2 --- /dev/null +++ b/meridian/src/engine/expr.ts @@ -0,0 +1,464 @@ +/** + * A small, safe expression language for node configs. + * + * Design constraints: + * - No `eval` / `Function` — configs come from user data and must never + * execute arbitrary JS. + * - Deterministic and pure, except for a whitelist of helper functions. + * + * Supports: number/string/boolean/null literals, dotted + bracket member + * access against a scope, arithmetic (+ - * / %), comparison (== != < <= > >=), + * logical (&& || !), unary minus, parentheses, and whitelisted function calls. + * + * Two entry points: + * - `evaluate(expr, scope)` evaluates one expression, returning a typed value. + * - `render(text, scope)` interpolates a string containing `{{ ... }}` spans. + * A string that is exactly one `{{ ... }}` span returns the raw typed value; + * otherwise spans are coerced to text and concatenated. + */ + +export type Scope = Record; + +// --------------------------------------------------------------------------- +// Lexer +// --------------------------------------------------------------------------- + +type TokKind = + | "num" + | "str" + | "ident" + | "op" + | "lparen" + | "rparen" + | "lbracket" + | "rbracket" + | "comma" + | "dot" + | "eof"; + +interface Tok { + kind: TokKind; + value: string; + pos: number; +} + +const OPS = [ + "===", + "!==", + "==", + "!=", + "<=", + ">=", + "&&", + "||", + "<", + ">", + "+", + "-", + "*", + "/", + "%", + "!", +]; + +function lex(src: string): Tok[] { + const toks: Tok[] = []; + let i = 0; + const n = src.length; + while (i < n) { + const c = src[i]!; + if (c === " " || c === "\t" || c === "\n" || c === "\r") { + i++; + continue; + } + if (c === "(") { + toks.push({ kind: "lparen", value: c, pos: i++ }); + continue; + } + if (c === ")") { + toks.push({ kind: "rparen", value: c, pos: i++ }); + continue; + } + if (c === "[") { + toks.push({ kind: "lbracket", value: c, pos: i++ }); + continue; + } + if (c === "]") { + toks.push({ kind: "rbracket", value: c, pos: i++ }); + continue; + } + if (c === ",") { + toks.push({ kind: "comma", value: c, pos: i++ }); + continue; + } + if (c === ".") { + // Only a member-access dot if not the start of a number like `.5`. + if (!/[0-9]/.test(src[i + 1] ?? "")) { + toks.push({ kind: "dot", value: c, pos: i++ }); + continue; + } + } + // strings + if (c === '"' || c === "'") { + const quote = c; + let j = i + 1; + let out = ""; + while (j < n && src[j] !== quote) { + if (src[j] === "\\" && j + 1 < n) { + const esc = src[j + 1]!; + out += + esc === "n" + ? "\n" + : esc === "t" + ? "\t" + : esc === "r" + ? "\r" + : esc; + j += 2; + } else { + out += src[j]; + j++; + } + } + if (j >= n) throw new ExprError(`Unterminated string at ${i}`); + toks.push({ kind: "str", value: out, pos: i }); + i = j + 1; + continue; + } + // numbers + if (/[0-9]/.test(c) || (c === "." && /[0-9]/.test(src[i + 1] ?? ""))) { + let j = i; + while (j < n && /[0-9.]/.test(src[j]!)) j++; + toks.push({ kind: "num", value: src.slice(i, j), pos: i }); + i = j; + continue; + } + // identifiers + if (/[A-Za-z_$]/.test(c)) { + let j = i; + while (j < n && /[A-Za-z0-9_$]/.test(src[j]!)) j++; + toks.push({ kind: "ident", value: src.slice(i, j), pos: i }); + i = j; + continue; + } + // operators (longest match first) + const rest = src.slice(i); + const op = OPS.find((o) => rest.startsWith(o)); + if (op) { + toks.push({ kind: "op", value: op, pos: i }); + i += op.length; + continue; + } + throw new ExprError(`Unexpected character '${c}' at ${i}`); + } + toks.push({ kind: "eof", value: "", pos: n }); + return toks; +} + +// --------------------------------------------------------------------------- +// AST +// --------------------------------------------------------------------------- + +type Ast = + | { k: "lit"; v: unknown } + | { k: "ident"; name: string } + | { k: "member"; obj: Ast; prop: string } + | { k: "index"; obj: Ast; index: Ast } + | { k: "call"; name: string; args: Ast[] } + | { k: "unary"; op: string; arg: Ast } + | { k: "binary"; op: string; left: Ast; right: Ast }; + +export class ExprError extends Error { + constructor(msg: string) { + super(`Expression error: ${msg}`); + this.name = "ExprError"; + } +} + +// Binary operator precedence (higher binds tighter). +const PREC: Record = { + "||": 1, + "&&": 2, + "==": 3, + "===": 3, + "!=": 3, + "!==": 3, + "<": 4, + "<=": 4, + ">": 4, + ">=": 4, + "+": 5, + "-": 5, + "*": 6, + "/": 6, + "%": 6, +}; + +class Parser { + private p = 0; + constructor(private toks: Tok[]) {} + + private peek(): Tok { + return this.toks[this.p]!; + } + private next(): Tok { + return this.toks[this.p++]!; + } + private expect(kind: TokKind): Tok { + const t = this.next(); + if (t.kind !== kind) + throw new ExprError(`Expected ${kind} but got '${t.value || t.kind}'`); + return t; + } + + parse(): Ast { + const ast = this.parseBinary(0); + if (this.peek().kind !== "eof") + throw new ExprError(`Unexpected trailing '${this.peek().value}'`); + return ast; + } + + private parseBinary(minPrec: number): Ast { + let left = this.parseUnary(); + for (;;) { + const t = this.peek(); + if (t.kind !== "op" || !(t.value in PREC)) break; + const prec = PREC[t.value]!; + if (prec < minPrec) break; + this.next(); + const right = this.parseBinary(prec + 1); // left-associative + left = { k: "binary", op: t.value, left, right }; + } + return left; + } + + private parseUnary(): Ast { + const t = this.peek(); + if (t.kind === "op" && (t.value === "!" || t.value === "-")) { + this.next(); + return { k: "unary", op: t.value, arg: this.parseUnary() }; + } + return this.parsePostfix(); + } + + private parsePostfix(): Ast { + let node = this.parsePrimary(); + for (;;) { + const t = this.peek(); + if (t.kind === "dot") { + this.next(); + const prop = this.expect("ident").value; + node = { k: "member", obj: node, prop }; + } else if (t.kind === "lbracket") { + this.next(); + const index = this.parseBinary(0); + this.expect("rbracket"); + node = { k: "index", obj: node, index }; + } else { + break; + } + } + return node; + } + + private parsePrimary(): Ast { + const t = this.next(); + switch (t.kind) { + case "num": + return { k: "lit", v: Number(t.value) }; + case "str": + return { k: "lit", v: t.value }; + case "lparen": { + const inner = this.parseBinary(0); + this.expect("rparen"); + return inner; + } + case "ident": { + if (t.value === "true") return { k: "lit", v: true }; + if (t.value === "false") return { k: "lit", v: false }; + if (t.value === "null") return { k: "lit", v: null }; + // function call? + if (this.peek().kind === "lparen") { + this.next(); + const args: Ast[] = []; + if (this.peek().kind !== "rparen") { + args.push(this.parseBinary(0)); + while (this.peek().kind === "comma") { + this.next(); + args.push(this.parseBinary(0)); + } + } + this.expect("rparen"); + return { k: "call", name: t.value, args }; + } + return { k: "ident", name: t.value }; + } + default: + throw new ExprError(`Unexpected '${t.value || t.kind}'`); + } + } +} + +// --------------------------------------------------------------------------- +// Whitelisted helper functions +// --------------------------------------------------------------------------- + +const HELPERS: Record unknown> = { + upper: (s) => String(s ?? "").toUpperCase(), + lower: (s) => String(s ?? "").toLowerCase(), + trim: (s) => String(s ?? "").trim(), + len: (s) => (s == null ? 0 : (s as { length?: number }).length ?? 0), + // first non-nullish argument + default: (...args) => args.find((a) => a !== null && a !== undefined) ?? null, + json: (v) => JSON.stringify(v ?? null), + parse: (s) => { + try { + return JSON.parse(String(s)); + } catch { + return null; + } + }, + number: (v) => Number(v), + string: (v) => (v == null ? "" : String(v)), + bool: (v) => Boolean(v), + round: (v) => Math.round(Number(v)), + floor: (v) => Math.floor(Number(v)), + ceil: (v) => Math.ceil(Number(v)), + abs: (v) => Math.abs(Number(v)), + min: (...a) => Math.min(...a.map(Number)), + max: (...a) => Math.max(...a.map(Number)), + concat: (...a) => a.map((x) => (x == null ? "" : String(x))).join(""), + contains: (hay, needle) => + String(hay ?? "").includes(String(needle ?? "")), + now: () => new Date().toISOString(), + coalesce: (...args) => args.find((a) => a !== null && a !== undefined) ?? null, +}; + +// --------------------------------------------------------------------------- +// Evaluator +// --------------------------------------------------------------------------- + +function evalAst(node: Ast, scope: Scope): unknown { + switch (node.k) { + case "lit": + return node.v; + case "ident": + return scope[node.name]; + case "member": { + const obj = evalAst(node.obj, scope); + if (obj == null) return undefined; + return (obj as Record)[node.prop]; + } + case "index": { + const obj = evalAst(node.obj, scope); + if (obj == null) return undefined; + const idx = evalAst(node.index, scope) as string | number; + return (obj as Record)[idx]; + } + case "call": { + const fn = HELPERS[node.name]; + if (!fn) throw new ExprError(`Unknown function '${node.name}'`); + return fn(...node.args.map((a) => evalAst(a, scope))); + } + case "unary": { + const v = evalAst(node.arg, scope); + return node.op === "!" ? !v : -Number(v); + } + case "binary": + return evalBinary(node, scope); + } +} + +function evalBinary(node: Extract, scope: Scope): unknown { + const { op } = node; + // Short-circuit logical operators. + if (op === "&&") return evalAst(node.left, scope) && evalAst(node.right, scope); + if (op === "||") return evalAst(node.left, scope) || evalAst(node.right, scope); + const l = evalAst(node.left, scope) as never; + const r = evalAst(node.right, scope) as never; + switch (op) { + case "+": + // If either side is a string, concatenate; else numeric add. + return typeof l === "string" || typeof r === "string" + ? String(l) + String(r) + : (l as number) + (r as number); + case "-": + return (l as number) - (r as number); + case "*": + return (l as number) * (r as number); + case "/": + return (l as number) / (r as number); + case "%": + return (l as number) % (r as number); + case "==": + case "===": + return l === r; + case "!=": + case "!==": + return l !== r; + case "<": + return l < r; + case "<=": + return l <= r; + case ">": + return l > r; + case ">=": + return l >= r; + default: + throw new ExprError(`Unknown operator '${op}'`); + } +} + +const astCache = new Map(); + +function compile(expr: string): Ast { + let ast = astCache.get(expr); + if (!ast) { + ast = new Parser(lex(expr)).parse(); + astCache.set(expr, ast); + } + return ast; +} + +/** Evaluate a single expression string against a scope. */ +export function evaluate(expr: string, scope: Scope): unknown { + return evalAst(compile(expr), scope); +} + +const SPAN = /\{\{([\s\S]*?)\}\}/g; + +/** + * Render a template string. If the whole string is exactly one `{{ ... }}` + * span, the raw typed value is returned; otherwise every span is stringified + * and concatenated with the surrounding text. + */ +export function render(text: string, scope: Scope): unknown { + const trimmed = text.trim(); + const single = /^\{\{([\s\S]*)\}\}$/.exec(trimmed); + if (single && !single[1]!.includes("}}")) { + return evaluate(single[1]!, scope); + } + return text.replace(SPAN, (_m, e: string) => { + const v = evaluate(e, scope); + return v == null ? "" : typeof v === "object" ? JSON.stringify(v) : String(v); + }); +} + +/** + * Deep-resolve a config object: any string containing `{{ }}` is rendered, + * arrays/objects are walked recursively. Non-template values pass through. + */ +export function resolveConfig(config: T, scope: Scope): T { + if (typeof config === "string") { + return (config.includes("{{") ? render(config, scope) : config) as T; + } + if (Array.isArray(config)) { + return config.map((v) => resolveConfig(v, scope)) as unknown as T; + } + if (config && typeof config === "object") { + const out: Record = {}; + for (const [k, v] of Object.entries(config)) out[k] = resolveConfig(v, scope); + return out as T; + } + return config; +} diff --git a/meridian/src/engine/nodes/builtins.ts b/meridian/src/engine/nodes/builtins.ts new file mode 100644 index 0000000..0932ea6 --- /dev/null +++ b/meridian/src/engine/nodes/builtins.ts @@ -0,0 +1,351 @@ +import type { Value } from "../../domain/types.js"; +import type { NodeType } from "../context.js"; + +/** + * The built-in node catalog. Each entry fully describes itself (ports, config + * fields, color) so the UI palette and config forms are generated from here — + * never hard-coded in the frontend. + * + * The engine resolves `{{ expressions }}` in a node's config before calling + * `execute`, so handlers mostly read already-resolved values via `ctx.cfg`. + */ + +const CAT = { + trigger: "Triggers", + data: "Data", + logic: "Logic", + io: "Integrations", + util: "Utility", +}; + +/** Entry point. Emits the trigger payload so downstream nodes can consume it. */ +const trigger: NodeType = { + spec: { + type: "trigger", + label: "Trigger", + category: CAT.trigger, + description: + "Starts the workflow. Emits the incoming payload (manual body, webhook JSON, or schedule tick).", + color: "#22c55e", + inputs: [], + outputs: [{ name: "out", description: "The trigger payload" }], + fields: [ + { + key: "mode", + label: "Trigger mode", + type: "string", + default: "manual", + help: "manual | webhook | schedule", + }, + { + key: "everyMs", + label: "Schedule interval (ms)", + type: "number", + help: "Only used when mode = schedule. Minimum 1000ms.", + }, + ], + }, + execute(ctx) { + return { out: ctx.trigger ?? null }; + }, +}; + +/** A constant/seed value defined in config. Useful for testing and defaults. */ +const manualInput: NodeType = { + spec: { + type: "manual.input", + label: "Value", + category: CAT.data, + description: "Emits a constant value defined in config. Supports expressions.", + color: "#38bdf8", + inputs: [], + outputs: [{ name: "out" }], + fields: [ + { + key: "value", + label: "Value (JSON or expression)", + type: "json", + default: {}, + }, + ], + }, + execute(ctx) { + return { out: ctx.cfg("value", null) }; + }, +}; + +/** Reshape data. The `output` config object is resolved with expressions. */ +const transform: NodeType = { + spec: { + type: "transform", + label: "Transform", + category: CAT.data, + description: + "Builds a new object from expressions over the input. Each field value may use {{ input.x }}.", + color: "#a78bfa", + inputs: [{ name: "in" }], + outputs: [{ name: "out" }], + fields: [ + { + key: "output", + label: "Output shape (JSON with expressions)", + type: "json", + default: { value: "{{ input.in }}" }, + }, + ], + }, + execute(ctx) { + // config.output has already been deep-resolved by the engine. + return { out: ctx.cfg("output", {}) }; + }, +}; + +/** Branch on a boolean expression. Emits on exactly one of true/false. */ +const condition: NodeType = { + spec: { + type: "condition", + label: "Condition", + category: CAT.logic, + description: + "Evaluates a boolean expression and routes the input to the 'true' or 'false' output.", + color: "#f59e0b", + inputs: [{ name: "in" }], + outputs: [ + { name: "true", description: "Taken when the expression is truthy" }, + { name: "false", description: "Taken when the expression is falsy" }, + ], + fields: [ + { + key: "expression", + label: "Boolean expression", + type: "expression", + required: true, + default: "input.in == true", + placeholder: "input.amount > 100", + help: "Evaluated against input / vars / trigger / nodes.", + }, + ], + }, + execute(ctx) { + const source = String(ctx.cfg("expression", "false")); + const result = Boolean(ctx.expr(source)); + ctx.log("info", `condition '${source}' => ${result}`); + const payload = ctx.input["in"] ?? ctx.trigger ?? null; + // Emit on exactly one branch; the other is pruned downstream. + return result ? { true: payload } : { false: payload }; + }, +}; + +/** Render a string template. */ +const template: NodeType = { + spec: { + type: "template", + label: "Template", + category: CAT.util, + description: "Renders a text template with {{ expressions }}.", + color: "#f472b6", + inputs: [{ name: "in" }], + outputs: [{ name: "out" }], + fields: [ + { + key: "text", + label: "Template text", + type: "text", + default: "Hello, {{ input.in }}", + }, + ], + }, + execute(ctx) { + return { out: ctx.cfg("text", "") }; + }, +}; + +/** Record a message into the run log and pass the input through unchanged. */ +const log: NodeType = { + spec: { + type: "log", + label: "Log", + category: CAT.util, + description: "Writes a message to the run log; passes input through.", + color: "#94a3b8", + inputs: [{ name: "in" }], + outputs: [{ name: "out" }], + fields: [ + { + key: "message", + label: "Message", + type: "string", + default: "{{ json(input.in) }}", + }, + { + key: "level", + label: "Level", + type: "string", + default: "info", + help: "debug | info | warn | error", + }, + ], + }, + execute(ctx) { + const level = (ctx.cfg("level", "info") as "info") ?? "info"; + ctx.log(level, String(ctx.cfg("message", ""))); + return { out: ctx.input["in"] ?? null }; + }, +}; + +/** Wait a bounded number of milliseconds, then pass input through. */ +const delay: NodeType = { + spec: { + type: "delay", + label: "Delay", + category: CAT.logic, + description: "Waits for a bounded interval before continuing.", + color: "#fbbf24", + inputs: [{ name: "in" }], + outputs: [{ name: "out" }], + fields: [ + { key: "ms", label: "Milliseconds", type: "number", default: 500 }, + ], + }, + async execute(ctx) { + const ms = Math.min(Math.max(Number(ctx.cfg("ms", 0)) || 0, 0), 30_000); + await new Promise((resolve, reject) => { + const t = setTimeout(resolve, ms); + ctx.signal.addEventListener("abort", () => { + clearTimeout(t); + reject(new Error("aborted")); + }); + }); + return { out: ctx.input["in"] ?? null }; + }, +}; + +/** Merge two inbound branches into one object. */ +const merge: NodeType = { + spec: { + type: "merge", + label: "Merge", + category: CAT.logic, + description: "Combines inputs 'a' and 'b' into a single object { a, b }.", + color: "#2dd4bf", + inputs: [{ name: "a" }, { name: "b" }], + outputs: [{ name: "out" }], + fields: [], + }, + execute(ctx) { + return { out: { a: ctx.input["a"] ?? null, b: ctx.input["b"] ?? null } }; + }, +}; + +/** Compute a named value for use downstream (a labeled transform). */ +const setVariable: NodeType = { + spec: { + type: "set.variable", + label: "Set Value", + category: CAT.data, + description: "Computes a single named value from an expression.", + color: "#818cf8", + inputs: [{ name: "in" }], + outputs: [{ name: "out" }], + fields: [ + { key: "name", label: "Name", type: "string", default: "value" }, + { + key: "value", + label: "Value (expression)", + type: "json", + default: "{{ input.in }}", + }, + ], + }, + execute(ctx) { + const name = String(ctx.cfg("name", "value")); + return { out: { [name]: ctx.cfg("value", null) } }; + }, +}; + +/** Call an external HTTP API using the platform fetch. */ +const httpRequest: NodeType = { + spec: { + type: "http.request", + label: "HTTP Request", + category: CAT.io, + description: + "Performs a real HTTP request. Emits the response on 'out', or the error object on 'error'.", + color: "#60a5fa", + inputs: [{ name: "in" }], + outputs: [ + { name: "out", description: "Successful response { status, body }" }, + { name: "error", description: "Failure { status, message }" }, + ], + fields: [ + { key: "method", label: "Method", type: "string", default: "GET" }, + { + key: "url", + label: "URL", + type: "string", + required: true, + placeholder: "https://api.example.com/{{ input.id }}", + }, + { key: "headers", label: "Headers (JSON)", type: "json", default: {} }, + { key: "body", label: "Body (JSON)", type: "json" }, + ], + }, + async execute(ctx) { + const url = String(ctx.cfg("url", "")); + const method = String(ctx.cfg("method", "GET")).toUpperCase(); + const headers = (ctx.cfg("headers", {}) as Record) ?? {}; + const bodyCfg = ctx.cfg("body", undefined); + if (!url) throw new Error("http.request requires a url"); + + const init: RequestInit = { method, headers, signal: ctx.signal }; + if (bodyCfg !== undefined && method !== "GET" && method !== "HEAD") { + init.body = + typeof bodyCfg === "string" ? bodyCfg : JSON.stringify(bodyCfg); + if (!("content-type" in lowerKeys(headers))) { + (init.headers as Record)["content-type"] = + "application/json"; + } + } + + ctx.log("info", `${method} ${url}`); + try { + const res = await fetch(url, init); + const text = await res.text(); + let body: Value = text; + try { + body = JSON.parse(text); + } catch { + /* leave as text */ + } + const response = { status: res.status, ok: res.ok, body }; + if (!res.ok) { + ctx.log("warn", `response ${res.status}`); + return { error: response }; + } + return { out: response }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + ctx.log("error", `request failed: ${message}`); + return { error: { status: 0, message } }; + } + }, +}; + +function lowerKeys(o: Record): Record { + const out: Record = {}; + for (const [k, v] of Object.entries(o)) out[k.toLowerCase()] = v; + return out; +} + +export const BUILTINS: NodeType[] = [ + trigger, + manualInput, + transform, + condition, + template, + log, + delay, + merge, + setVariable, + httpRequest, +]; diff --git a/meridian/src/engine/nodes/index.ts b/meridian/src/engine/nodes/index.ts new file mode 100644 index 0000000..a694419 --- /dev/null +++ b/meridian/src/engine/nodes/index.ts @@ -0,0 +1,12 @@ +import { NodeRegistry } from "../registry.js"; +import { BUILTINS } from "./builtins.js"; +import { INTEGRATIONS } from "./integrations.js"; + +/** Build a registry pre-loaded with the built-in and integration node types. */ +export function defaultRegistry(): NodeRegistry { + const reg = new NodeRegistry(); + for (const t of [...BUILTINS, ...INTEGRATIONS]) reg.register(t); + return reg; +} + +export { BUILTINS, INTEGRATIONS }; diff --git a/meridian/src/engine/nodes/integrations.ts b/meridian/src/engine/nodes/integrations.ts new file mode 100644 index 0000000..d31c3d4 --- /dev/null +++ b/meridian/src/engine/nodes/integrations.ts @@ -0,0 +1,225 @@ +import type { Value } from "../../domain/types.js"; +import type { ExecutionContext, NodeType } from "../context.js"; + +/** + * Integration nodes — the ones that *act on the world*. Each performs a real + * network call via the built-in `fetch` (no dependencies), exposes an `out` + * port for success and an `error` port for failure so flows can branch on + * outcome, and reads secrets from config first, then the environment, so an + * agent never has to embed credentials in a workflow it authors. + */ + +const CAT = { io: "Integrations", ai: "AI" }; + +/** Resolve a secret: explicit config value wins, else the named env var. */ +function secret(ctx: ExecutionContext, key: string, envVar: string): string { + const fromCfg = ctx.cfg(key, undefined); + return (fromCfg && String(fromCfg)) || process.env[envVar] || ""; +} + +/** Generic outbound webhook: POST a JSON payload to any URL. */ +const webhookSend: NodeType = { + spec: { + type: "webhook.send", + label: "Send Webhook", + category: CAT.io, + description: + "POSTs a JSON payload to any URL. Emits the response on 'out', or the failure on 'error'.", + color: "#38bdf8", + inputs: [{ name: "in" }], + outputs: [{ name: "out" }, { name: "error" }], + fields: [ + { key: "url", label: "URL", type: "string", required: true, placeholder: "https://hooks.example.com/..." }, + { key: "payload", label: "JSON payload", type: "json", default: { message: "{{ input.in }}" } }, + { key: "headers", label: "Headers (JSON)", type: "json", default: {} }, + ], + }, + async execute(ctx) { + const url = String(ctx.cfg("url", "")); + if (!url) throw new Error("webhook.send requires a url"); + const headers = { + "content-type": "application/json", + ...((ctx.cfg("headers", {}) as Record) ?? {}), + }; + return postJson(ctx, url, headers, ctx.cfg("payload", {})); + }, +}; + +/** Post a message to a Slack Incoming Webhook. */ +const slackMessage: NodeType = { + spec: { + type: "slack.message", + label: "Slack Message", + category: CAT.io, + description: + "Posts a message to a Slack Incoming Webhook URL. Set the webhook in config or via SLACK_WEBHOOK_URL.", + color: "#4a154b", + inputs: [{ name: "in" }], + outputs: [{ name: "out" }, { name: "error" }], + fields: [ + { key: "webhookUrl", label: "Webhook URL", type: "string", placeholder: "https://hooks.slack.com/services/...", help: "Falls back to SLACK_WEBHOOK_URL." }, + { key: "text", label: "Message text", type: "text", required: true, default: "{{ input.in }}" }, + ], + }, + async execute(ctx) { + const url = secret(ctx, "webhookUrl", "SLACK_WEBHOOK_URL"); + if (!url) { + ctx.log("error", "no Slack webhook configured"); + return { error: { message: "Set a Slack Incoming Webhook URL in config or SLACK_WEBHOOK_URL." } }; + } + return postJson(ctx, url, { "content-type": "application/json" }, { + text: String(ctx.cfg("text", "")), + }); + }, +}; + +/** Send email via the Resend API (https://resend.com). */ +const emailSend: NodeType = { + spec: { + type: "email.send", + label: "Send Email", + category: CAT.io, + description: + "Sends an email via the Resend API. Set the API key in config or via RESEND_API_KEY.", + color: "#f97316", + inputs: [{ name: "in" }], + outputs: [{ name: "out" }, { name: "error" }], + fields: [ + { key: "apiKey", label: "Resend API key", type: "string", help: "Falls back to RESEND_API_KEY." }, + { key: "from", label: "From", type: "string", required: true, placeholder: "you@yourdomain.com" }, + { key: "to", label: "To", type: "string", required: true, placeholder: "someone@example.com" }, + { key: "subject", label: "Subject", type: "string", required: true }, + { key: "html", label: "HTML body", type: "text", default: "

{{ input.in }}

" }, + ], + }, + async execute(ctx) { + const apiKey = secret(ctx, "apiKey", "RESEND_API_KEY"); + if (!apiKey) { + ctx.log("error", "no Resend API key configured"); + return { error: { message: "Set a Resend API key in config or RESEND_API_KEY." } }; + } + const to = String(ctx.cfg("to", "")); + return postJson( + ctx, + "https://api.resend.com/emails", + { "content-type": "application/json", authorization: `Bearer ${apiKey}` }, + { + from: String(ctx.cfg("from", "")), + to: to.includes(",") ? to.split(",").map((s) => s.trim()) : to, + subject: String(ctx.cfg("subject", "")), + html: String(ctx.cfg("html", "")), + }, + ); + }, +}; + +/** Call an LLM (Anthropic Messages API) — puts AI in the automation loop. */ +const llmComplete: NodeType = { + spec: { + type: "llm.complete", + label: "LLM Complete", + category: CAT.ai, + description: + "Calls an Anthropic model with a prompt and returns the text. Set the key in config or via ANTHROPIC_API_KEY. Use it to classify, extract, summarize, or draft inside a workflow.", + color: "#d97757", + inputs: [{ name: "in" }], + outputs: [ + { name: "out", description: "{ text, model }" }, + { name: "error" }, + ], + fields: [ + { key: "apiKey", label: "Anthropic API key", type: "string", help: "Falls back to ANTHROPIC_API_KEY." }, + { key: "model", label: "Model", type: "string", default: "claude-haiku-4-5-20251001" }, + { key: "system", label: "System prompt", type: "text", default: "" }, + { key: "prompt", label: "Prompt", type: "text", required: true, default: "{{ input.in }}" }, + { key: "maxTokens", label: "Max tokens", type: "number", default: 1024 }, + ], + }, + async execute(ctx) { + const apiKey = secret(ctx, "apiKey", "ANTHROPIC_API_KEY"); + if (!apiKey) { + ctx.log("error", "no Anthropic API key configured"); + return { error: { message: "Set an Anthropic API key in config or ANTHROPIC_API_KEY." } }; + } + const model = String(ctx.cfg("model", "claude-haiku-4-5-20251001")); + const system = String(ctx.cfg("system", "")); + const prompt = String(ctx.cfg("prompt", "")); + const maxTokens = Math.max(1, Number(ctx.cfg("maxTokens", 1024)) || 1024); + + ctx.log("info", `llm.complete → ${model}`); + try { + const res = await fetch("https://api.anthropic.com/v1/messages", { + method: "POST", + headers: { + "content-type": "application/json", + "x-api-key": apiKey, + "anthropic-version": "2023-06-01", + }, + body: JSON.stringify({ + model, + max_tokens: maxTokens, + ...(system ? { system } : {}), + messages: [{ role: "user", content: prompt }], + }), + signal: ctx.signal, + }); + const data = (await res.json()) as { + content?: { text?: string }[]; + error?: { message?: string }; + }; + if (!res.ok) { + const message = data.error?.message ?? `HTTP ${res.status}`; + ctx.log("error", `llm error: ${message}`); + return { error: { status: res.status, message } }; + } + const text = (data.content ?? []).map((c) => c.text ?? "").join(""); + return { out: { text, model } }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + ctx.log("error", `llm request failed: ${message}`); + return { error: { message } }; + } + }, +}; + +/** Shared POST-JSON helper: returns { out } on 2xx, { error } otherwise. */ +async function postJson( + ctx: ExecutionContext, + url: string, + headers: Record, + body: Value, +) { + ctx.log("info", `POST ${url}`); + try { + const res = await fetch(url, { + method: "POST", + headers, + body: typeof body === "string" ? body : JSON.stringify(body), + signal: ctx.signal, + }); + const text = await res.text(); + let parsed: Value = text; + try { + parsed = JSON.parse(text); + } catch { + /* keep as text */ + } + const response = { status: res.status, ok: res.ok, body: parsed }; + if (!res.ok) { + ctx.log("warn", `response ${res.status}`); + return { error: response }; + } + return { out: response }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + ctx.log("error", `request failed: ${message}`); + return { error: { status: 0, message } }; + } +} + +export const INTEGRATIONS: NodeType[] = [ + webhookSend, + slackMessage, + emailSend, + llmComplete, +]; diff --git a/meridian/src/engine/registry.ts b/meridian/src/engine/registry.ts new file mode 100644 index 0000000..645ddff --- /dev/null +++ b/meridian/src/engine/registry.ts @@ -0,0 +1,32 @@ +import type { NodeTypeSpec } from "../domain/types.js"; +import type { NodeType } from "./context.js"; + +/** + * The registry is the product's extension point: every capability is a + * NodeType registered here. The engine and API only ever talk to the registry, + * so adding a feature never means editing the engine. + */ +export class NodeRegistry { + private types = new Map(); + + register(type: NodeType): this { + if (this.types.has(type.spec.type)) { + throw new Error(`Node type already registered: ${type.spec.type}`); + } + this.types.set(type.spec.type, type); + return this; + } + + get(type: string): NodeType | undefined { + return this.types.get(type); + } + + has(type: string): boolean { + return this.types.has(type); + } + + /** The catalog the UI uses to build its palette and config forms. */ + catalog(): NodeTypeSpec[] { + return [...this.types.values()].map((t) => t.spec); + } +} diff --git a/meridian/src/engine/validate.ts b/meridian/src/engine/validate.ts new file mode 100644 index 0000000..ca71157 --- /dev/null +++ b/meridian/src/engine/validate.ts @@ -0,0 +1,136 @@ +import type { Workflow } from "../domain/types.js"; +import type { NodeRegistry } from "./registry.js"; +import type { ValidationIssue } from "../util/errors.js"; +import { topoSort } from "../util/graph.js"; + +/** + * Static validation of a workflow against the node registry. This runs before + * any execution and before persistence, so a malformed map can never start a + * run. Returns a full issue list (errors + warnings) rather than throwing, so + * the UI can show everything at once. + */ +export function validateWorkflow( + wf: Workflow, + registry: NodeRegistry, +): ValidationIssue[] { + const issues: ValidationIssue[] = []; + const nodeById = new Map(wf.nodes.map((n) => [n.id, n])); + + // Duplicate node ids. + const seen = new Set(); + for (const n of wf.nodes) { + if (seen.has(n.id)) { + issues.push({ + level: "error", + code: "DUP_NODE_ID", + message: `Duplicate node id '${n.id}'`, + nodeId: n.id, + }); + } + seen.add(n.id); + } + + // Node types + port existence. + for (const n of wf.nodes) { + const type = registry.get(n.type); + if (!type) { + issues.push({ + level: "error", + code: "UNKNOWN_TYPE", + message: `Unknown node type '${n.type}'`, + nodeId: n.id, + }); + continue; + } + for (const f of type.spec.fields) { + if ( + f.required && + (n.config[f.key] === undefined || + n.config[f.key] === null || + n.config[f.key] === "") + ) { + issues.push({ + level: "error", + code: "MISSING_CONFIG", + message: `Node '${n.name || n.id}' is missing required field '${f.key}'`, + nodeId: n.id, + }); + } + } + } + + // Edge integrity. + for (const e of wf.edges) { + const from = nodeById.get(e.from.node); + const to = nodeById.get(e.to.node); + if (!from) { + issues.push({ + level: "error", + code: "EDGE_BAD_SOURCE", + message: `Edge '${e.id}' references missing source node '${e.from.node}'`, + edgeId: e.id, + }); + } else { + const t = registry.get(from.type); + if (t && !t.spec.outputs.some((p) => p.name === e.from.port)) { + issues.push({ + level: "error", + code: "EDGE_BAD_SOURCE_PORT", + message: `Node '${from.name || from.id}' has no output port '${e.from.port}'`, + edgeId: e.id, + nodeId: from.id, + }); + } + } + if (!to) { + issues.push({ + level: "error", + code: "EDGE_BAD_TARGET", + message: `Edge '${e.id}' references missing target node '${e.to.node}'`, + edgeId: e.id, + }); + } else { + const t = registry.get(to.type); + if (t && !t.spec.inputs.some((p) => p.name === e.to.port)) { + issues.push({ + level: "error", + code: "EDGE_BAD_TARGET_PORT", + message: `Node '${to.name || to.id}' has no input port '${e.to.port}'`, + edgeId: e.id, + nodeId: to.id, + }); + } + } + } + + // Acyclicity. + const topo = topoSort(wf); + if (topo.cycle.length > 0) { + issues.push({ + level: "error", + code: "CYCLE", + message: `Workflow contains a cycle involving: ${topo.cycle.join(", ")}`, + }); + } + + // Warnings: unreachable nodes with no inbound edge that are not entry points. + const hasInbound = new Set(wf.edges.map((e) => e.to.node)); + for (const n of wf.nodes) { + const t = registry.get(n.type); + const isEntry = t ? t.spec.inputs.length === 0 : false; + if (!isEntry && !hasInbound.has(n.id)) { + issues.push({ + level: "warning", + code: "ORPHAN", + message: `Node '${n.name || n.id}' has no incoming connection and will not run.`, + nodeId: n.id, + }); + } + } + + return issues; +} + +export function hasErrors(issues: ValidationIssue[]): boolean { + return issues.some((i) => i.level === "error"); +} diff --git a/meridian/src/index.ts b/meridian/src/index.ts new file mode 100644 index 0000000..b821459 --- /dev/null +++ b/meridian/src/index.ts @@ -0,0 +1,15 @@ +import { createApp } from "./app.js"; + +/** Server entry point. */ +const app = createApp(); + +app.start().then(({ port, host }) => { + // eslint-disable-next-line no-console + console.log(`\n Meridian running at http://${host}:${port}\n`); +}); + +for (const sig of ["SIGINT", "SIGTERM"] as const) { + process.on(sig, () => { + void app.stop().then(() => process.exit(0)); + }); +} diff --git a/meridian/src/mcp/format.ts b/meridian/src/mcp/format.ts new file mode 100644 index 0000000..ecb072e --- /dev/null +++ b/meridian/src/mcp/format.ts @@ -0,0 +1,138 @@ +import type { Run, Workflow } from "../domain/types.js"; +import type { ValidationIssue } from "../util/errors.js"; + +/** Maximum response size before we truncate list payloads. */ +export const CHARACTER_LIMIT = 25_000; + +export type ToolResult = { + content: { type: "text"; text: string }[]; + structuredContent?: Record; + isError?: boolean; +}; + +/** A successful tool result carrying both text and structured data. */ +export function ok( + structured: Record, + text?: string, +): ToolResult { + return { + content: [{ type: "text", text: text ?? JSON.stringify(structured, null, 2) }], + structuredContent: structured, + }; +} + +/** An error result with an actionable message. */ +export function fail(message: string, extra?: Record): ToolResult { + const structured = { error: message, ...(extra ?? {}) }; + return { + content: [{ type: "text", text: `Error: ${message}` }], + structuredContent: structured, + isError: true, + }; +} + +/** + * Turn any thrown error into an actionable tool result. Recognizes the domain + * error types so the agent gets specific guidance (and validation issues). + */ +export function fromError(err: unknown): ToolResult { + const name = (err as Error)?.name; + const message = err instanceof Error ? err.message : String(err); + if (name === "NotFoundError") { + return fail( + `${message}. List available workflows with meridian_list_workflows, or create one with meridian_create_workflow.`, + ); + } + if (name === "ValidationError") { + const issues = ((err as { issues?: ValidationIssue[] }).issues ?? []).filter( + (i) => i.level === "error", + ); + return fail( + `Workflow is invalid and cannot run. Fix these issues, then retry: ${issues + .map((i) => `[${i.code}] ${i.message}`) + .join("; ")}`, + { issues }, + ); + } + return fail(message); +} + +/** Compact projection of a workflow for list views. */ +export function workflowSummary(wf: Workflow) { + return { + id: wf.id, + name: wf.name, + description: wf.description, + nodeCount: wf.nodes.length, + edgeCount: wf.edges.length, + updatedAt: wf.updatedAt, + }; +} + +/** Compact projection of a run. */ +export function runSummary(run: Run) { + return { + id: run.id, + workflowId: run.workflowId, + status: run.status, + trigger: run.trigger.kind, + startedAt: run.startedAt, + finishedAt: run.finishedAt, + nodeResults: run.nodeRuns.map((n) => ({ + nodeId: n.nodeId, + name: n.name, + status: n.status, + attempts: n.attempts, + ...(n.error ? { error: n.error } : {}), + })), + ...(run.error ? { error: run.error } : {}), + }; +} + +/** Enforce the response character budget by halving list items if needed. */ +export function withBudget( + items: T[], + build: (items: T[], truncated: boolean) => Record, +): ToolResult { + let current = items; + let truncated = false; + let payload = build(current, truncated); + while ( + JSON.stringify(payload).length > CHARACTER_LIMIT && + current.length > 1 + ) { + current = current.slice(0, Math.max(1, Math.floor(current.length / 2))); + truncated = true; + payload = build(current, truncated); + } + return ok(payload); +} + +/** Render the node-type catalog as readable markdown. */ +export function catalogMarkdown(catalog: import("../domain/types.js").NodeTypeSpec[]): string { + const byCat = new Map(); + for (const t of catalog) { + const arr = byCat.get(t.category) ?? []; + arr.push(t); + byCat.set(t.category, arr); + } + const lines = ["# Meridian node types", ""]; + for (const [cat, types] of byCat) { + lines.push(`## ${cat}`); + for (const t of types) { + const ins = t.inputs.map((p) => p.name).join(", ") || "—"; + const outs = t.outputs.map((p) => p.name).join(", ") || "—"; + lines.push(`- **${t.type}** (${t.label}): ${t.description}`); + lines.push(` - inputs: ${ins} → outputs: ${outs}`); + if (t.fields.length) { + lines.push( + ` - config: ${t.fields + .map((f) => `${f.key}${f.required ? "*" : ""}:${f.type}`) + .join(", ")}`, + ); + } + } + lines.push(""); + } + return lines.join("\n"); +} diff --git a/meridian/src/mcp/http-index.ts b/meridian/src/mcp/http-index.ts new file mode 100644 index 0000000..bba772d --- /dev/null +++ b/meridian/src/mcp/http-index.ts @@ -0,0 +1,8 @@ +import { main } from "./http.js"; + +main().catch((err) => { + process.stderr.write( + `meridian-mcp-server (HTTP) failed to start: ${err instanceof Error ? err.stack : String(err)}\n`, + ); + process.exit(1); +}); diff --git a/meridian/src/mcp/http.ts b/meridian/src/mcp/http.ts new file mode 100644 index 0000000..3e51b2e --- /dev/null +++ b/meridian/src/mcp/http.ts @@ -0,0 +1,134 @@ +import http from "node:http"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import { loadConfig } from "../config.js"; +import { JsonStore } from "../store/jsonStore.js"; +import { defaultRegistry } from "../engine/nodes/index.js"; +import { WorkflowService } from "../service.js"; +import { registerTools } from "./tools.js"; + +/** + * Remote MCP transport: exposes the same tools over **Streamable HTTP** so the + * server can be hosted and reached by remote clients, not just spawned locally + * over stdio. + * + * It runs **stateless** (no server-side sessions): each POST gets a fresh + * McpServer + transport that share the one long-lived WorkflowService (and thus + * the same store). Stateless is simpler to scale and matches MCP best practice + * for remote servers. GET/DELETE are rejected since there is no session stream. + */ +export function buildMcpHttpServer(): http.Server { + const config = loadConfig(); + const store = new JsonStore(config.dataDir); + const registry = defaultRegistry(); + const service = new WorkflowService(store, registry); + + return http.createServer((req, res) => { + void handle(req, res, service).catch((err) => { + if (!res.headersSent) { + res.writeHead(500, { "content-type": "application/json" }); + res.end( + JSON.stringify({ + jsonrpc: "2.0", + error: { code: -32603, message: "Internal server error" }, + id: null, + }), + ); + } + process.stderr.write(`mcp-http error: ${String(err)}\n`); + }); + }); +} + +async function handle( + req: http.IncomingMessage, + res: http.ServerResponse, + service: WorkflowService, +): Promise { + const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`); + + if (url.pathname === "/health") { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ status: "ok" })); + return; + } + + if (url.pathname !== "/mcp") { + res.writeHead(404, { "content-type": "application/json" }); + res.end(JSON.stringify({ error: "Not found. POST JSON-RPC to /mcp." })); + return; + } + + // Stateless: sessions are not supported, so only POST carries requests. + if (req.method !== "POST") { + res.writeHead(405, { + "content-type": "application/json", + allow: "POST", + }); + res.end( + JSON.stringify({ + jsonrpc: "2.0", + error: { code: -32000, message: "Method not allowed. Use POST /mcp." }, + id: null, + }), + ); + return; + } + + const body = await readJson(req); + + // Fresh server + transport per request; share the service/store. + const server = new McpServer({ name: "meridian-mcp-server", version: "0.1.0" }); + registerTools(server, service); + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, // stateless + enableJsonResponse: true, + }); + + res.on("close", () => { + void transport.close(); + void server.close(); + }); + + await server.connect(transport); + await transport.handleRequest(req, res, body); +} + +function readJson(req: http.IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + let size = 0; + const MAX = 5 * 1024 * 1024; + req.on("data", (c: Buffer) => { + size += c.length; + if (size > MAX) { + reject(new Error("Payload too large")); + req.destroy(); + return; + } + chunks.push(c); + }); + req.on("end", () => { + const raw = Buffer.concat(chunks).toString("utf8"); + if (!raw) return resolve(undefined); + try { + resolve(JSON.parse(raw)); + } catch { + resolve(undefined); + } + }); + req.on("error", reject); + }); +} + +/** Entry point for `npm run mcp:http`. */ +export async function main(): Promise { + const server = buildMcpHttpServer(); + const port = Number(process.env.MCP_PORT ?? 8788); + const host = process.env.HOST ?? "0.0.0.0"; + server.listen(port, host, () => { + process.stderr.write( + `meridian-mcp-server (Streamable HTTP) on http://${host}:${port}/mcp\n`, + ); + }); +} diff --git a/meridian/src/mcp/index.ts b/meridian/src/mcp/index.ts new file mode 100644 index 0000000..8c41301 --- /dev/null +++ b/meridian/src/mcp/index.ts @@ -0,0 +1,8 @@ +import { main } from "./server.js"; + +main().catch((err) => { + process.stderr.write( + `meridian-mcp-server failed to start: ${err instanceof Error ? err.stack : String(err)}\n`, + ); + process.exit(1); +}); diff --git a/meridian/src/mcp/schemas.ts b/meridian/src/mcp/schemas.ts new file mode 100644 index 0000000..a2c096a --- /dev/null +++ b/meridian/src/mcp/schemas.ts @@ -0,0 +1,89 @@ +import { z } from "zod"; + +/** + * Zod schemas mirroring the engine's domain, used to validate tool inputs at + * the MCP boundary. Kept as reusable pieces; tools compose them into raw + * input shapes (the shape the SDK's registerTool expects). + */ + +export const ResponseFormat = z.enum(["markdown", "json"]); + +export const positionSchema = z + .object({ + x: z.number().describe("Canvas X coordinate"), + y: z.number().describe("Canvas Y coordinate"), + }) + .describe("Optional canvas position for the node"); + +export const nodeSchema = z.object({ + id: z + .string() + .min(1) + .describe("Unique node id within the workflow (e.g. 'check_amount')"), + type: z + .string() + .min(1) + .describe("A registered node type (see meridian_list_node_types)"), + name: z.string().default("").describe("Human-readable label"), + config: z + .record(z.string(), z.unknown()) + .default({}) + .describe( + "Per-node settings. String values may contain {{ expressions }} evaluated against input/vars/trigger/nodes.", + ), + position: positionSchema.optional(), + retries: z + .number() + .int() + .min(0) + .max(5) + .optional() + .describe("Retry attempts on failure (exponential backoff)"), + timeoutMs: z + .number() + .int() + .min(1) + .optional() + .describe("Per-node timeout in milliseconds"), + onError: z + .enum(["stop", "continue"]) + .optional() + .describe("stop = fail the run; continue = skip this node's descendants"), +}); + +export const edgeSchema = z.object({ + id: z.string().min(1).describe("Unique edge id"), + from: z + .object({ + node: z.string().describe("Source node id"), + port: z.string().describe("Source output port name"), + }) + .describe("Edge source"), + to: z + .object({ + node: z.string().describe("Target node id"), + port: z.string().describe("Target input port name"), + }) + .describe("Edge target"), +}); + +export type NodeInput = z.infer; +export type EdgeInput = z.infer; + +/** Reusable field: how a workflow's graph is provided to create/update. */ +export const graphFields = { + nodes: z + .array(nodeSchema) + .optional() + .describe("The workflow's nodes. Replaces the existing set when provided."), + edges: z + .array(edgeSchema) + .optional() + .describe( + "Directed connections between node ports. Replaces the existing set when provided.", + ), + variables: z + .record(z.string(), z.unknown()) + .optional() + .describe("Workflow-scoped constants, referenced in expressions as vars.*"), +}; diff --git a/meridian/src/mcp/server.ts b/meridian/src/mcp/server.ts new file mode 100644 index 0000000..c354e12 --- /dev/null +++ b/meridian/src/mcp/server.ts @@ -0,0 +1,37 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { loadConfig } from "../config.js"; +import { JsonStore } from "../store/jsonStore.js"; +import { defaultRegistry } from "../engine/nodes/index.js"; +import { WorkflowService } from "../service.js"; +import { registerTools } from "./tools.js"; + +/** + * meridian-mcp-server — exposes the Meridian automation engine to any MCP + * client (Claude, IDEs, agents) over stdio. The server runs the engine + * in-process against the same JSON store the HTTP app uses, so an agent and a + * human can collaborate on the same workflows. + */ +export async function createMcpServer(): Promise { + const config = loadConfig(); + const store = new JsonStore(config.dataDir); + const registry = defaultRegistry(); + const service = new WorkflowService(store, registry); + + const server = new McpServer({ + name: "meridian-mcp-server", + version: "0.1.0", + }); + + registerTools(server, service); + return server; +} + +/** Start the server on stdio. */ +export async function main(): Promise { + const server = await createMcpServer(); + const transport = new StdioServerTransport(); + await server.connect(transport); + // Never log to stdout on stdio transport — it corrupts the JSON-RPC stream. + process.stderr.write("meridian-mcp-server ready on stdio\n"); +} diff --git a/meridian/src/mcp/tools.ts b/meridian/src/mcp/tools.ts new file mode 100644 index 0000000..1f6d8e0 --- /dev/null +++ b/meridian/src/mcp/tools.ts @@ -0,0 +1,406 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import type { WorkflowService } from "../service.js"; +import type { Node, TriggerInfo } from "../domain/types.js"; +import { hasErrors } from "../engine/validate.js"; +import { graphFields, nodeSchema, ResponseFormat } from "./schemas.js"; +import { + ok, + fail, + fromError, + workflowSummary, + runSummary, + withBudget, + catalogMarkdown, + type ToolResult, +} from "./format.js"; + +/** + * Register every Meridian tool on the MCP server. Tools are a comprehensive, + * composable surface over the automation engine: introspect the building + * blocks, author workflows (the "map"), validate them, and run them. + */ +export function registerTools(server: McpServer, service: WorkflowService): void { + // ---- Introspection ---------------------------------------------------- + server.registerTool( + "meridian_list_node_types", + { + title: "List node types", + description: `List every available node type (the building blocks of an automation). + +Call this FIRST when authoring a workflow so you know which node 'type' values exist, what input/output ports each has, and what config fields they take. Node config string values may contain {{ expressions }} (e.g. "{{ input.in.amount > 100 }}") evaluated against input / vars / trigger / nodes. + +Args: + - response_format ('markdown' | 'json'): 'markdown' (default) is easiest to read; 'json' returns the full machine-readable specs. + +Returns: the node-type catalog, each with type, label, category, description, inputs[], outputs[], and config fields[].`, + inputSchema: { response_format: ResponseFormat.default("markdown") }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + }, + async ({ response_format }): Promise => { + try { + const catalog = service.catalog(); + if (response_format === "markdown") { + return { + content: [{ type: "text", text: catalogMarkdown(catalog) }], + structuredContent: { count: catalog.length, nodeTypes: catalog }, + }; + } + return ok({ count: catalog.length, nodeTypes: catalog }); + } catch (err) { + return fromError(err); + } + }, + ); + + server.registerTool( + "meridian_list_workflows", + { + title: "List workflows", + description: `List all automation workflows with summary info (id, name, node/edge counts, last updated). + +Use this to discover existing workflows before reading, running, or updating one. + +Args: + - response_format ('markdown' | 'json'): output format (default 'json'). + +Returns: { count, workflows: [{ id, name, description, nodeCount, edgeCount, updatedAt }] }`, + inputSchema: { response_format: ResponseFormat.default("json") }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + }, + async (): Promise => { + try { + const list = await service.list(); + return withBudget(list, (items, truncated) => ({ + count: list.length, + shown: items.length, + ...(truncated ? { truncated: true } : {}), + workflows: items.map(workflowSummary), + })); + } catch (err) { + return fromError(err); + } + }, + ); + + server.registerTool( + "meridian_get_workflow", + { + title: "Get workflow", + description: `Get a single workflow in full, including all nodes, edges, and variables. + +Args: + - id (string): the workflow id (from meridian_list_workflows). + +Returns: the complete Workflow object. Use this before updating so you can send back a full, correct graph.`, + inputSchema: { id: z.string().min(1).describe("Workflow id") }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + }, + async ({ id }): Promise => { + try { + const wf = await service.get(id); + return ok({ workflow: wf }); + } catch (err) { + return fromError(err); + } + }, + ); + + // ---- Authoring -------------------------------------------------------- + server.registerTool( + "meridian_create_workflow", + { + title: "Create workflow", + description: `Create a new automation workflow (a graph of nodes connected by edges). + +A workflow models a business process: a 'trigger' or 'manual.input' node starts it, edges carry data from a source node's output port to a target node's input port, and 'condition' nodes branch the flow. Call meridian_list_node_types first to see valid node types and ports. + +The response includes a validation report — if 'valid' is false, fix the listed issues with meridian_update_workflow before running. + +Args: + - name (string, required): workflow name. + - description (string): what the automation does. + - nodes (Node[]): nodes to add. Each: { id, type, name?, config?, position?, retries?, timeoutMs?, onError? }. + - edges (Edge[]): connections. Each: { id, from:{node,port}, to:{node,port} }. + - variables (object): workflow constants referenced as vars.* in expressions. + +Returns: { workflow, validation: { valid, issues } }`, + inputSchema: { + name: z.string().min(1).describe("Workflow name"), + description: z.string().optional().describe("What the automation does"), + ...graphFields, + }, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false, + }, + }, + async (args): Promise => { + try { + const wf = await service.create({ + name: args.name, + description: args.description ?? "", + nodes: args.nodes ? normalizeNodes(args.nodes) : [], + edges: args.edges ?? [], + variables: args.variables ?? {}, + }); + const issues = service.validate(wf); + return ok({ + workflow: wf, + validation: { valid: !hasErrors(issues), issues }, + }); + } catch (err) { + return fromError(err); + } + }, + ); + + server.registerTool( + "meridian_update_workflow", + { + title: "Update workflow", + description: `Update an existing workflow. Any provided field replaces the current value; omitted fields are left unchanged. To edit a graph, send the FULL nodes/edges arrays (get the current state with meridian_get_workflow first). + +The response includes a fresh validation report. + +Args: + - id (string, required): workflow id. + - name, description (string): optional metadata updates. + - nodes (Node[]): full replacement node set. + - edges (Edge[]): full replacement edge set. + - variables (object): full replacement variables. + +Returns: { workflow, validation: { valid, issues } }`, + inputSchema: { + id: z.string().min(1).describe("Workflow id"), + name: z.string().optional().describe("New name"), + description: z.string().optional().describe("New description"), + ...graphFields, + }, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + }, + async (args): Promise => { + try { + const wf = await service.update(args.id, { + ...(args.name !== undefined ? { name: args.name } : {}), + ...(args.description !== undefined ? { description: args.description } : {}), + ...(args.nodes !== undefined ? { nodes: normalizeNodes(args.nodes) } : {}), + ...(args.edges !== undefined ? { edges: args.edges } : {}), + ...(args.variables !== undefined ? { variables: args.variables } : {}), + }); + const issues = service.validate(wf); + return ok({ + workflow: wf, + validation: { valid: !hasErrors(issues), issues }, + }); + } catch (err) { + return fromError(err); + } + }, + ); + + server.registerTool( + "meridian_delete_workflow", + { + title: "Delete workflow", + description: `Permanently delete a workflow and its run history. This cannot be undone. + +Args: + - id (string, required): workflow id. + +Returns: { deleted: true, id }`, + inputSchema: { id: z.string().min(1).describe("Workflow id to delete") }, + annotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: true, + openWorldHint: false, + }, + }, + async ({ id }): Promise => { + try { + await service.remove(id); + return ok({ deleted: true, id }, `Deleted workflow ${id}.`); + } catch (err) { + return fromError(err); + } + }, + ); + + server.registerTool( + "meridian_validate_workflow", + { + title: "Validate workflow", + description: `Statically validate a workflow without running it: checks that node types exist, edge ports are valid, there are no cycles, and required config is present. Also reports warnings (e.g. orphan nodes). + +Args: + - id (string, required): workflow id. + +Returns: { valid, errorCount, warningCount, issues: [{ level, code, message, nodeId?, edgeId? }] }`, + inputSchema: { id: z.string().min(1).describe("Workflow id") }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + }, + async ({ id }): Promise => { + try { + const wf = await service.get(id); + const issues = service.validate(wf); + return ok({ + valid: !hasErrors(issues), + errorCount: issues.filter((i) => i.level === "error").length, + warningCount: issues.filter((i) => i.level === "warning").length, + issues, + }); + } catch (err) { + return fromError(err); + } + }, + ); + + // ---- Execution -------------------------------------------------------- + server.registerTool( + "meridian_run_workflow", + { + title: "Run workflow", + description: `Execute a workflow now and return the result. The engine validates the graph, runs nodes in dependency order, evaluates conditions to branch, and records each node's outcome. Nodes may call external services (e.g. http.request), so this can have real-world effects. + +Args: + - id (string, required): workflow id. + - input (any): the trigger payload, available in expressions as 'trigger' and emitted by the trigger node. For example { "amount": 250, "customer": "Acme" }. + +Returns: { + run: { id, status, trigger, startedAt, finishedAt, + nodeResults: [{ nodeId, name, status, attempts, error? }], error? }, + output: +} +status is one of succeeded | failed. If it failed, inspect nodeResults for the failing node, or run meridian_validate_workflow.`, + inputSchema: { + id: z.string().min(1).describe("Workflow id to run"), + input: z + .unknown() + .optional() + .describe("Trigger payload passed to the workflow"), + }, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: true, + }, + }, + async ({ id, input }): Promise => { + try { + const trigger: TriggerInfo = { kind: "manual", payload: input ?? null }; + const run = await service.runById(id, trigger); + return ok({ run: runSummary(run), output: run.output ?? {} }); + } catch (err) { + return fromError(err); + } + }, + ); + + server.registerTool( + "meridian_list_runs", + { + title: "List runs", + description: `List recent execution runs for a workflow, most recent first. + +Args: + - workflow_id (string, required): the workflow id. + - limit (number): max runs to return, 1-100 (default 20). + +Returns: { count, runs: [{ id, status, trigger, startedAt, finishedAt, nodeResults }] }`, + inputSchema: { + workflow_id: z.string().min(1).describe("Workflow id"), + limit: z.number().int().min(1).max(100).default(20).describe("Max runs"), + }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + }, + async ({ workflow_id, limit }): Promise => { + try { + const runs = await service.listRuns(workflow_id, limit); + return withBudget(runs, (items, truncated) => ({ + count: runs.length, + shown: items.length, + ...(truncated ? { truncated: true } : {}), + runs: items.map(runSummary), + })); + } catch (err) { + return fromError(err); + } + }, + ); + + server.registerTool( + "meridian_get_run", + { + title: "Get run", + description: `Get one execution run in full, including every node's input, output, and logs. + +Args: + - id (string, required): the run id (from meridian_run_workflow or meridian_list_runs). + +Returns: the complete Run object with per-node detail.`, + inputSchema: { id: z.string().min(1).describe("Run id") }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + }, + async ({ id }): Promise => { + try { + const run = await service.getRun(id); + return ok({ run }); + } catch (err) { + return fromError(err); + } + }, + ); +} + +/** Fill engine-required fields the MCP input treats as optional. */ +function normalizeNodes(nodes: z.infer[]): Node[] { + return nodes.map((n) => ({ + id: n.id, + type: n.type, + name: n.name || n.id, + config: n.config ?? {}, + position: n.position ?? { x: 0, y: 0 }, + ...(n.retries !== undefined ? { retries: n.retries } : {}), + ...(n.timeoutMs !== undefined ? { timeoutMs: n.timeoutMs } : {}), + ...(n.onError !== undefined ? { onError: n.onError } : {}), + })); +} diff --git a/meridian/src/seed.ts b/meridian/src/seed.ts new file mode 100644 index 0000000..f7538b9 --- /dev/null +++ b/meridian/src/seed.ts @@ -0,0 +1,76 @@ +import type { Workflow } from "./domain/types.js"; +import { nowIso, uuid } from "./util/id.js"; + +/** + * A ready-to-run example so the canvas isn't empty on first launch: + * an order-triage automation. A sample order is branched on its amount; + * high-value orders get an escalation message, others a standard one, and + * both paths are logged. + */ +export function exampleWorkflow(): Workflow { + const now = nowIso(); + return { + id: uuid(), + name: "Order triage (example)", + description: "Branch an incoming order on its value and route a message.", + variables: { threshold: 100 }, + nodes: [ + { + id: "order", + type: "manual.input", + name: "New order", + config: { value: { customer: "Acme Co", amount: 250 } }, + position: { x: 80, y: 120 }, + }, + { + id: "check", + type: "condition", + name: "High value?", + config: { expression: "input.in.amount > vars.threshold" }, + position: { x: 320, y: 120 }, + }, + { + id: "escalate", + type: "template", + name: "Escalation note", + config: { + text: "⚠ High-value order (${{ input.in.amount }}) from {{ input.in.customer }} — route to a human.", + }, + position: { x: 600, y: 40 }, + }, + { + id: "standard", + type: "template", + name: "Standard note", + config: { + text: "Order from {{ input.in.customer }} auto-approved.", + }, + position: { x: 600, y: 220 }, + }, + { + id: "record", + type: "log", + name: "Record", + config: { message: "{{ input.in }}", level: "info" }, + position: { x: 880, y: 130 }, + }, + ], + edges: [ + edge("order", "out", "check", "in"), + edge("check", "true", "escalate", "in"), + edge("check", "false", "standard", "in"), + edge("escalate", "out", "record", "in"), + edge("standard", "out", "record", "in"), + ], + createdAt: now, + updatedAt: now, + }; +} + +function edge(fn: string, fp: string, tn: string, tp: string) { + return { + id: `e_${fn}_${fp}_${tn}`, + from: { node: fn, port: fp }, + to: { node: tn, port: tp }, + }; +} diff --git a/meridian/src/service.ts b/meridian/src/service.ts new file mode 100644 index 0000000..674a47a --- /dev/null +++ b/meridian/src/service.ts @@ -0,0 +1,105 @@ +import type { Run, TriggerInfo, Value, Workflow } from "./domain/types.js"; +import type { Store } from "./store/store.js"; +import { Engine, type RunEvent } from "./engine/engine.js"; +import type { NodeRegistry } from "./engine/registry.js"; +import { validateWorkflow } from "./engine/validate.js"; +import type { ValidationIssue } from "./util/errors.js"; +import { NotFoundError } from "./util/errors.js"; +import { nowIso, uuid } from "./util/id.js"; + +export interface WorkflowInput { + name?: string; + description?: string; + nodes?: Workflow["nodes"]; + edges?: Workflow["edges"]; + variables?: Record; +} + +/** + * Application service: the single place the API calls into. Coordinates the + * store, the engine, and validation, and owns id/timestamp assignment so those + * concerns never leak into the HTTP layer. + */ +export class WorkflowService { + readonly engine: Engine; + + constructor( + private store: Store, + private registry: NodeRegistry, + ) { + this.engine = new Engine(registry); + } + + list(): Promise { + return this.store.listWorkflows(); + } + + async get(id: string): Promise { + const wf = await this.store.getWorkflow(id); + if (!wf) throw new NotFoundError(`Workflow '${id}'`); + return wf; + } + + async create(input: WorkflowInput): Promise { + const now = nowIso(); + const wf: Workflow = { + id: uuid(), + name: input.name?.trim() || "Untitled workflow", + description: input.description ?? "", + nodes: input.nodes ?? [], + edges: input.edges ?? [], + variables: input.variables ?? {}, + createdAt: now, + updatedAt: now, + }; + return this.store.saveWorkflow(wf); + } + + async update(id: string, input: WorkflowInput): Promise { + const existing = await this.get(id); + const updated: Workflow = { + ...existing, + name: input.name?.trim() || existing.name, + description: input.description ?? existing.description, + nodes: input.nodes ?? existing.nodes, + edges: input.edges ?? existing.edges, + variables: input.variables ?? existing.variables, + updatedAt: nowIso(), + }; + return this.store.saveWorkflow(updated); + } + + async remove(id: string): Promise { + const ok = await this.store.deleteWorkflow(id); + if (!ok) throw new NotFoundError(`Workflow '${id}'`); + } + + validate(wf: Workflow): ValidationIssue[] { + return validateWorkflow(wf, this.registry); + } + + async runById( + id: string, + trigger: TriggerInfo, + onEvent?: (e: RunEvent) => void, + ): Promise { + const wf = await this.get(id); + const run = await this.engine.run(wf, { trigger, onEvent }); + await this.store.saveRun(run); + return run; + } + + listRuns(workflowId: string, limit?: number): Promise { + return this.store.listRuns(workflowId, limit); + } + + async getRun(id: string): Promise { + const run = await this.store.getRun(id); + if (!run) throw new NotFoundError(`Run '${id}'`); + return run; + } + + catalog() { + return this.registry.catalog(); + } +} diff --git a/meridian/src/store/jsonStore.ts b/meridian/src/store/jsonStore.ts new file mode 100644 index 0000000..8af5ad7 --- /dev/null +++ b/meridian/src/store/jsonStore.ts @@ -0,0 +1,122 @@ +import { promises as fs } from "node:fs"; +import path from "node:path"; +import type { Run, Workflow } from "../domain/types.js"; +import type { Store } from "./store.js"; + +/** + * File-backed JSON store with atomic writes (write-temp-then-rename) and an + * in-memory index for fast reads. No database process required, so the whole + * app is self-contained. Runs are capped per workflow to keep files bounded. + */ +export class JsonStore implements Store { + private workflows = new Map(); + private runs = new Map(); + private ready: Promise; + + constructor( + private dir: string, + private maxRunsPerWorkflow = 200, + ) { + this.ready = this.load(); + } + + private wfFile() { + return path.join(this.dir, "workflows.json"); + } + private runsFile() { + return path.join(this.dir, "runs.json"); + } + + private async load(): Promise { + await fs.mkdir(this.dir, { recursive: true }); + this.workflows = new Map( + (await readJson(this.wfFile(), [])).map((w) => [w.id, w]), + ); + this.runs = new Map( + (await readJson(this.runsFile(), [])).map((r) => [r.id, r]), + ); + } + + private async persistWorkflows(): Promise { + await atomicWrite(this.wfFile(), [...this.workflows.values()]); + } + private async persistRuns(): Promise { + await atomicWrite(this.runsFile(), [...this.runs.values()]); + } + + async listWorkflows(): Promise { + await this.ready; + return [...this.workflows.values()].sort((a, b) => + b.updatedAt.localeCompare(a.updatedAt), + ); + } + + async getWorkflow(id: string): Promise { + await this.ready; + return this.workflows.get(id); + } + + async saveWorkflow(wf: Workflow): Promise { + await this.ready; + this.workflows.set(wf.id, wf); + await this.persistWorkflows(); + return wf; + } + + async deleteWorkflow(id: string): Promise { + await this.ready; + const existed = this.workflows.delete(id); + // Drop associated runs too. + for (const [rid, r] of this.runs) if (r.workflowId === id) this.runs.delete(rid); + if (existed) { + await this.persistWorkflows(); + await this.persistRuns(); + } + return existed; + } + + async saveRun(run: Run): Promise { + await this.ready; + this.runs.set(run.id, run); + this.trim(run.workflowId); + await this.persistRuns(); + return run; + } + + async getRun(id: string): Promise { + await this.ready; + return this.runs.get(id); + } + + async listRuns(workflowId: string, limit = 50): Promise { + await this.ready; + return [...this.runs.values()] + .filter((r) => r.workflowId === workflowId) + .sort((a, b) => b.startedAt.localeCompare(a.startedAt)) + .slice(0, limit); + } + + /** Keep only the most recent runs for a workflow. */ + private trim(workflowId: string): void { + const forWf = [...this.runs.values()] + .filter((r) => r.workflowId === workflowId) + .sort((a, b) => b.startedAt.localeCompare(a.startedAt)); + for (const stale of forWf.slice(this.maxRunsPerWorkflow)) { + this.runs.delete(stale.id); + } + } +} + +async function readJson(file: string, fallback: T): Promise { + try { + return JSON.parse(await fs.readFile(file, "utf8")) as T; + } catch { + return fallback; + } +} + +async function atomicWrite(file: string, data: unknown): Promise { + const tmp = `${file}.${process.pid}.tmp`; + await fs.writeFile(tmp, JSON.stringify(data, null, 2), "utf8"); + await fs.rename(tmp, file); +} diff --git a/meridian/src/store/store.ts b/meridian/src/store/store.ts new file mode 100644 index 0000000..c04a6d5 --- /dev/null +++ b/meridian/src/store/store.ts @@ -0,0 +1,17 @@ +import type { Run, Workflow } from "../domain/types.js"; + +/** + * Persistence boundary. The engine and API depend only on this interface, so + * the default JSON store can be swapped for Postgres/SQLite/etc. without + * touching business logic. + */ +export interface Store { + listWorkflows(): Promise; + getWorkflow(id: string): Promise; + saveWorkflow(wf: Workflow): Promise; + deleteWorkflow(id: string): Promise; + + saveRun(run: Run): Promise; + getRun(id: string): Promise; + listRuns(workflowId: string, limit?: number): Promise; +} diff --git a/meridian/src/triggers/scheduler.ts b/meridian/src/triggers/scheduler.ts new file mode 100644 index 0000000..e4458b1 --- /dev/null +++ b/meridian/src/triggers/scheduler.ts @@ -0,0 +1,68 @@ +import type { WorkflowService } from "../service.js"; +import type { Workflow } from "../domain/types.js"; + +/** + * In-process scheduler for `schedule`-mode trigger nodes. Each workflow whose + * trigger node declares `{ mode: "schedule", everyMs }` gets a repeating timer + * that fires a run. Kept intentionally simple (fixed interval, single process); + * a production deployment would swap this for a durable queue behind the same + * `sync` call. + */ +export class Scheduler { + private timers = new Map(); + private minIntervalMs = 1000; + + constructor(private service: WorkflowService) {} + + /** Reconcile timers with the current set of workflows. */ + async sync(): Promise { + const workflows = await this.service.list(); + const desired = new Map(); + for (const wf of workflows) { + const everyMs = scheduleInterval(wf); + if (everyMs) desired.set(wf.id, Math.max(everyMs, this.minIntervalMs)); + } + + // Remove timers no longer wanted or whose interval changed. + for (const [id, timer] of this.timers) { + if (!desired.has(id)) { + clearInterval(timer); + this.timers.delete(id); + } + } + // Add timers for newly-scheduled workflows. + for (const [id, everyMs] of desired) { + if (this.timers.has(id)) continue; + const timer = setInterval(() => { + void this.fire(id); + }, everyMs); + // Don't keep the process alive solely for schedules. + timer.unref?.(); + this.timers.set(id, timer); + } + } + + private async fire(workflowId: string): Promise { + try { + await this.service.runById(workflowId, { + kind: "schedule", + payload: { firedAt: new Date().toISOString() }, + }); + } catch { + // A failing scheduled run is recorded as a failed Run; nothing to do here. + } + } + + stop(): void { + for (const t of this.timers.values()) clearInterval(t); + this.timers.clear(); + } +} + +function scheduleInterval(wf: Workflow): number | undefined { + const node = wf.nodes.find((n) => n.type === "trigger"); + if (!node) return undefined; + if (node.config["mode"] !== "schedule") return undefined; + const everyMs = Number(node.config["everyMs"]); + return Number.isFinite(everyMs) && everyMs > 0 ? everyMs : undefined; +} diff --git a/meridian/src/util/errors.ts b/meridian/src/util/errors.ts new file mode 100644 index 0000000..eaa638c --- /dev/null +++ b/meridian/src/util/errors.ts @@ -0,0 +1,34 @@ +/** A validation problem found in a workflow graph before execution. */ +export interface ValidationIssue { + level: "error" | "warning"; + code: string; + message: string; + nodeId?: string; + edgeId?: string; +} + +export class ValidationError extends Error { + issues: ValidationIssue[]; + constructor(issues: ValidationIssue[]) { + super( + `Workflow validation failed with ${issues.filter((i) => i.level === "error").length} error(s)`, + ); + this.name = "ValidationError"; + this.issues = issues; + } +} + +export class NotFoundError extends Error { + constructor(what: string) { + super(`${what} not found`); + this.name = "NotFoundError"; + } +} + +/** Thrown by the engine when a node exceeds its configured timeout. */ +export class TimeoutError extends Error { + constructor(ms: number) { + super(`Node exceeded timeout of ${ms}ms`); + this.name = "TimeoutError"; + } +} diff --git a/meridian/src/util/graph.ts b/meridian/src/util/graph.ts new file mode 100644 index 0000000..25eeb42 --- /dev/null +++ b/meridian/src/util/graph.ts @@ -0,0 +1,84 @@ +import type { Workflow, Edge } from "../domain/types.js"; + +/** + * Graph algorithms over a workflow. Pure functions, no engine state — kept + * separate so they are trivially testable. + */ + +export interface Adjacency { + /** nodeId -> outgoing edges */ + out: Map; + /** nodeId -> incoming edges */ + in: Map; +} + +export function buildAdjacency(wf: Workflow): Adjacency { + const out = new Map(); + const inc = new Map(); + for (const n of wf.nodes) { + out.set(n.id, []); + inc.set(n.id, []); + } + for (const e of wf.edges) { + out.get(e.from.node)?.push(e); + inc.get(e.to.node)?.push(e); + } + return { out, in: inc }; +} + +export interface TopoResult { + /** Node ids in a valid execution order (empty if a cycle exists). */ + order: string[]; + /** Node ids participating in a cycle, if any. */ + cycle: string[]; +} + +/** + * Kahn's algorithm. Returns a topological order, or the set of nodes that + * could not be ordered because they form (or depend on) a cycle. + */ +export function topoSort(wf: Workflow): TopoResult { + const adj = buildAdjacency(wf); + const indeg = new Map(); + for (const n of wf.nodes) indeg.set(n.id, adj.in.get(n.id)!.length); + + // Deterministic ordering: seed the queue in node declaration order. + const queue: string[] = wf.nodes + .filter((n) => (indeg.get(n.id) ?? 0) === 0) + .map((n) => n.id); + + const order: string[] = []; + while (queue.length) { + const id = queue.shift()!; + order.push(id); + for (const e of adj.out.get(id) ?? []) { + const d = (indeg.get(e.to.node) ?? 0) - 1; + indeg.set(e.to.node, d); + if (d === 0) queue.push(e.to.node); + } + } + + if (order.length === wf.nodes.length) return { order, cycle: [] }; + + const cycle = wf.nodes + .map((n) => n.id) + .filter((id) => (indeg.get(id) ?? 0) > 0); + return { order: [], cycle }; +} + +/** All node ids reachable downstream from `startIds` (inclusive of neighbors). */ +export function descendants(wf: Workflow, startIds: Iterable): Set { + const adj = buildAdjacency(wf); + const seen = new Set(); + const stack = [...startIds]; + while (stack.length) { + const id = stack.pop()!; + for (const e of adj.out.get(id) ?? []) { + if (!seen.has(e.to.node)) { + seen.add(e.to.node); + stack.push(e.to.node); + } + } + } + return seen; +} diff --git a/meridian/src/util/id.ts b/meridian/src/util/id.ts new file mode 100644 index 0000000..7017928 --- /dev/null +++ b/meridian/src/util/id.ts @@ -0,0 +1,19 @@ +import { randomUUID, randomBytes } from "node:crypto"; + +/** RFC4122 v4 id, used for entity ids. */ +export function uuid(): string { + return randomUUID(); +} + +/** + * Short, url-safe, sortable-ish id for runs/nodes where a full uuid is noisy. + * Prefix keeps ids self-describing in logs. + */ +export function shortId(prefix = ""): string { + const s = randomBytes(6).toString("base64url"); + return prefix ? `${prefix}_${s}` : s; +} + +export function nowIso(): string { + return new Date().toISOString(); +} diff --git a/meridian/test/engine.test.ts b/meridian/test/engine.test.ts new file mode 100644 index 0000000..f8cf6a6 --- /dev/null +++ b/meridian/test/engine.test.ts @@ -0,0 +1,222 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { Engine } from "../src/engine/engine.js"; +import { defaultRegistry } from "../src/engine/nodes/index.js"; +import { validateWorkflow } from "../src/engine/validate.js"; +import type { Workflow, Node, Edge } from "../src/domain/types.js"; + +const registry = defaultRegistry(); +const engine = new Engine(registry); + +function build(nodes: Node[], edges: Edge[], variables = {}): Workflow { + return { + id: "w", + name: "test", + description: "", + variables, + nodes, + edges, + createdAt: "", + updatedAt: "", + }; +} +const n = (id: string, type: string, config: Record = {}, extra: Partial = {}): Node => ({ + id, + type, + name: id, + config, + position: { x: 0, y: 0 }, + ...extra, +}); +const e = (fn: string, fp: string, tn: string, tp: string): Edge => ({ + id: `${fn}.${fp}->${tn}.${tp}`, + from: { node: fn, port: fp }, + to: { node: tn, port: tp }, +}); + +test("linear flow passes data downstream", async () => { + const wf = build( + [ + n("in", "manual.input", { value: { amount: 5 } }), + n("t", "transform", { output: { doubled: "{{ input.in.amount * 2 }}" } }), + n("log", "log", { message: "result={{ input.in.doubled }}" }), + ], + [e("in", "out", "t", "in"), e("t", "out", "log", "in")], + ); + const run = await engine.run(wf); + assert.equal(run.status, "succeeded"); + const t = run.nodeRuns.find((r) => r.nodeId === "t")!; + assert.deepEqual(t.output, { out: { doubled: 10 } }); + const log = run.nodeRuns.find((r) => r.nodeId === "log")!; + assert.equal(log.logs[0]!.message, "result=10"); +}); + +test("condition takes the true branch and prunes the false branch", async () => { + const wf = build( + [ + n("in", "manual.input", { value: { amount: 250 } }), + n("c", "condition", { expression: "input.in.amount > vars.threshold" }), + n("hi", "template", { text: "high" }), + n("lo", "template", { text: "low" }), + ], + [ + e("in", "out", "c", "in"), + e("c", "true", "hi", "in"), + e("c", "false", "lo", "in"), + ], + { threshold: 100 }, + ); + const run = await engine.run(wf); + assert.equal(run.status, "succeeded"); + assert.equal(run.nodeRuns.find((r) => r.nodeId === "hi")!.status, "succeeded"); + assert.equal(run.nodeRuns.find((r) => r.nodeId === "lo")!.status, "skipped"); +}); + +test("condition false branch prunes the true side", async () => { + const wf = build( + [ + n("in", "manual.input", { value: { amount: 5 } }), + n("c", "condition", { expression: "input.in.amount > 100" }), + n("hi", "template", { text: "high" }), + n("lo", "template", { text: "low" }), + ], + [ + e("in", "out", "c", "in"), + e("c", "true", "hi", "in"), + e("c", "false", "lo", "in"), + ], + ); + const run = await engine.run(wf); + assert.equal(run.nodeRuns.find((r) => r.nodeId === "hi")!.status, "skipped"); + assert.equal(run.nodeRuns.find((r) => r.nodeId === "lo")!.status, "succeeded"); +}); + +/** A registry whose `test.boom` node always throws — deterministic failure. */ +function boomRegistry() { + const reg = defaultRegistry(); + reg.register({ + spec: { + type: "test.boom", + label: "Boom", + category: "Test", + description: "", + color: "#f00", + inputs: [{ name: "in" }], + outputs: [{ name: "out" }], + fields: [], + }, + execute() { + throw new Error("kaboom"); + }, + }); + return reg; +} + +test("a failing node stops the run by default", async () => { + const eng = new Engine(boomRegistry()); + const wf = build( + [n("in", "manual.input", { value: 1 }), n("b", "test.boom")], + [e("in", "out", "b", "in")], + ); + const run = await eng.run(wf); + assert.equal(run.status, "failed"); + assert.match(run.error ?? "", /kaboom/); +}); + +test("onError=continue lets independent branches finish", async () => { + const eng = new Engine(boomRegistry()); + const wf = build( + [ + n("in", "manual.input", { value: 1 }), + n("bad", "test.boom", {}, { onError: "continue" }), + n("after", "log", { message: "downstream" }), + n("ok", "log", { message: "independent" }), + ], + [ + e("in", "out", "bad", "in"), + e("bad", "out", "after", "in"), + e("in", "out", "ok", "in"), + ], + ); + const run = await eng.run(wf); + // whole run still succeeds because `bad` is set to continue + assert.equal(run.status, "succeeded"); + assert.equal(run.nodeRuns.find((r) => r.nodeId === "bad")!.status, "failed"); + assert.equal(run.nodeRuns.find((r) => r.nodeId === "after")!.status, "skipped"); + assert.equal(run.nodeRuns.find((r) => r.nodeId === "ok")!.status, "succeeded"); +}); + +test("retries eventually succeed and are counted", async () => { + // Register a flaky one-off type in an isolated registry. + const reg = defaultRegistry(); + let calls = 0; + reg.register({ + spec: { + type: "test.flaky", + label: "Flaky", + category: "Test", + description: "", + color: "#fff", + inputs: [], + outputs: [{ name: "out" }], + fields: [], + }, + execute() { + calls++; + if (calls < 3) throw new Error("transient"); + return { out: calls }; + }, + }); + const eng = new Engine(reg); + const wf = build([n("f", "test.flaky", {}, { retries: 5 })], []); + const run = await eng.run(wf); + assert.equal(run.status, "succeeded"); + const fr = run.nodeRuns[0]!; + assert.equal(fr.attempts, 3); + assert.deepEqual(fr.output, { out: 3 }); +}); + +test("validation rejects unknown node types and cycles", () => { + const bad = build( + [n("x", "does.not.exist")], + [], + ); + const issues = validateWorkflow(bad, registry); + assert.ok(issues.some((i) => i.code === "UNKNOWN_TYPE")); + + const cyclic = build( + [n("a", "log"), n("b", "log")], + [e("a", "out", "b", "in"), e("b", "out", "a", "in")], + ); + const cissues = validateWorkflow(cyclic, registry); + assert.ok(cissues.some((i) => i.code === "CYCLE")); +}); + +test("merge combines two branches", async () => { + const wf = build( + [ + n("a", "manual.input", { value: "A" }), + n("b", "manual.input", { value: "B" }), + n("m", "merge"), + ], + [e("a", "out", "m", "a"), e("b", "out", "m", "b")], + ); + const run = await engine.run(wf); + assert.equal(run.status, "succeeded"); + assert.deepEqual(run.nodeRuns.find((r) => r.nodeId === "m")!.output, { + out: { a: "A", b: "B" }, + }); +}); + +test("emits lifecycle events in order", async () => { + const wf = build( + [n("in", "manual.input", { value: 1 }), n("l", "log", { message: "hi" })], + [e("in", "out", "l", "in")], + ); + const events: string[] = []; + await engine.run(wf, { onEvent: (e) => events.push(e.type) }); + assert.equal(events[0], "run:start"); + assert.equal(events.at(-1), "run:finish"); + assert.ok(events.includes("node:start")); + assert.ok(events.includes("node:finish")); +}); diff --git a/meridian/test/expr.test.ts b/meridian/test/expr.test.ts new file mode 100644 index 0000000..2b243c8 --- /dev/null +++ b/meridian/test/expr.test.ts @@ -0,0 +1,74 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { evaluate, render, resolveConfig } from "../src/engine/expr.js"; + +const scope = { + input: { name: "ada", amount: 250, tags: ["vip", "eu"] }, + vars: { threshold: 100 }, + trigger: { source: "webhook" }, + nodes: { a: { out: 7 } }, +}; + +test("literals and arithmetic", () => { + assert.equal(evaluate("1 + 2 * 3", {}), 7); + assert.equal(evaluate("(1 + 2) * 3", {}), 9); + assert.equal(evaluate("10 % 3", {}), 1); + assert.equal(evaluate("-5 + 2", {}), -3); +}); + +test("comparison and logic", () => { + assert.equal(evaluate("input.amount > vars.threshold", scope), true); + assert.equal(evaluate("input.amount < 100", scope), false); + assert.equal(evaluate("input.amount >= 250 && input.name == 'ada'", scope), true); + assert.equal(evaluate("false || input.amount == 250", scope), true); + assert.equal(evaluate("!(input.amount == 1)", scope), true); +}); + +test("member and index access", () => { + assert.equal(evaluate("input.tags[0]", scope), "vip"); + assert.equal(evaluate("nodes.a.out", scope), 7); + assert.equal(evaluate("input.missing", scope), undefined); + assert.equal(evaluate("input.missing.deep", scope), undefined); +}); + +test("helper functions", () => { + assert.equal(evaluate("upper(input.name)", scope), "ADA"); + assert.equal(evaluate("len(input.tags)", scope), 2); + assert.equal(evaluate("default(input.missing, 'fallback')", scope), "fallback"); + assert.equal(evaluate("round(2.6)", {}), 3); + assert.equal(evaluate("max(1, 9, 4)", {}), 9); +}); + +test("render: single span returns raw typed value", () => { + assert.equal(render("{{ input.amount }}", scope), 250); + assert.deepEqual(render("{{ input.tags }}", scope), ["vip", "eu"]); + assert.equal(typeof render("{{ input.amount }}", scope), "number"); +}); + +test("render: interpolation coerces to string", () => { + assert.equal(render("Hi {{ upper(input.name) }}!", scope), "Hi ADA!"); + assert.equal( + render("{{ input.name }} owes {{ input.amount }}", scope), + "ada owes 250", + ); +}); + +test("resolveConfig walks objects and arrays", () => { + const cfg = { + url: "https://x/{{ input.name }}", + n: "{{ input.amount }}", + nested: { list: ["{{ input.tags[0] }}", "static"] }, + untouched: 5, + }; + const out = resolveConfig(cfg, scope) as any; + assert.equal(out.url, "https://x/ada"); + assert.equal(out.n, 250); // raw typed value preserved + assert.equal(out.nested.list[0], "vip"); + assert.equal(out.untouched, 5); +}); + +test("no eval: unknown function throws, not executes", () => { + assert.throws(() => evaluate("danger(1)", {}), /Unknown function/); + // Method-style calls on objects are not part of the grammar at all. + assert.throws(() => evaluate("process.exit(1)", {})); +}); diff --git a/meridian/test/graph.test.ts b/meridian/test/graph.test.ts new file mode 100644 index 0000000..0cb488e --- /dev/null +++ b/meridian/test/graph.test.ts @@ -0,0 +1,72 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { topoSort, descendants } from "../src/util/graph.js"; +import type { Workflow } from "../src/domain/types.js"; + +function wf(nodeIds: string[], edges: [string, string][]): Workflow { + return { + id: "w", + name: "w", + description: "", + variables: {}, + createdAt: "", + updatedAt: "", + nodes: nodeIds.map((id) => ({ + id, + type: "log", + name: id, + config: {}, + position: { x: 0, y: 0 }, + })), + edges: edges.map(([f, t], i) => ({ + id: "e" + i, + from: { node: f, port: "out" }, + to: { node: t, port: "in" }, + })), + }; +} + +test("topological order respects dependencies", () => { + const g = wf( + ["a", "b", "c", "d"], + [ + ["a", "b"], + ["a", "c"], + ["b", "d"], + ["c", "d"], + ], + ); + const { order, cycle } = topoSort(g); + assert.equal(cycle.length, 0); + assert.equal(order.length, 4); + assert.ok(order.indexOf("a") < order.indexOf("b")); + assert.ok(order.indexOf("b") < order.indexOf("d")); + assert.ok(order.indexOf("c") < order.indexOf("d")); +}); + +test("cycle is detected and reported", () => { + const g = wf( + ["a", "b", "c"], + [ + ["a", "b"], + ["b", "c"], + ["c", "a"], + ], + ); + const { order, cycle } = topoSort(g); + assert.equal(order.length, 0); + assert.deepEqual(new Set(cycle), new Set(["a", "b", "c"])); +}); + +test("descendants collects everything downstream", () => { + const g = wf( + ["a", "b", "c", "d"], + [ + ["a", "b"], + ["b", "c"], + ["a", "d"], + ], + ); + assert.deepEqual(descendants(g, ["b"]), new Set(["c"])); + assert.deepEqual(descendants(g, ["a"]), new Set(["b", "c", "d"])); +}); diff --git a/meridian/test/integrations.test.ts b/meridian/test/integrations.test.ts new file mode 100644 index 0000000..c05da2b --- /dev/null +++ b/meridian/test/integrations.test.ts @@ -0,0 +1,85 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { Engine } from "../src/engine/engine.js"; +import { defaultRegistry } from "../src/engine/nodes/index.js"; +import type { Workflow, Node, Edge } from "../src/domain/types.js"; + +const registry = defaultRegistry(); +const engine = new Engine(registry); + +function build(nodes: Node[], edges: Edge[]): Workflow { + return { + id: "w", + name: "t", + description: "", + variables: {}, + nodes, + edges, + createdAt: "", + updatedAt: "", + }; +} +const node = (id: string, type: string, config: Record = {}): Node => ({ + id, + type, + name: id, + config, + position: { x: 0, y: 0 }, +}); +const edge = (fn: string, fp: string, tn: string, tp: string): Edge => ({ + id: `${fn}.${fp}->${tn}.${tp}`, + from: { node: fn, port: fp }, + to: { node: tn, port: tp }, +}); + +test("integration nodes are registered", () => { + const types = registry.catalog().map((t) => t.type); + for (const t of ["webhook.send", "slack.message", "email.send", "llm.complete"]) { + assert.ok(types.includes(t), `missing ${t}`); + } + assert.equal(registry.catalog().length, 14); +}); + +test("slack.message without a webhook routes to the error port (no network)", async () => { + // Ensure no ambient env credential interferes. + delete process.env.SLACK_WEBHOOK_URL; + const wf = build( + [ + node("in", "manual.input", { value: "hello" }), + node("slack", "slack.message", { text: "{{ input.in }}" }), + node("okPath", "log", { message: "sent" }), + node("errPath", "log", { message: "failed" }), + ], + [ + edge("in", "out", "slack", "in"), + edge("slack", "out", "okPath", "in"), + edge("slack", "error", "errPath", "in"), + ], + ); + const run = await engine.run(wf); + assert.equal(run.status, "succeeded"); + const status = (id: string) => run.nodeRuns.find((r) => r.nodeId === id)!.status; + // slack emitted only on `error`, so okPath is pruned and errPath runs. + assert.equal(status("okPath"), "skipped"); + assert.equal(status("errPath"), "succeeded"); +}); + +test("email.send and llm.complete fail gracefully without credentials", async () => { + delete process.env.RESEND_API_KEY; + delete process.env.ANTHROPIC_API_KEY; + const wf = build( + [ + node("email", "email.send", { from: "a@b.co", to: "c@d.co", subject: "hi", html: "x" }), + node("llm", "llm.complete", { prompt: "hi" }), + ], + [], + ); + const run = await engine.run(wf); + // Both emit on their `error` port but do not throw, so the run succeeds. + assert.equal(run.status, "succeeded"); + const email = run.nodeRuns.find((r) => r.nodeId === "email")!; + const llm = run.nodeRuns.find((r) => r.nodeId === "llm")!; + assert.equal(email.status, "succeeded"); + assert.match(JSON.stringify(email.output), /Resend API key/); + assert.match(JSON.stringify(llm.output), /Anthropic API key/); +}); diff --git a/meridian/tsconfig.build.json b/meridian/tsconfig.build.json new file mode 100644 index 0000000..5faac24 --- /dev/null +++ b/meridian/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "test"] +} diff --git a/meridian/tsconfig.json b/meridian/tsconfig.json new file mode 100644 index 0000000..9b5ce0d --- /dev/null +++ b/meridian/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2023"], + "outDir": "dist", + "rootDir": ".", + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": false, + "noImplicitOverride": true, + "forceConsistentCasingInFileNames": true, + "verbatimModuleSyntax": false, + "skipLibCheck": true, + "resolveJsonModule": true, + "declaration": false, + "sourceMap": false + }, + "include": ["src/**/*.ts", "test/**/*.ts"], + "exclude": ["node_modules", "dist"] +}